d20e3d9077
Wireframe settles fixed middle line + movable slab, tick-declared L, and shared crop_fill. Scaffolding scrubbed before shipping into the app.
714 lines
40 KiB
HTML
714 lines
40 KiB
HTML
<!doctype html>
|
||
<!--
|
||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||
rectangle (calibration length + crop)".
|
||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||
|
||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||
in from the top and the bottom — and never changes the aspect ratio.
|
||
|
||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||
production workflow:
|
||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||
- the requirements-coverage board below the stage
|
||
- the "was / now" pair of mini diagrams
|
||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||
- the window.__wf debug hook
|
||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||
semantics, and the single shared crop_fill.
|
||
-->
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||
<style>
|
||
:root{
|
||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||
.panel{padding:10px 4px;color:var(--fg)}
|
||
h1{font-size:1.3rem;margin:0 0 10px}
|
||
h3{font-size:.95rem;margin:0 0 8px}
|
||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||
.row{display:flex;gap:10px;margin-top:16px}
|
||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||
/* ---- the stage: fixed line + movable slab ---- */
|
||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||
.tw.dragging svg{cursor:grabbing}
|
||
.tickg{cursor:ns-resize}
|
||
.rotg{cursor:ew-resize}
|
||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||
input[type=range]{flex:1}
|
||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||
.nums span:first-child{color:var(--mut)}
|
||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||
.nums .dim{color:#777}
|
||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||
.status.ok{background:#12210f;color:var(--ok)}
|
||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||
.plan code{color:#ddd;font-size:.72rem}
|
||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||
/* replacement two-up */
|
||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||
.swap .gone{color:#ff9d8a}
|
||
/* scope lineup */
|
||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||
/* fields + settings */
|
||
.fields{display:flex;gap:16px;margin-top:16px}
|
||
.field{flex:1}
|
||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||
.settings .msg.ok{color:var(--ok)}
|
||
/* requirement coverage board (outside the phone) */
|
||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||
.cov .where{color:#ddd}
|
||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||
@media (max-width:760px){
|
||
.cov thead{display:none}
|
||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||
.cov tr:last-child{border-bottom:0}
|
||
.cov td{border:0;padding:1px 0}
|
||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||
}
|
||
.hidden{display:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||
<div class="flowbar" id="flowbar"></div>
|
||
<div class="vp">
|
||
<span>Viewport</span>
|
||
<span id="vpval">412</span>
|
||
<button data-w="390">390</button>
|
||
<button data-w="412" class="on">412</button>
|
||
<button data-w="720">720</button>
|
||
<button data-w="1440">1440</button>
|
||
</div>
|
||
</div>
|
||
<div class="stage"><div id="phone"></div></div>
|
||
<div class="cov"><div class="cov-inner">
|
||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||
<table>
|
||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div></div>
|
||
<script>
|
||
// ===========================================================================
|
||
// issue #99 — the tape line is a FIXED ENTITY.
|
||
//
|
||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||
// its long axis lies on the line, tick to tick.
|
||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||
// * L = the distance between the two ticks (the owner's tape reading).
|
||
// ===========================================================================
|
||
const S = {
|
||
screen: "tape",
|
||
// slab transform (the only movable entity)
|
||
tilt: 14, // degrees
|
||
zoom: 1,
|
||
panX: 60, panY: 0,
|
||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||
// bottom - and the operator drags them onto their tape marks from there.
|
||
tickA: 32,
|
||
tickB: 608,
|
||
focus: "A",
|
||
fill: 90, // settings.crop_fill - global, default 90
|
||
// declared values (entered on Measure, shown on both steps)
|
||
L: "88",
|
||
thickness: "2.125",
|
||
savedMsg: "",
|
||
};
|
||
|
||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||
const VB = { w: 480, h: 640 };
|
||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||
// AXIS - the centreline running down the middle of the board, which is where
|
||
// the tape is laid. (Not a physical side edge.)
|
||
const SLAB = {
|
||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||
};
|
||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||
const TICK_GAP = 40; // min separation between ticks
|
||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||
const HIT_R = 34;
|
||
const ALIGN_TOL = 3; // stage units
|
||
|
||
const rad = (a) => a * Math.PI / 180;
|
||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||
// crop frame, which is where the crop centres the slab's long axis.
|
||
const lineX = () => VB.w / 2;
|
||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||
|
||
// slab transform: zoom + tilt about the stage centre, then pan
|
||
function mapPoint(p) {
|
||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||
}
|
||
function slabTransform() {
|
||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||
}
|
||
// where the slab's long axis crosses a given stage y
|
||
function axisXAt(y) {
|
||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||
}
|
||
const axisAngle = () => {
|
||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||
};
|
||
// the axis is vertical when its angle points straight down (or up) the screen
|
||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||
|
||
// ---------------------------------------------------------------- the stage
|
||
function slabSvg() {
|
||
return `
|
||
<g id="slab" transform="${slabTransform()}">
|
||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||
<g clip-path="url(#slabClip)" opacity=".8">
|
||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||
</g>
|
||
</g>`;
|
||
}
|
||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||
function fixtureSvg() {
|
||
const x = lineX();
|
||
const tick = (y, id) => {
|
||
const on = S.focus === id;
|
||
return `
|
||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||
};
|
||
return `
|
||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||
}
|
||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||
function cropFrameSvg() {
|
||
const m = cropInset();
|
||
return `
|
||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||
}
|
||
function rotateHandleSvg() {
|
||
return `
|
||
<g class="rotg" data-rot="1">
|
||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||
</g>`;
|
||
}
|
||
function stageSvg() {
|
||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||
${cropFrameSvg()}
|
||
${slabSvg()}
|
||
${fixtureSvg()}
|
||
${rotateHandleSvg()}
|
||
</svg>`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ screens
|
||
const screens = {
|
||
batch() {
|
||
return `<section class="panel">
|
||
<h1>Batch photos</h1>
|
||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||
</section>`;
|
||
},
|
||
mask() {
|
||
return `<section class="panel">
|
||
<h1>Mask</h1>
|
||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||
<div class="nums">
|
||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||
</div>
|
||
<div class="row">
|
||
<button class="ghost" data-act="to-batch">Back</button>
|
||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||
</div>
|
||
</section>`;
|
||
},
|
||
tape() {
|
||
const off = axisOffset(), ok = aligned();
|
||
return `<section class="panel">
|
||
<h1>Tape line</h1>
|
||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||
<div class="tw" id="tw">${stageSvg()}</div>
|
||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||
|
||
<div class="controls">
|
||
<div>
|
||
<p class="ctl-label">Tilt the slab</p>
|
||
<div class="angle">
|
||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||
</div>
|
||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||
<div class="angle">
|
||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||
</div>
|
||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||
<div class="croprow">
|
||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||
<span class="val" id="fillVal">${S.fill}%</span>
|
||
</div>
|
||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||
? "The board's long axis is on the line ✓"
|
||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||
|
||
<div class="nums">
|
||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||
</div>
|
||
|
||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||
|
||
<div class="swap">
|
||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button class="ghost" data-act="to-mask">Back</button>
|
||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||
</div>
|
||
|
||
<div class="plan">
|
||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||
<ul>
|
||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||
</ul>
|
||
</div>
|
||
</section>`;
|
||
},
|
||
measure() {
|
||
const mini = (tilt, marks) => `
|
||
<svg viewBox="0 0 480 640">
|
||
<rect width="480" height="640" fill="#16281c"/>
|
||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||
</g>
|
||
</svg>`;
|
||
return `<section class="panel">
|
||
<h1>Photos ready</h1>
|
||
<div class="fields">
|
||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||
</div>
|
||
<div class="nums">
|
||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||
</div>
|
||
<div class="plan">
|
||
<h3>Rotation scope — req. 6</h3>
|
||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||
</div>
|
||
<ol class="lineup">
|
||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||
</ol>
|
||
<div class="row">
|
||
<button class="ghost" data-act="to-tape">Back</button>
|
||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||
</div>
|
||
</section>`;
|
||
},
|
||
settings() {
|
||
return `<section class="panel settings">
|
||
<h1>Settings</h1>
|
||
<div class="section">
|
||
<h3>Crop — global</h3>
|
||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||
<div class="croprow">
|
||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||
<span class="val" id="fillVal">${S.fill}%</span>
|
||
</div>
|
||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||
<button data-act="save-fill">Save Changes</button>
|
||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||
</div>
|
||
<div class="section">
|
||
<h3>Implementation notes</h3>
|
||
<ul class="plan" style="border:0;padding:0;background:none">
|
||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||
</ul>
|
||
</div>
|
||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||
</section>`;
|
||
},
|
||
};
|
||
|
||
// the two mini overlays for the was/now pair
|
||
function oldOverlayMini() {
|
||
return `<svg viewBox="0 0 120 160">
|
||
<rect width="120" height="160" fill="#141414"/>
|
||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||
</svg>`;
|
||
}
|
||
function newOverlayMini() {
|
||
return `<svg viewBox="0 0 120 160">
|
||
<rect width="120" height="160" fill="#141414"/>
|
||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||
</svg>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------------- plumbing
|
||
const phone = document.getElementById('phone');
|
||
const flowbar = document.getElementById('flowbar');
|
||
const STEPS = [
|
||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||
];
|
||
function render() {
|
||
phone.innerHTML = screens[S.screen]();
|
||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||
}
|
||
flowbar.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-goto]');
|
||
if (!b) return;
|
||
S.screen = b.dataset.goto;
|
||
render();
|
||
});
|
||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-w]');
|
||
if (!b) return;
|
||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||
b.classList.add('on');
|
||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||
document.getElementById('vpval').textContent = b.dataset.w;
|
||
});
|
||
|
||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||
// (a full panel re-render would destroy the element under the pointer).
|
||
function refreshStage() {
|
||
const host = document.getElementById('tw');
|
||
if (!host) return;
|
||
host.innerHTML = stageSvg();
|
||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||
set('tiltVal', S.tilt + '\u00B0');
|
||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||
set('fillVal', S.fill + '%');
|
||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||
const st = document.getElementById('alignstatus');
|
||
if (st) {
|
||
const ok = aligned(), off = axisOffset();
|
||
st.className = 'status' + (ok ? ' ok' : '');
|
||
st.innerHTML = ok
|
||
? "The board's long axis is on the line \u2713"
|
||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||
}
|
||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||
axisAngle: axisAngle(), aligned: aligned() };
|
||
}
|
||
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||
// twin, stepping about the stage centre so the slab stays where it is.
|
||
function stepZoom(dir) {
|
||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||
refreshStage();
|
||
}
|
||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||
function nudgeTick(dir, mult) {
|
||
const step = (mult === undefined ? 1 : mult);
|
||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||
render();
|
||
}
|
||
|
||
function bindStage() {
|
||
const host = document.getElementById('tw');
|
||
if (!host) return;
|
||
const toVB = (e) => {
|
||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||
};
|
||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||
function zoomAt(p, factor) {
|
||
const cx = VB.w / 2, cy = VB.h / 2;
|
||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||
if (z1 === z0) return;
|
||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||
S.zoom = z1;
|
||
S.panX = p.x - cx - z1 * (qx - cx);
|
||
S.panY = p.y - cy - z1 * (qy - cy);
|
||
refreshStage();
|
||
}
|
||
host.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||
const dy = clamp(e.deltaY, -200, 200);
|
||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||
}, { passive: false });
|
||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||
|
||
let drag = null, tb = null, moved = false, downP = null;
|
||
const off = { x: 0, y: 0 };
|
||
host.addEventListener('pointerdown', (e) => {
|
||
const tick = e.target.closest('[data-tick]');
|
||
const rot = e.target.closest('[data-rot]');
|
||
const p = toVB(e);
|
||
moved = false; downP = p;
|
||
if (tick) {
|
||
S.focus = tick.dataset.tick;
|
||
drag = { kind: "tick", id: S.focus };
|
||
} else if (rot) {
|
||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||
} else {
|
||
drag = { kind: "slab" };
|
||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||
}
|
||
host.setPointerCapture(e.pointerId);
|
||
host.classList.add('dragging');
|
||
refreshStage();
|
||
e.preventDefault();
|
||
});
|
||
host.addEventListener('pointermove', (e) => {
|
||
if (!drag) return;
|
||
const p = toVB(e);
|
||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||
if (drag.kind === "tick") {
|
||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||
const y = clamp(p.y, 20, VB.h - 20);
|
||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||
} else if (drag.kind === "rot") {
|
||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||
} else {
|
||
S.panX = clamp(p.x - off.x, -400, 400);
|
||
S.panY = clamp(p.y - off.y, -400, 400);
|
||
}
|
||
refreshStage();
|
||
});
|
||
const end = (e) => {
|
||
if (!drag) return;
|
||
const wasSlab = drag.kind === "slab";
|
||
const startP = toVB(e);
|
||
drag = null;
|
||
host.classList.remove('dragging');
|
||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||
// dblclick both stop arriving), so track the taps ourselves.
|
||
if (wasSlab && !moved) {
|
||
const now = performance.now();
|
||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||
tb = { t: now, x: startP.x, y: startP.y };
|
||
}
|
||
refreshStage();
|
||
};
|
||
host.addEventListener('pointerup', end);
|
||
host.addEventListener('pointercancel', end);
|
||
}
|
||
|
||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||
document.addEventListener('keydown', (e) => {
|
||
if (S.screen !== "tape") return;
|
||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (!e.key.startsWith("Arrow")) return;
|
||
const fine = e.shiftKey ? 0.1 : 1;
|
||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||
else return;
|
||
e.preventDefault();
|
||
});
|
||
|
||
phone.addEventListener('click', (e) => {
|
||
const t = e.target.closest('[data-act]');
|
||
if (!t) return;
|
||
const a = t.dataset.act;
|
||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||
else if (a === "fit") fitSlab();
|
||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||
});
|
||
phone.addEventListener('input', (e) => {
|
||
const t = e.target;
|
||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||
else if (t.dataset.act === "fill-range") {
|
||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||
// rectangle's aspect never changes and the line's x never moves.
|
||
S.fill = +t.value;
|
||
const ct = cropTicks();
|
||
S.tickA = ct.a; S.tickB = ct.b;
|
||
refreshStage();
|
||
}
|
||
});
|
||
phone.addEventListener('change', (e) => {
|
||
const t = e.target;
|
||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|