Files
scripts/HTML/STICKERS/index.html
Dobromir Popov 84d83bb947 fix print
2025-08-08 15:16:28 +03:00

445 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 (Print-Fixed)</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;
}
/* Base @page; the exact size is injected dynamically so the printer uses
the selected paper & orientation without scaling. */
@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;
}
/* On-screen preview uses px so it fits the viewport nicely.
For print we override width/height to exact 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); /* 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;
}
/* Print: force exact physical size (cm) so PDF printers don't scale or clip. */
@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>
<!-- This style tag will be populated dynamically to set @page size -->
<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.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 to PDF at 100% scale (or “Actual size”) with backgrounds enabled. If edges clip, increase Printer margin slightly (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'),
pageSizeStyle: document.getElementById('page-size-style')
};
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
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);
// Force the printer page to use exactly this size (prevents the "quarter page" issue).
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 (cm)
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
const placements = (layout === 'grid')
? packGrid(W, H, outerD, g)
: packHex(W, H, outerD, g);
// Render stickers
placements.forEach(p => {
const s = createSticker(imageDataURL);
// Align content circle; outline is border around it
s.style.left = cm(p.x + outlineWidthCm);
s.style.top = cm(p.y + outlineWidthCm);
canvas.appendChild(s);
});
// Compare 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);
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 (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
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, row = 0;
while (y + outerD <= H + 1e-6) {
let x = (row % 2 === 1) ? 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, 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>