outline setting
This commit is contained in:
@ -3,22 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>4 cm Sticker Sheet – Hex Packed (Max Utilization)</title>
|
<title>4 cm Sticker Sheet – Hex Packed (Cutline Optional)</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--sticker-diameter-cm: 4; /* Locked at 4 cm by default */
|
--sticker-diameter-cm: 4; /* Sticker content diameter (cm) */
|
||||||
--gutter-cm: 0; /* Extra spacing between stickers */
|
--gutter-cm: 0; /* Spacing between sticker edges (cm) */
|
||||||
--margin-cm: 0; /* Printable area inset on each side */
|
--margin-cm: 0; /* Page margin/safety (cm) */
|
||||||
--outline: 1; /* 1 = show cut outline, 0 = hide */
|
--outline-on: 0; /* 1 = show cut outline, 0 = hide */
|
||||||
--fit-mode: cover; /* cover|contain for image fit */
|
--outline-mm: 0.3; /* Outline width in mm when enabled */
|
||||||
--page-width-cm: 21; /* A4 portrait defaults in UI */
|
--fit-mode: cover; /* cover|contain image fit */
|
||||||
|
--page-width-cm: 21; /* Default A4 portrait */
|
||||||
--page-height-cm: 29.7;
|
--page-height-cm: 29.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page { margin: 0; }
|
||||||
/* Let the browser/printer handle size; set margins to 0 for max area */
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
@ -30,7 +28,6 @@
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls */
|
|
||||||
.controls {
|
.controls {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -80,8 +77,11 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
/* Preview/Printable area */
|
|
||||||
.pages {
|
.pages {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -94,7 +94,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
outline: 1px dashed #e5e7eb;
|
outline: 1px dashed #e5e7eb;
|
||||||
width: calc(var(--page-width-cm) * 37.8px); /* ~37.8 px/cm for screen preview */
|
width: calc(var(--page-width-cm) * 37.8px);
|
||||||
height: calc(var(--page-height-cm) * 37.8px);
|
height: calc(var(--page-height-cm) * 37.8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,8 +113,15 @@
|
|||||||
height: calc(var(--sticker-diameter-cm) * 1cm);
|
height: calc(var(--sticker-diameter-cm) * 1cm);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Optional hairline cut guide */
|
|
||||||
border: calc(var(--outline) * 0.3mm) solid rgba(0,0,0,0.25);
|
/* 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;
|
background: #f8fafc center/var(--fit-mode) no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +134,6 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide UI in print; expand to full page; remove shadows/outlines */
|
|
||||||
@media print {
|
@media print {
|
||||||
body { background: white; }
|
body { background: white; }
|
||||||
.controls, .hint-bar { display: none !important; }
|
.controls, .hint-bar { display: none !important; }
|
||||||
@ -142,9 +148,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small-screen responsiveness */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.controls .row { gap: 8px; }
|
|
||||||
.page { width: 100%; height: auto; aspect-ratio: var(--page-width-cm) / var(--page-height-cm); }
|
.page { width: 100%; height: auto; aspect-ratio: var(--page-width-cm) / var(--page-height-cm); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -189,8 +193,8 @@
|
|||||||
<label>
|
<label>
|
||||||
Cut outline
|
Cut outline
|
||||||
<select id="outline">
|
<select id="outline">
|
||||||
<option value="1" selected>Show</option>
|
<option value="0" selected>None (best fit)</option>
|
||||||
<option value="0">Hide</option>
|
<option value="1">Hairline (≈0.3 mm)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -201,14 +205,16 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="secondary" id="generate">Generate layout</button>
|
<button class="secondary" id="generate">Generate layout</button>
|
||||||
<button class="primary" id="print">Print…</button>
|
<button class="primary" id="print">Print…</button>
|
||||||
<span class="hint">Tip: In the browser print dialog, set Scale 100%, enable “Print backgrounds,” and choose the same paper size/orientation.</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="pages" id="pages"></div>
|
<div class="pages" id="pages"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Paper sizes in cm
|
|
||||||
const PAPER = {
|
const PAPER = {
|
||||||
A4: { w: 21.0, h: 29.7 },
|
A4: { w: 21.0, h: 29.7 },
|
||||||
LETTER: { w: 21.59, h: 27.94 }
|
LETTER: { w: 21.59, h: 27.94 }
|
||||||
@ -225,67 +231,60 @@
|
|||||||
file: document.getElementById('file'),
|
file: document.getElementById('file'),
|
||||||
pages: document.getElementById('pages'),
|
pages: document.getElementById('pages'),
|
||||||
generate: document.getElementById('generate'),
|
generate: document.getElementById('generate'),
|
||||||
print: document.getElementById('print')
|
print: document.getElementById('print'),
|
||||||
|
stats: document.getElementById('stats')
|
||||||
};
|
};
|
||||||
|
|
||||||
let imageDataURL = null;
|
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) => {
|
el.file.addEventListener('change', async (e) => {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const dataUrl = await fileToDataURL(f);
|
imageDataURL = await fileToDataURL(f);
|
||||||
imageDataURL = dataUrl;
|
|
||||||
});
|
|
||||||
|
|
||||||
el.generate.addEventListener('click', () => {
|
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.generate.addEventListener('click', () => render());
|
||||||
|
|
||||||
el.print.addEventListener('click', () => {
|
el.print.addEventListener('click', () => {
|
||||||
if (!document.querySelector('.page')) {
|
if (!document.querySelector('.page')) render();
|
||||||
render();
|
setTimeout(() => window.print(), 50);
|
||||||
// Slight delay to ensure layout is present
|
|
||||||
setTimeout(() => window.print(), 50);
|
|
||||||
} else {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial render
|
render(); // Initial
|
||||||
render();
|
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
// Read settings
|
const d = clampNum(parseFloat(el.diameter.value), 1, 100); // sticker diameter (cm) content
|
||||||
const d = clampNum(parseFloat(el.diameter.value), 1, 100); // sticker diameter (cm)
|
const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm)
|
||||||
const g = clampNum(parseFloat(el.gutter.value), 0, 5); // gutter (cm)
|
const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm)
|
||||||
const m = clampNum(parseFloat(el.margin.value), 0, 5); // margin (cm)
|
|
||||||
const fit = el.fit.value === 'contain' ? 'contain' : 'cover';
|
const fit = el.fit.value === 'contain' ? 'contain' : 'cover';
|
||||||
const outline = el.outline.value === '0' ? 0 : 1;
|
const outlineOn = el.outline.value === '1' ? 1 : 0;
|
||||||
|
|
||||||
let { w, h } = PAPER[el.paper.value] || PAPER.A4;
|
let { w, h } = PAPER[el.paper.value] || PAPER.A4;
|
||||||
const isLandscape = el.orientation.value === 'landscape';
|
const isLandscape = el.orientation.value === 'landscape';
|
||||||
if (isLandscape) [w, h] = [h, w];
|
if (isLandscape) [w, h] = [h, w];
|
||||||
|
|
||||||
// Update CSS variables (for preview sizing)
|
|
||||||
document.documentElement.style.setProperty('--sticker-diameter-cm', d);
|
document.documentElement.style.setProperty('--sticker-diameter-cm', d);
|
||||||
document.documentElement.style.setProperty('--gutter-cm', g);
|
document.documentElement.style.setProperty('--gutter-cm', g);
|
||||||
document.documentElement.style.setProperty('--margin-cm', m);
|
document.documentElement.style.setProperty('--margin-cm', m);
|
||||||
document.documentElement.style.setProperty('--page-width-cm', w);
|
document.documentElement.style.setProperty('--page-width-cm', w);
|
||||||
document.documentElement.style.setProperty('--page-height-cm', h);
|
document.documentElement.style.setProperty('--page-height-cm', h);
|
||||||
document.documentElement.style.setProperty('--fit-mode', fit);
|
document.documentElement.style.setProperty('--fit-mode', fit);
|
||||||
document.documentElement.style.setProperty('--outline', outline);
|
document.documentElement.style.setProperty('--outline-on', outlineOn);
|
||||||
|
|
||||||
// Build pages
|
|
||||||
el.pages.innerHTML = '';
|
el.pages.innerHTML = '';
|
||||||
// We will generate enough pages to fill with stickers until a "reasonable" upper bound.
|
const page = buildPage(w, h, m, d, g, outlineOn);
|
||||||
// One page is typically enough; we generate one by default. Duplicate if you need more.
|
el.pages.appendChild(page);
|
||||||
const pagesNeeded = 1; // Adjust here if you want multiple identical pages by default
|
|
||||||
for (let p = 0; p < pagesNeeded; p++) {
|
|
||||||
el.pages.appendChild(buildPage(w, h, m, d, g));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPage(w, h, margin, diameter, gutter) {
|
function buildPage(w, h, margin, diameter, gutter, outlineOn) {
|
||||||
const page = document.createElement('div');
|
const page = document.createElement('div');
|
||||||
page.className = 'page';
|
page.className = 'page';
|
||||||
|
|
||||||
@ -293,40 +292,49 @@
|
|||||||
canvas.className = 'canvas';
|
canvas.className = 'canvas';
|
||||||
page.appendChild(canvas);
|
page.appendChild(canvas);
|
||||||
|
|
||||||
// Usable width/height inside margins (in cm)
|
// Usable area inside margins (cm)
|
||||||
const W = w - 2 * margin;
|
const W = w - 2 * margin;
|
||||||
const H = h - 2 * margin;
|
const H = h - 2 * margin;
|
||||||
|
|
||||||
// Hex packing parameters
|
// If outline is on, it increases the outer diameter.
|
||||||
const D = diameter; // circle diameter (cm)
|
const outlineWidthCm = outlineOn ? (getCssNumber('--outline-mm') / 10) : 0; // mm -> cm
|
||||||
const pitchX = D + gutter; // horizontal pitch
|
const outerD = diameter + 2 * outlineWidthCm;
|
||||||
const pitchY = (Math.sqrt(3) / 2) * D + gutter; // vertical pitch
|
|
||||||
const offsetX = 0.5 * (D + gutter); // half-step for alternate rows
|
// Hex packing
|
||||||
|
const pitchX = outerD + gutter;
|
||||||
|
const pitchY = (Math.sqrt(3) / 2) * outerD + gutter;
|
||||||
|
const offsetX = 0.5 * (outerD + gutter);
|
||||||
|
|
||||||
// Row by row placement
|
|
||||||
let y = 0;
|
let y = 0;
|
||||||
let row = 0;
|
let row = 0;
|
||||||
let placed = 0;
|
let placed = 0;
|
||||||
|
|
||||||
while (y + D <= H + 1e-6) {
|
while (y + outerD <= H + 1e-6) {
|
||||||
const isOddRow = row % 2 === 1;
|
const isOddRow = row % 2 === 1;
|
||||||
const startX = isOddRow ? offsetX : 0;
|
const startX = isOddRow ? offsetX : 0;
|
||||||
let x = startX;
|
let x = startX;
|
||||||
while (x + D <= W + 1e-6) {
|
|
||||||
|
while (x + outerD <= W + 1e-6) {
|
||||||
const s = createSticker(imageDataURL);
|
const s = createSticker(imageDataURL);
|
||||||
s.style.left = cm(x);
|
// Positioning uses the content diameter; outline is handled by CSS border on .sticker.
|
||||||
s.style.top = cm(y);
|
s.style.left = cm(x + outlineWidthCm);
|
||||||
|
s.style.top = cm(y + outlineWidthCm);
|
||||||
canvas.appendChild(s);
|
canvas.appendChild(s);
|
||||||
placed++;
|
placed++;
|
||||||
x += pitchX;
|
x += pitchX;
|
||||||
}
|
}
|
||||||
row++;
|
row++;
|
||||||
y += pitchY;
|
y += pitchY;
|
||||||
// Small guard to avoid subpixel accumulation
|
if (row > 2000) break;
|
||||||
if (row > 1000) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing placed (e.g., margins too large), at least show a nudge
|
// 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) {
|
if (placed === 0) {
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.style.position = 'absolute';
|
msg.style.position = 'absolute';
|
||||||
@ -346,13 +354,11 @@
|
|||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'sticker';
|
d.className = 'sticker';
|
||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
// Use an <img> to ensure high-quality printing with backgrounds on
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
img.alt = 'Sticker';
|
img.alt = 'Sticker';
|
||||||
d.appendChild(img);
|
d.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
// Placeholder background + label
|
|
||||||
d.style.background = '#eef2ff';
|
d.style.background = '#eef2ff';
|
||||||
const ph = document.createElement('div');
|
const ph = document.createElement('div');
|
||||||
ph.style.position = 'absolute';
|
ph.style.position = 'absolute';
|
||||||
@ -374,6 +380,12 @@
|
|||||||
return Math.min(max, Math.max(min, v));
|
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) {
|
async function fileToDataURL(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
Reference in New Issue
Block a user