Files
scripts/HTML/STICKERS/index.html
2025-08-13 00:28:05 +03:00

453 lines
14 KiB
HTML
Raw Permalink 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 (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.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;
// 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>