2 layouts

This commit is contained in:
Dobromir Popov
2025-08-08 15:15:07 +03:00
parent 65012289b9
commit 164616f803

View File

@ -3,16 +3,16 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>4 cm Sticker Sheet Hex Packed (Cutline Optional)</title> <title>4 cm Sticker Sheet Hex vs Grid</title>
<style> <style>
:root { :root {
--sticker-diameter-cm: 4; /* Sticker content diameter (cm) */ --sticker-diameter-cm: 4; /* Content circle diameter */
--gutter-cm: 0; /* Spacing between sticker edges (cm) */ --gutter-cm: 0; /* Space between outer edges */
--margin-cm: 0; /* Page margin/safety (cm) */ --margin-cm: 0; /* Safety/printer margin */
--outline-on: 0; /* 1 = show cut outline, 0 = hide */ --outline-on: 0; /* 1 show outline, 0 none */
--outline-mm: 0.3; /* Outline width in mm when enabled */ --outline-mm: 0.3; /* Outline width when enabled */
--fit-mode: cover; /* cover|contain image fit */ --fit-mode: cover; /* cover|contain */
--page-width-cm: 21; /* Default A4 portrait */ --page-width-cm: 21; /* A4 by default */
--page-height-cm: 29.7; --page-height-cm: 29.7;
} }
@ -73,14 +73,11 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
} }
.hint { .hint, .stats {
font-size: 12px; font-size: 12px;
color: #6b7280; color: #475569;
}
.stats {
font-size: 13px;
color: #334155;
} }
.stats strong { color: #0f172a; }
.pages { .pages {
padding: 12px; padding: 12px;
@ -94,7 +91,7 @@
position: relative; position: relative;
box-shadow: 0 1px 4px rgba(0,0,0,0.08); box-shadow: 0 1px 4px rgba(0,0,0,0.08);
outline: 1px dashed #e5e7eb; outline: 1px dashed #e5e7eb;
width: calc(var(--page-width-cm) * 37.8px); width: calc(var(--page-width-cm) * 37.8px); /* screen preview only */
height: calc(var(--page-height-cm) * 37.8px); height: calc(var(--page-height-cm) * 37.8px);
} }
@ -109,34 +106,26 @@
.sticker { .sticker {
position: absolute; position: absolute;
width: calc(var(--sticker-diameter-cm) * 1cm); width: calc(var(--sticker-diameter-cm) * 1cm); /* content circle */
height: calc(var(--sticker-diameter-cm) * 1cm); height: calc(var(--sticker-diameter-cm) * 1cm);
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
box-sizing: content-box; /* outline adds to outer size */
/* Important: content-box so outline adds to outer size.
If outline is OFF, no extra thickness; if ON, increases
outer diameter by 2 * outline width. */
box-sizing: content-box;
/* Optional cut outline (hairline). */
border: calc(var(--outline-on) * var(--outline-mm) * 1mm) solid rgba(0,0,0,0.28); border: calc(var(--outline-on) * var(--outline-mm) * 1mm) solid rgba(0,0,0,0.28);
background: #f8fafc center/var(--fit-mode) no-repeat; background: #f8fafc center/var(--fit-mode) no-repeat;
} }
.sticker img { .sticker img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: var(--fit-mode); object-fit: var(--fit-mode);
display: block;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
@media print { @media print {
body { background: white; } body { background: white; }
.controls, .hint-bar { display: none !important; } .controls { display: none !important; }
.pages { padding: 0; } .pages { padding: 0; }
.page { .page {
width: auto !important; width: auto !important;
@ -157,6 +146,13 @@
<div class="controls"> <div class="controls">
<div class="row"> <div class="row">
<label>
Layout
<select id="layout">
<option value="hex" selected>Hex (max)</option>
<option value="grid">Grid (rows & columns)</option>
</select>
</label>
<label> <label>
Sticker Ø (cm) Sticker Ø (cm)
<input id="diameter" type="number" step="0.1" min="1" value="4"> <input id="diameter" type="number" step="0.1" min="1" value="4">
@ -169,6 +165,15 @@
Printer margin (cm) Printer margin (cm)
<input id="margin" type="number" step="0.1" min="0" value="0"> <input id="margin" type="number" step="0.1" min="0" value="0">
</label> </label>
<label>
Cut outline
<select id="outline">
<option value="0" selected>None (best fit)</option>
<option value="1">Hairline (≈0.3 mm)</option>
</select>
</label>
</div>
<div class="row">
<label> <label>
Paper Paper
<select id="paper"> <select id="paper">
@ -190,44 +195,39 @@
<option value="contain">Contain (no crop)</option> <option value="contain">Contain (no crop)</option>
</select> </select>
</label> </label>
<label>
Cut outline
<select id="outline">
<option value="0" selected>None (best fit)</option>
<option value="1">Hairline (≈0.3 mm)</option>
</select>
</label>
</div>
<div class="row">
<label> <label>
Sticker image Sticker image
<input id="file" type="file" accept="image/*"> <input id="file" type="file" accept="image/*">
</label> </label>
<button class="secondary" id="generate">Generate layout</button> <button class="secondary" id="generate">Generate layout</button>
<button class="primary" id="print">Print…</button> <button class="primary" id="print">Print…</button>
</div>
<div class="row">
<span class="stats" id="stats"></span> <span class="stats" id="stats"></span>
</div> </div>
<div class="row"> <div class="row">
<span class="hint">Print tips: Set Scale 100%, enable “Print backgrounds/graphics,and choose the same paper size/orientation here and in the dialog. If edges clip, increase Printer margin slightly.</span> <span class="hint">Print at 100% scale with “Print backgrounds/graphics” enabled. If edges clip, increase Printer margin slightly (e.g., 0.30.5 cm).</span>
</div> </div>
</div> </div>
<div class="pages" id="pages"></div> <div class="pages" id="pages"></div>
<script> <script>
// Paper sizes in cm
const PAPER = { const PAPER = {
A4: { w: 21.0, h: 29.7 }, A4: { w: 21.0, h: 29.7 },
LETTER: { w: 21.59, h: 27.94 } LETTER: { w: 21.59, h: 27.94 }
}; };
const el = { const el = {
layout: document.getElementById('layout'),
diameter: document.getElementById('diameter'), diameter: document.getElementById('diameter'),
gutter: document.getElementById('gutter'), gutter: document.getElementById('gutter'),
margin: document.getElementById('margin'), margin: document.getElementById('margin'),
outline: document.getElementById('outline'),
paper: document.getElementById('paper'), paper: document.getElementById('paper'),
orientation: document.getElementById('orientation'), orientation: document.getElementById('orientation'),
fit: document.getElementById('fit'), fit: document.getElementById('fit'),
outline: document.getElementById('outline'),
file: document.getElementById('file'), file: document.getElementById('file'),
pages: document.getElementById('pages'), pages: document.getElementById('pages'),
generate: document.getElementById('generate'), generate: document.getElementById('generate'),
@ -237,10 +237,10 @@
let imageDataURL = null; let imageDataURL = null;
// React to changes immediately // Update on any change
['change','input'].forEach(evt => { ['change','input'].forEach(evt => {
[el.diameter, el.gutter, el.margin, el.paper, el.orientation, el.fit, el.outline].forEach(ctrl => { [el.layout, el.diameter, el.gutter, el.margin, el.outline, el.paper, el.orientation, el.fit].forEach(ctrl => {
ctrl.addEventListener(evt, () => render()); ctrl.addEventListener(evt, render);
}); });
}); });
@ -251,91 +251,88 @@
render(); render();
}); });
el.generate.addEventListener('click', () => render()); el.generate.addEventListener('click', render);
el.print.addEventListener('click', () => { el.print.addEventListener('click', () => {
if (!document.querySelector('.page')) render(); if (!document.querySelector('.page')) render();
setTimeout(() => window.print(), 50); setTimeout(() => window.print(), 50);
}); });
render(); // Initial // Initial render
render();
function render() { function render() {
const d = clampNum(parseFloat(el.diameter.value), 1, 100); // sticker diameter (cm) content const layout = el.layout.value; // 'hex' or 'grid'
const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm) const d = clampNum(parseFloat(el.diameter.value), 1, 100); // content diameter (cm)
const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm) const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm)
const fit = el.fit.value === 'contain' ? 'contain' : 'cover'; const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm)
const outlineOn = el.outline.value === '1' ? 1 : 0; const outlineOn = el.outline.value === '1' ? 1 : 0;
const fit = el.fit.value === 'contain' ? 'contain' : 'cover';
let { w, h } = PAPER[el.paper.value] || PAPER.A4; let { w, h } = PAPER[el.paper.value] || PAPER.A4;
const isLandscape = el.orientation.value === 'landscape'; const isLandscape = el.orientation.value === 'landscape';
if (isLandscape) [w, h] = [h, w]; if (isLandscape) [w, h] = [h, w];
// Push CSS variables for preview
document.documentElement.style.setProperty('--sticker-diameter-cm', d); document.documentElement.style.setProperty('--sticker-diameter-cm', d);
document.documentElement.style.setProperty('--gutter-cm', g); document.documentElement.style.setProperty('--gutter-cm', g);
document.documentElement.style.setProperty('--margin-cm', m); document.documentElement.style.setProperty('--margin-cm', m);
document.documentElement.style.setProperty('--page-width-cm', w); document.documentElement.style.setProperty('--page-width-cm', w);
document.documentElement.style.setProperty('--page-height-cm', h); document.documentElement.style.setProperty('--page-height-cm', h);
document.documentElement.style.setProperty('--fit-mode', fit);
document.documentElement.style.setProperty('--outline-on', outlineOn); document.documentElement.style.setProperty('--outline-on', outlineOn);
document.documentElement.style.setProperty('--fit-mode', fit);
// Build one page (duplicate in print dialog for more)
el.pages.innerHTML = ''; el.pages.innerHTML = '';
const page = buildPage(w, h, m, d, g, outlineOn);
el.pages.appendChild(page);
}
function buildPage(w, h, margin, diameter, gutter, outlineOn) {
const page = document.createElement('div'); const page = document.createElement('div');
page.className = 'page'; page.className = 'page';
const canvas = document.createElement('div'); const canvas = document.createElement('div');
canvas.className = 'canvas'; canvas.className = 'canvas';
page.appendChild(canvas); page.appendChild(canvas);
el.pages.appendChild(page);
// Usable area inside margins (cm) // Dimensions inside margins
const W = w - 2 * margin; const W = w - 2*m;
const H = h - 2 * margin; const H = h - 2*m;
// If outline is on, it increases the outer diameter. // Outer diameter (content + outline on both sides)
const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm
const outerD = diameter + 2 * outlineWidthCm; const outerD = d + 2*outlineWidthCm;
// Hex packing // Generate placements for selected layout
const pitchX = outerD + gutter; const placements = layout === 'grid'
const pitchY = (Math.sqrt(3) / 2) * outerD + gutter; ? packGrid(W, H, outerD, g)
const offsetX = 0.5 * (outerD + gutter); : packHex(W, H, outerD, g);
let y = 0; // Render stickers
let row = 0; placements.forEach(p => {
let placed = 0; const s = createSticker(imageDataURL);
// Offset by outline so the content circle sits at p.x..p.x+outerD
s.style.left = cm(p.x + outlineWidthCm);
s.style.top = cm(p.y + outlineWidthCm);
canvas.appendChild(s);
});
while (y + outerD <= H + 1e-6) { // Compute comparison stats for both layouts
const isOddRow = row % 2 === 1; const hexCount = packHex(W, H, outerD, g).length;
const startX = isOddRow ? offsetX : 0; const gridCount = packGrid(W, H, outerD, g).length;
let x = startX; const used = placements.length;
const areaCanvas = W * H;
const areaCircle = Math.PI * Math.pow(outerD/2, 2); // using outer diameter for sheet usage
const coverage = areaCanvas > 0 ? (used * areaCircle / areaCanvas) : 0;
while (x + outerD <= W + 1e-6) { const gain = hexCount - gridCount;
const s = createSticker(imageDataURL); const gainText = gain === 0 ? 'same' : (gain > 0 ? `+${gain} with Hex` : `+${-gain} with Grid`);
// Positioning uses the content diameter; outline is handled by CSS border on .sticker.
s.style.left = cm(x + outlineWidthCm);
s.style.top = cm(y + outlineWidthCm);
canvas.appendChild(s);
placed++;
x += pitchX;
}
row++;
y += pitchY;
if (row > 2000) break;
}
// Update stats el.stats.innerHTML =
const perRow = Math.max(0, Math.floor((W + 1e-6 - (row > 1 ? 0 : 0)) / pitchX) + Math.floor((W + 1e-6 - offsetX) / pitchX)); `<strong>${layout.toUpperCase()}</strong> layout: ${used} stickers • ` +
document.getElementById('stats').textContent = `Ø ${d.toFixed(1)} cm${outlineOn ? ` + ${Math.round(outlineWidthCm*10)} mm outline` : ''}` +
`This page: ${placed} stickers • Ø ${diameter.toFixed(1)} cm` + (g > 0 ? ` • Gutter ${g.toFixed(1)} cm` : ' • No gutter') +
(outlineOn ? ` + ${Math.round(getCssNumber('--outline-mm')*10)/10} mm outline` : ` (no outline)`) + ` • Coverage ${(coverage*100).toFixed(1)}%` +
(gutter > 0 ? ` • Gutter ${g.toFixed?.(1) ?? gutter} cm` : ''); ` — Compare ⇒ Hex: ${hexCount}, Grid: ${gridCount} (${gainText})`;
if (placed === 0) { // If none fit, show a helpful message
if (placements.length === 0) {
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.style.position = 'absolute'; msg.style.position = 'absolute';
msg.style.inset = '0'; msg.style.inset = '0';
@ -343,11 +340,52 @@
msg.style.placeItems = 'center'; msg.style.placeItems = 'center';
msg.style.color = '#6b7280'; msg.style.color = '#6b7280';
msg.style.fontSize = '14px'; msg.style.fontSize = '14px';
msg.textContent = 'No stickers fit with current settings. Reduce margins/gutter or rotate orientation.'; msg.textContent = 'No stickers fit with current settings. Reduce margins/gutter or switch orientation.';
canvas.appendChild(msg); canvas.appendChild(msg);
} }
}
return page; // Hexagonal packing (odd rows offset by half pitch)
function packHex(W, H, outerD, gutter) {
const res = [];
const pitchX = outerD + gutter;
const pitchY = (Math.sqrt(3) / 2) * outerD + gutter;
const halfStep = 0.5 * (outerD + gutter);
let y = 0;
let row = 0;
while (y + outerD <= H + 1e-6) {
const startX = (row % 2 === 1) ? halfStep : 0;
let x = startX;
while (x + outerD <= W + 1e-6) {
res.push({ x, y });
x += pitchX;
}
row++;
y += pitchY;
if (row > 5000) break;
}
return res;
// Note: This is edge-to-edge packing using the outer diameter (includes outline if enabled).
}
// Simple rows & columns grid
function packGrid(W, H, outerD, gutter) {
const res = [];
const pitchX = outerD + gutter;
const pitchY = outerD + gutter;
let y = 0;
while (y + outerD <= H + 1e-6) {
let x = 0;
while (x + outerD <= W + 1e-6) {
res.push({ x, y });
x += pitchX;
}
y += pitchY;
if (res.length > 100000) break;
}
return res;
} }
function createSticker(dataUrl) { function createSticker(dataUrl) {