Files
scripts/HTML/STICKERS/index.html
Dobromir Popov 164616f803 2 layouts
2025-08-08 15:15:07 +03:00

438 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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</title>
<style>
:root {
--sticker-diameter-cm: 4; /* Content circle diameter */
--gutter-cm: 0; /* Space between outer edges */
--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 by default */
--page-height-cm: 29.7;
}
@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: 6.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;
}
.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); /* screen preview only */
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;
}
@media print {
body { background: white; }
.controls { display: none !important; }
.pages { padding: 0; }
.page {
width: auto !important;
height: auto !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>
</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.1" min="1" value="4">
</label>
<label>
Gutter (cm)
<input id="gutter" type="number" step="0.1" min="0" 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">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 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')
};
let imageDataURL = null;
// Update on any change
['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);
});
// Initial render
render();
function render() {
const layout = el.layout.value; // 'hex' or 'grid'
const d = clampNum(parseFloat(el.diameter.value), 1, 100); // content diameter (cm)
const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm)
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('--gutter-cm', g);
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);
// Build one page (duplicate in print dialog for more)
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;
// Outer diameter (content + outline on both sides)
const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm
const outerD = d + 2*outlineWidthCm;
// Generate placements for selected layout
const placements = layout === 'grid'
? packGrid(W, H, outerD, g)
: packHex(W, H, outerD, g);
// Render stickers
placements.forEach(p => {
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);
});
// Compute comparison stats for both layouts
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); // using outer diameter for sheet usage
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`);
el.stats.innerHTML =
`<strong>${layout.toUpperCase()}</strong> layout: ${used} stickers • ` +
`Ø ${d.toFixed(1)} cm${outlineOn ? ` + ${Math.round(outlineWidthCm*10)} mm outline` : ''}` +
(g > 0 ? ` • Gutter ${g.toFixed(1)} cm` : ' • No gutter') +
` • Coverage ${(coverage*100).toFixed(1)}%` +
` — Compare ⇒ Hex: ${hexCount}, Grid: ${gridCount} (${gainText})`;
// If none fit, show a helpful message
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/gutter or switch orientation.';
canvas.appendChild(msg);
}
}
// 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) {
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>