2 layouts
This commit is contained in:
@ -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.3–0.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) {
|
||||||
|
Reference in New Issue
Block a user