Files
SlabUploader/frontend/src/capture/SessionCapture.svelte
T
Ty 0bc86ecc6d fix(fe): debounce mask auto-run 500ms after slider/sheet changes
Rapid sensitivity drags triggered a flood of async mask runs; an in-flight
run could settle after the owner pressed Approve and clobber the state, so
the Approve click read a snapshot with overlay:null and early-returned.
Debounce 500ms after the user stops adjusting so the mask settles before any
interaction and the click reads a stable state.
2026-09-25 09:19:52 -07:00

247 lines
11 KiB
Svelte
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.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CaptureSession } from './session';
import type { Sheet } from './capture.types';
export let session: CaptureSession;
const dispatch = createEventDispatcher<{ 'photos-ready': void }>();
const stateStore = session.state;
$: state = $stateStore;
$: cap = state.capture;
$: kind = cap.k;
$: photos = kind === 'batch' || kind === 'pickCalibration' ? cap.photos : [];
$: control = kind === 'mask' ? cap.control : null;
$: overlay = kind === 'mask' ? cap.overlay : null;
$: failed = kind === 'mask' ? cap.failed : false;
$: measure = kind === 'measure' ? cap : null;
$: results = measure?.results ?? null;
// Mask preview: the calibration photo with removed (non-slab) pixels shown
// as a light-gray checkerboard (CAPTURE-45/50). Redrawn when the overlay
// settles. The photo element is bound so its natural size is available.
let maskCanvas: HTMLCanvasElement;
let maskImg: HTMLImageElement;
let maskObjUrl = '';
$: if (kind === 'mask' && cap.source) {
if (maskObjUrl) URL.revokeObjectURL(maskObjUrl);
maskObjUrl = URL.createObjectURL(cap.source.blob);
}
$: if (kind === 'mask' && maskImg?.complete && maskImg.naturalWidth > 0 && overlay) {
drawMaskPreview();
}
function drawMaskPreview() {
const canvas = maskCanvas;
if (!canvas || !maskImg || !overlay) return;
const w = maskImg.naturalWidth;
const h = maskImg.naturalHeight;
// Cap the canvas at a sensible size for performance; keep aspect.
const maxDim = 900;
const scale = Math.min(1, maxDim / Math.max(w, h));
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height);
// Downsample the full-res bitset to the canvas grid and checker removed px.
const bitset = overlay.bitset;
const cw = canvas.width;
const ch = canvas.height;
const cell = 6; // checker cell px
const imgData = ctx.getImageData(0, 0, cw, ch);
const px = imgData.data;
for (let y = 0; y < ch; y++) {
for (let x = 0; x < cw; x++) {
const sx = Math.min(w - 1, Math.round((x / cw) * w));
const sy = Math.min(h - 1, Math.round((y / ch) * h));
if (bitset[sy * w + sx]) continue; // slab pixel: keep photo
const checker = ((Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0);
const i = (y * cw + x) * 4;
const g = checker ? 210 : 235;
px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}
let fileInput: HTMLInputElement;
// Auto-run the mask so the overlay (and preview) populate without the owner
// pressing Approve first. Debounced 500ms after the user stops adjusting the
// sheet/sensitivity, so rapid slider drags don't trigger a flood of async
// mask runs (and an in-flight run can't clobber the state around an Approve
// click). Keyed on control values, not the overlay, so settling never loops.
let maskRunTimer = 0;
$: controlKey = kind === 'mask' && cap.control ? `${cap.control.sheet}:${cap.control.sensitivity}` : null;
$: if (controlKey && cap.source && cap.source.widthPx > 0) {
clearTimeout(maskRunTimer);
maskRunTimer = setTimeout(() => {
void session.approveMask();
}, 500);
}
function pickFiles(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files) return;
for (const file of Array.from(input.files)) {
session.addToBatch(file);
}
input.value = '';
}
function onSheetChange(e: Event) {
session.setSheet((e.target as HTMLSelectElement).value as Sheet);
}
function onSensitivityInput(e: Event) {
session.setSensitivity(Number((e.target as HTMLInputElement).value));
}
function onLengthChange(e: Event) {
session.setLength(Number((e.target as HTMLInputElement).value));
}
function onThicknessChange(e: Event) {
session.setThickness(Number((e.target as HTMLInputElement).value));
}
function onThumbKey(e: KeyboardEvent, i: number) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
session.pickCalibration(i);
}
}
function fmt(eighths: number): string {
const whole = Math.floor(eighths / 8);
const rem = eighths - whole * 8;
if (rem === 0) return `${whole}″`;
const frac: Record<number, string> = { 1: '⅛', 2: '¼', 3: '⅜', 4: '½', 5: '⅝', 6: '¾', 7: '⅞' };
return whole > 0 ? `${whole} ${frac[rem] ?? `${rem}/8`}″` : `${frac[rem] ?? `${rem}/8`}″`;
}
</script>
<section>
{#if kind === 'batch'}
<h2>1. Batch photos</h2>
<p>Add one or more photos of the slab on a green or black sheet.</p>
<div class="picker-row">
<button type="button" class="picker" on:click={() => fileInput?.click()}>Choose photos</button>
<span>{photos.length} photo{photos.length === 1 ? '' : 's'}</span>
<input bind:this={fileInput} class="file-input" type="file" multiple accept="image/*" on:change={pickFiles} />
</div>
{#if photos.length}
<div class="thumbs">
{#each photos as p, i (i)}
<div class="thumb">
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
<button on:click={() => session.removeFromBatch(i)}>×</button>
</div>
{/each}
</div>
<button class="primary" on:click={() => session.continueBatch()}>Continue →</button>
{/if}
{:else if kind === 'pickCalibration'}
<h2>2. Pick calibration photo</h2>
<p>Choose the reference shot that will drive mask, axis, and measure.</p>
<div class="thumbs">
{#each photos as p, i (i)}
<div class="thumb" role="button" tabindex="0" on:click={() => session.pickCalibration(i)} on:keydown={(e) => onThumbKey(e, i)} aria-label="pick photo {i} as calibration">
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
</div>
{/each}
</div>
<button on:click={() => session.back()}>← Back</button>
{:else if kind === 'mask'}
<h2>3. Mask</h2>
<p>Adjust sheet and sensitivity, then approve the mask. Removed background shows as a gray checkerboard.</p>
{#if cap.source && maskObjUrl}
<div class="mask-preview">
<canvas bind:this={maskCanvas} aria-label="mask preview"></canvas>
<img bind:this={maskImg} src={maskObjUrl} alt="calibration photo (hidden, drives mask preview)" class="mask-src" />
</div>
{/if}
{#if control}
<div class="controls">
<label>Sheet
<select value={control.sheet} on:change={onSheetChange}>
<option value="auto">Auto</option>
<option value="green">Green</option>
<option value="black">Black</option>
</select>
</label>
<label>Sensitivity {control.sensitivity}
<input type="range" min="0" max="100" step="1" value={control.sensitivity} on:input={onSensitivityInput} />
</label>
</div>
{/if}
{#if overlay && overlay.slabPx === 0}
<p class="err">No slab pixels detected — the mask removed everything. Lower the sensitivity, or set the sheet to match what the photo sits on, then retake if the shot is dark.</p>
{:else if failed}
<p class="err">No slab pixels detected. Adjust controls or retake.</p>
{:else if overlay}
<p>Mask settled: {overlay.slabPx} slab pixels.</p>
{/if}
<div class="actions">
<button class="primary" disabled={!overlay || overlay.slabPx === 0} on:click={() => session.approveMaskAxis()}>Approve mask →</button>
<button on:click={() => session.back()}>← Back</button>
</div>
{:else if kind === 'axis'}
<h2>4. Length axis</h2>
<p>Place the tape endpoints at the two ends of the slab's length.</p>
<div class="actions">
<button class="primary" on:click={() => session.approveAxis()}>Approve axis →</button>
<button on:click={() => session.back()}>← Back</button>
</div>
{:else if kind === 'measure'}
<h2>5. Measure</h2>
<p>Enter length and thickness (1/8″ steps). Widths, sqft, and bdft are derived from the calibration mask.</p>
{#if measure}
<div class="controls">
<label>Length (eighths) <input type="number" min="0" step="1" value={measure.lengthEighths} on:change={onLengthChange} /></label>
<label>Thickness (eighths) <input type="number" min="0" step="1" value={measure.thicknessEighths} on:change={onThicknessChange} /></label>
</div>
{/if}
{#if results}
<div class="results">
<div>Length <strong>{fmt(results.lengthEighths)}</strong></div>
<div>Thickness <strong>{fmt(results.thicknessEighths)}</strong></div>
<div>Sqft <strong>{results.sqft}</strong></div>
<div>Bdft <strong>{results.bdft}</strong></div>
</div>
<button class="primary" on:click={() => dispatch('photos-ready')}>Photos ready →</button>
{:else}
<button class="primary" disabled={!measure || measure.lengthEighths === 0 || measure.thicknessEighths === 0} on:click={() => void session.calculate()}>Calculate</button>
{/if}
<button on:click={() => session.back()}>← Back</button>
{:else}
<p>Unknown capture state.</p>
{/if}
</section>
<style>
.mask-preview { margin: 1rem 0; }
.mask-preview canvas { width: 100%; height: auto; max-height: 70vh; object-fit: contain; display: block; border: 1px solid #444; background: #000; }
.mask-src { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.picker-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 1rem 0; color: #aaa; }
.picker { min-height: 44px; padding: 0.65rem 1rem; background: #00e5ff; color: #002; border: 0; font-weight: 700; border-radius: 4px; }
.file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.5rem; margin: 1rem 0; }
.thumb { position: relative; border: 1px solid #444; padding: 4px; }
.thumb img { width: 100%; height: 80px; object-fit: cover; display: block; }
.thumb button { position: absolute; top: 2px; right: 2px; background: #a33; color: #fff; border: none; border-radius: 3px; width: 20px; height: 20px; }
.controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0; }
.controls label { display: grid; gap: 0.35rem; }
.controls input, .controls select { padding: 0.3rem; background: #222; color: #eee; border: 1px solid #444; }
.actions { display: flex; gap: 0.75rem; margin: 1rem 0; }
.primary { background: #0a7; color: #fff; border: none; padding: 0.6rem 1.2rem; }
.err { color: #f88; }
.results { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; background: #1a1a1a; padding: 1rem; border-radius: 4px; margin: 1rem 0; }
</style>