453 lines
14 KiB
HTML
453 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>4 cm Sticker Sheet – Hex vs Grid (Negative Gutter)</title>
|
||
<style>
|
||
:root {
|
||
--sticker-diameter-cm: 4; /* Content circle diameter */
|
||
--gutter-cm: 0; /* Can be negative for overlap */
|
||
--margin-cm: 0; /* Safety/printer margin */
|
||
--outline-on: 0; /* 1 show outline, 0 none */
|
||
--outline-mm: 0.3; /* Outline width when enabled */
|
||
--fit-mode: cover; /* cover|contain */
|
||
--page-width-cm: 21; /* A4 default */
|
||
--page-height-cm: 29.7;
|
||
}
|
||
|
||
/* Printer page size is injected dynamically based on chosen paper/orientation. */
|
||
@page { margin: 0; }
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
height: 100%;
|
||
margin: 0;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
background: #f2f3f5;
|
||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||
}
|
||
|
||
.controls {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background: white;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
padding: 12px;
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.controls .row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.controls label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
}
|
||
.controls input[type="number"] {
|
||
width: 7.5em;
|
||
padding: 6px 8px;
|
||
}
|
||
.controls select, .controls button, .controls input[type="file"] {
|
||
padding: 6px 8px;
|
||
font-size: 14px;
|
||
}
|
||
.controls button.primary {
|
||
background: #0ea5e9;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.controls button.secondary {
|
||
background: white;
|
||
color: #0ea5e9;
|
||
border: 1px solid #0ea5e9;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.hint, .stats {
|
||
font-size: 12px;
|
||
color: #475569;
|
||
}
|
||
.stats strong { color: #0f172a; }
|
||
|
||
.pages {
|
||
padding: 12px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* On-screen preview in px; print overrides to cm below. */
|
||
.page {
|
||
background: white;
|
||
margin: 0 auto;
|
||
position: relative;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||
outline: 1px dashed #e5e7eb;
|
||
width: calc(var(--page-width-cm) * 37.8px);
|
||
height: calc(var(--page-height-cm) * 37.8px);
|
||
}
|
||
|
||
.canvas {
|
||
position: absolute;
|
||
left: calc(var(--margin-cm) * 1cm);
|
||
top: calc(var(--margin-cm) * 1cm);
|
||
width: calc((var(--page-width-cm) - 2*var(--margin-cm)) * 1cm);
|
||
height: calc((var(--page-height-cm) - 2*var(--margin-cm)) * 1cm);
|
||
background: white;
|
||
}
|
||
|
||
.sticker {
|
||
position: absolute;
|
||
width: calc(var(--sticker-diameter-cm) * 1cm); /* content circle */
|
||
height: calc(var(--sticker-diameter-cm) * 1cm);
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
box-sizing: content-box; /* outline adds to outer size */
|
||
border: calc(var(--outline-on) * var(--outline-mm) * 1mm) solid rgba(0,0,0,0.28);
|
||
background: #f8fafc center/var(--fit-mode) no-repeat;
|
||
}
|
||
.sticker img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: var(--fit-mode);
|
||
display: block;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
}
|
||
|
||
/* Print: exact physical size to avoid the “quarter page” issue. */
|
||
@media print {
|
||
body { background: white; }
|
||
.controls { display: none !important; }
|
||
.pages { padding: 0; }
|
||
.page {
|
||
width: calc(var(--page-width-cm) * 1cm) !important;
|
||
height: calc(var(--page-height-cm) * 1cm) !important;
|
||
box-shadow: none !important;
|
||
outline: none !important;
|
||
page-break-after: always;
|
||
page-break-inside: avoid;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.page { width: 100%; height: auto; aspect-ratio: var(--page-width-cm) / var(--page-height-cm); }
|
||
}
|
||
</style>
|
||
<style id="page-size-style"></style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="controls">
|
||
<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>
|
||
Sticker Ø (cm)
|
||
<input id="diameter" type="number" step="0.05" min="1" value="4">
|
||
</label>
|
||
<label>
|
||
Gutter (cm)
|
||
<!-- Allow negative input; code will clamp to a safe minimum based on diameter. -->
|
||
<input id="gutter" type="number" step="0.05" min="-5" value="0">
|
||
</label>
|
||
<label>
|
||
Printer margin (cm)
|
||
<input id="margin" type="number" step="0.1" min="0" value="0">
|
||
</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>
|
||
Paper
|
||
<select id="paper">
|
||
<option value="A4" selected>A4 (21 × 29.7 cm)</option>
|
||
<option value="LETTER">US Letter (21.59 × 27.94 cm)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Orientation
|
||
<select id="orientation">
|
||
<option value="portrait" selected>Portrait</option>
|
||
<option value="landscape">Landscape</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Fit
|
||
<select id="fit">
|
||
<option value="cover" selected>Cover (fills circle)</option>
|
||
<option value="contain">Contain (no crop)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Sticker image
|
||
<input id="file" type="file" accept="image/*">
|
||
</label>
|
||
<button class="secondary" id="generate">Generate layout</button>
|
||
<button class="primary" id="print">Print…</button>
|
||
</div>
|
||
<div class="row">
|
||
<span class="stats" id="stats">–</span>
|
||
</div>
|
||
<div class="row">
|
||
<span class="hint">Gutter can be negative for slight overlap. Print at 100% with backgrounds enabled. If edges clip, increase Printer margin (e.g., 0.3–0.5 cm).</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pages" id="pages"></div>
|
||
|
||
<script>
|
||
// Paper sizes in cm
|
||
const PAPER = {
|
||
A4: { w: 21.0, h: 29.7 },
|
||
LETTER: { w: 21.59, h: 27.94 }
|
||
};
|
||
|
||
const el = {
|
||
layout: document.getElementById('layout'),
|
||
diameter: document.getElementById('diameter'),
|
||
gutter: document.getElementById('gutter'),
|
||
margin: document.getElementById('margin'),
|
||
outline: document.getElementById('outline'),
|
||
paper: document.getElementById('paper'),
|
||
orientation: document.getElementById('orientation'),
|
||
fit: document.getElementById('fit'),
|
||
file: document.getElementById('file'),
|
||
pages: document.getElementById('pages'),
|
||
generate: document.getElementById('generate'),
|
||
print: document.getElementById('print'),
|
||
stats: document.getElementById('stats'),
|
||
pageSizeStyle: document.getElementById('page-size-style')
|
||
};
|
||
|
||
let imageDataURL = null;
|
||
|
||
// Live updates
|
||
['change','input'].forEach(evt => {
|
||
[el.layout, el.diameter, el.gutter, el.margin, el.outline, el.paper, el.orientation, el.fit].forEach(ctrl => {
|
||
ctrl.addEventListener(evt, render);
|
||
});
|
||
});
|
||
|
||
el.file.addEventListener('change', async (e) => {
|
||
const f = e.target.files?.[0];
|
||
if (!f) return;
|
||
imageDataURL = await fileToDataURL(f);
|
||
render();
|
||
});
|
||
|
||
el.generate.addEventListener('click', render);
|
||
|
||
el.print.addEventListener('click', () => {
|
||
if (!document.querySelector('.page')) render();
|
||
setTimeout(() => window.print(), 50);
|
||
});
|
||
|
||
render(); // initial
|
||
|
||
function render() {
|
||
const layout = el.layout.value; // 'hex' or 'grid'
|
||
const d = clampNum(parseFloat(el.diameter.value), 1, 100); // content diameter (cm)
|
||
const gInput = parseFloat(el.gutter.value); // can be negative
|
||
const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm)
|
||
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;
|
||
const isLandscape = el.orientation.value === 'landscape';
|
||
if (isLandscape) [w, h] = [h, w];
|
||
|
||
// Push CSS variables for preview
|
||
document.documentElement.style.setProperty('--sticker-diameter-cm', d);
|
||
document.documentElement.style.setProperty('--margin-cm', m);
|
||
document.documentElement.style.setProperty('--page-width-cm', w);
|
||
document.documentElement.style.setProperty('--page-height-cm', h);
|
||
document.documentElement.style.setProperty('--outline-on', outlineOn);
|
||
document.documentElement.style.setProperty('--fit-mode', fit);
|
||
|
||
// Force printer page size
|
||
el.pageSizeStyle.textContent = `@page { size: ${w}cm ${h}cm; margin: 0; }`;
|
||
|
||
// Build page
|
||
el.pages.innerHTML = '';
|
||
const page = document.createElement('div');
|
||
page.className = 'page';
|
||
const canvas = document.createElement('div');
|
||
canvas.className = 'canvas';
|
||
page.appendChild(canvas);
|
||
el.pages.appendChild(page);
|
||
|
||
// Dimensions inside margins
|
||
const W = w - 2*m;
|
||
const H = h - 2*m;
|
||
|
||
// Outline width in cm
|
||
const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm
|
||
const outerD = d + 2*outlineWidthCm;
|
||
|
||
// Allow negative gutter but keep step positive (pitch > 0).
|
||
// Minimum safe gutter is just above -outerD so that pitch stays positive.
|
||
const MIN_EPS = 0.01; // 0.01 cm = 0.1 mm
|
||
const gSafeMin = -(outerD - MIN_EPS);
|
||
const g = clampNum(isNaN(gInput) ? 0 : gInput, gSafeMin, 5); // upper bound 5 cm
|
||
document.documentElement.style.setProperty('--gutter-cm', g);
|
||
|
||
// Generate placements with the sanitized gutter
|
||
const placements = (layout === 'grid')
|
||
? packGrid(W, H, outerD, g)
|
||
: packHex(W, H, outerD, g);
|
||
|
||
// Render stickers
|
||
placements.forEach(p => {
|
||
const s = createSticker(imageDataURL);
|
||
s.style.left = cm(p.x + outlineWidthCm);
|
||
s.style.top = cm(p.y + outlineWidthCm);
|
||
canvas.appendChild(s);
|
||
});
|
||
|
||
// Compare both layouts (using the same safe gutter)
|
||
const hexCount = packHex(W, H, outerD, g).length;
|
||
const gridCount = packGrid(W, H, outerD, g).length;
|
||
const used = placements.length;
|
||
const areaCanvas = W * H;
|
||
const areaCircle = Math.PI * Math.pow(outerD/2, 2);
|
||
const coverage = areaCanvas > 0 ? (used * areaCircle / areaCanvas) : 0;
|
||
const gain = hexCount - gridCount;
|
||
const gainText = gain === 0 ? 'same' : (gain > 0 ? `+${gain} with Hex` : `+${-gain} with Grid`);
|
||
|
||
// If user requested a gutter below safe min, indicate clamping.
|
||
const clampedNote = gInput < gSafeMin ? ` (clamped to ${g.toFixed(2)} cm)` : '';
|
||
|
||
el.stats.innerHTML =
|
||
`<strong>${layout.toUpperCase()}</strong> layout: ${used} stickers • ` +
|
||
`Ø ${d.toFixed(2)} cm${outlineOn ? ` + ${Math.round(outlineWidthCm*10)} mm outline` : ''}` +
|
||
` • Gutter ${g.toFixed(2)} cm${clampedNote}` +
|
||
` • Coverage ${(coverage*100).toFixed(1)}%` +
|
||
` — Compare ⇒ Hex: ${hexCount}, Grid: ${gridCount} (${gainText})`;
|
||
|
||
if (placements.length === 0) {
|
||
const msg = document.createElement('div');
|
||
msg.style.position = 'absolute';
|
||
msg.style.inset = '0';
|
||
msg.style.display = 'grid';
|
||
msg.style.placeItems = 'center';
|
||
msg.style.color = '#6b7280';
|
||
msg.style.fontSize = '14px';
|
||
msg.textContent = 'No stickers fit with current settings. Reduce margins or adjust gutter/orientation.';
|
||
canvas.appendChild(msg);
|
||
}
|
||
}
|
||
|
||
// Hexagonal packing
|
||
function packHex(W, H, outerD, g) {
|
||
const res = [];
|
||
const pitchX = outerD + g;
|
||
const pitchY = (Math.sqrt(3) / 2) * outerD + g;
|
||
const halfStep = 0.5 * (outerD + g);
|
||
|
||
if (pitchX <= 0 || pitchY <= 0) return res; // safety
|
||
|
||
let y = 0, row = 0;
|
||
while (y + outerD <= H + 1e-6) {
|
||
let x = (row % 2 === 1) ? Math.max(0, halfStep) : 0;
|
||
while (x + outerD <= W + 1e-6) {
|
||
res.push({ x, y });
|
||
x += pitchX;
|
||
}
|
||
row++;
|
||
y += pitchY;
|
||
if (row > 5000) break;
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// Rows & columns grid
|
||
function packGrid(W, H, outerD, g) {
|
||
const res = [];
|
||
const pitchX = outerD + g;
|
||
const pitchY = outerD + g;
|
||
|
||
if (pitchX <= 0 || pitchY <= 0) return res; // safety
|
||
|
||
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) {
|
||
const d = document.createElement('div');
|
||
d.className = 'sticker';
|
||
if (dataUrl) {
|
||
const img = document.createElement('img');
|
||
img.src = dataUrl;
|
||
img.alt = 'Sticker';
|
||
d.appendChild(img);
|
||
} else {
|
||
d.style.background = '#eef2ff';
|
||
const ph = document.createElement('div');
|
||
ph.style.position = 'absolute';
|
||
ph.style.inset = '0';
|
||
ph.style.display = 'grid';
|
||
ph.style.placeItems = 'center';
|
||
ph.style.fontSize = '10px';
|
||
ph.style.color = '#475569';
|
||
ph.textContent = 'Upload image';
|
||
d.appendChild(ph);
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function cm(v) { return `${v}cm`; }
|
||
|
||
function clampNum(v, min, max) {
|
||
if (isNaN(v)) return min;
|
||
return Math.min(max, Math.max(min, v));
|
||
}
|
||
|
||
function getCssNumber(varName) {
|
||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||
const n = parseFloat(raw);
|
||
return isNaN(n) ? 0 : n;
|
||
}
|
||
|
||
async function fileToDataURL(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html> |