Files
scripts/HTML/STICKERS/index.html
Dobromir Popov 65012289b9 outline setting
2025-08-08 15:10:50 +03:00

400 lines
12 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 Packed (Cutline Optional)</title>
<style>
:root {
--sticker-diameter-cm: 4; /* Sticker content diameter (cm) */
--gutter-cm: 0; /* Spacing between sticker edges (cm) */
--margin-cm: 0; /* Page margin/safety (cm) */
--outline-on: 0; /* 1 = show cut outline, 0 = hide */
--outline-mm: 0.3; /* Outline width in mm when enabled */
--fit-mode: cover; /* cover|contain image fit */
--page-width-cm: 21; /* Default A4 portrait */
--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 {
font-size: 12px;
color: #6b7280;
}
.stats {
font-size: 13px;
color: #334155;
}
.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);
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);
height: calc(var(--sticker-diameter-cm) * 1cm);
border-radius: 50%;
overflow: hidden;
/* 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);
background: #f8fafc center/var(--fit-mode) no-repeat;
}
.sticker img {
display: block;
width: 100%;
height: 100%;
object-fit: var(--fit-mode);
pointer-events: none;
user-select: none;
}
@media print {
body { background: white; }
.controls, .hint-bar { 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>
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>
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>
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>
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>
<span class="stats" id="stats"></span>
</div>
<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>
</div>
</div>
<div class="pages" id="pages"></div>
<script>
const PAPER = {
A4: { w: 21.0, h: 29.7 },
LETTER: { w: 21.59, h: 27.94 }
};
const el = {
diameter: document.getElementById('diameter'),
gutter: document.getElementById('gutter'),
margin: document.getElementById('margin'),
paper: document.getElementById('paper'),
orientation: document.getElementById('orientation'),
fit: document.getElementById('fit'),
outline: document.getElementById('outline'),
file: document.getElementById('file'),
pages: document.getElementById('pages'),
generate: document.getElementById('generate'),
print: document.getElementById('print'),
stats: document.getElementById('stats')
};
let imageDataURL = null;
// React to changes immediately
['change','input'].forEach(evt => {
[el.diameter, el.gutter, el.margin, el.paper, el.orientation, el.fit, el.outline].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 d = clampNum(parseFloat(el.diameter.value), 1, 100); // sticker diameter (cm) content
const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm)
const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm)
const fit = el.fit.value === 'contain' ? 'contain' : 'cover';
const outlineOn = el.outline.value === '1' ? 1 : 0;
let { w, h } = PAPER[el.paper.value] || PAPER.A4;
const isLandscape = el.orientation.value === 'landscape';
if (isLandscape) [w, h] = [h, w];
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('--fit-mode', fit);
document.documentElement.style.setProperty('--outline-on', outlineOn);
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');
page.className = 'page';
const canvas = document.createElement('div');
canvas.className = 'canvas';
page.appendChild(canvas);
// Usable area inside margins (cm)
const W = w - 2 * margin;
const H = h - 2 * margin;
// If outline is on, it increases the outer diameter.
const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm
const outerD = diameter + 2 * outlineWidthCm;
// Hex packing
const pitchX = outerD + gutter;
const pitchY = (Math.sqrt(3) / 2) * outerD + gutter;
const offsetX = 0.5 * (outerD + gutter);
let y = 0;
let row = 0;
let placed = 0;
while (y + outerD <= H + 1e-6) {
const isOddRow = row % 2 === 1;
const startX = isOddRow ? offsetX : 0;
let x = startX;
while (x + outerD <= W + 1e-6) {
const s = createSticker(imageDataURL);
// 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
const perRow = Math.max(0, Math.floor((W + 1e-6 - (row > 1 ? 0 : 0)) / pitchX) + Math.floor((W + 1e-6 - offsetX) / pitchX));
document.getElementById('stats').textContent =
`This page: ${placed} stickers • Ø ${diameter.toFixed(1)} cm` +
(outlineOn ? ` + ${Math.round(getCssNumber('--outline-mm')*10)/10} mm outline` : ` (no outline)`) +
(gutter > 0 ? ` • Gutter ${g.toFixed?.(1) ?? gutter} cm` : '');
if (placed === 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 rotate orientation.';
canvas.appendChild(msg);
}
return page;
}
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>