Files
SlabUploader/frontend/src/capture/MeasurePanel.svelte
T
grokbot_ty 20a7d7d9d5 feat(ui): Jeff-facing copy + 3:4 inventory thumbs + fixed overlay font
Locked UI/copy pass on proto-per-user-woo-creds (Settings/Woo gate polish out of scope):
- Inventory PNG thumbs at 3:4 contain (Review, Publish preview, Photos ready); stop 1:1 cover squaring
- Dimension overlay labels fixed OVERLAY_FONT_PX=36 for every product
- Page 1–3 Jeff-plain copy; drop Measure red tech essays / px scale rows
- Review: lock SVG, consistent red missing/not-ready, View prompt gated (Settings default off)
2026-09-27 09:04:47 -07:00

453 lines
17 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 { onDestroy, onMount } from 'svelte';
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import type { TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { getTaxonomy } from '../api/slabs';
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
type PhotosReadyLineupItem,
} from './photosReadyLineup';
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let setSpecies: (id: number | null) => void;
export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
export let onContinue: (() => void) | undefined = undefined;
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let speciesSel = state.speciesId == null ? '' : String(state.speciesId);
let speciesOpts: { id: number; name: string }[] = [];
let speciesLoadErr = '';
let cropFill = getCropFill();
/** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */
let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
$: photosReady = !!state.results;
$: {
for (const u of lineupUrls) URL.revokeObjectURL(u);
lineupUrls = [];
if (state.results) {
lineupItems = buildPhotosReadyLineup({
inventoryPng: state.results.inventoryPng,
overlayPng: state.results.overlayPng,
batchApply: state.batchApply,
sourceBelowBlock: state.results.sourceBelowBlock,
sourceBelowWarn: state.results.sourceBelowWarn,
});
lineupUrls = lineupItems.map((it) =>
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
);
} else {
lineupItems = [];
}
}
// Sync local select only when session speciesId changes (draft restore / setSpecies).
// Do NOT depend on speciesSel — that fights bind:value and can clear a choice
// before setSpecies lands (or wipe restored ids while options are still loading).
let syncedSpeciesId: number | null | undefined = undefined;
$: if (syncedSpeciesId !== state.speciesId) {
syncedSpeciesId = state.speciesId;
speciesSel = state.speciesId == null ? '' : String(state.speciesId);
}
let speciesOptsReady = false;
onMount(() => {
void getTaxonomy()
.then((tax) => {
speciesOpts = speciesLeavesFromTaxonomy(tax.categories).map((c) => ({
id: c.id,
name: c.name,
}));
speciesOptsReady = true;
})
.catch(() => {
speciesLoadErr = 'Could not load species list.';
speciesOpts = [];
speciesOptsReady = true;
});
});
onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
};
const setTilt = (deg: number) => {
const clamped = clampTiltDeg(deg);
setTape({ ...tape, tiltDeg: clamped });
setAxisAngle(clamped);
};
const nudgeZoom = (dir: number) => {
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
setTape({ ...tape, zoom: z });
};
const onFill = (pct: number) => {
const fill = Math.min(1, Math.max(0.01, pct / 100));
cropFill = setCropFill(fill);
const ticks = defaultTicks(cropFill);
setTape({ ...tape, ...ticks });
};
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setLength(eighths);
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitThickness = () => {
const inches = positiveInches(String(thicknessIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitSpecies = () => {
setSpecies(speciesSel === '' ? null : Number(speciesSel));
};
$: canCalc =
positiveInches(String(lengthIn)) !== null
&& positiveInches(String(thicknessIn)) !== null
&& speciesSel !== '';
const doCalc = async () => {
calcBusy = true;
try {
commitLength();
commitThickness();
commitSpecies();
await calculate();
} finally { calcBusy = false; }
};
</script>
<section class="panel">
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady}
<p class="hint">Line up the slab with the black line, then enter length, thickness, and species.</p>
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
<button
type="button"
class="mode-btn"
class:active={gestureMode === 'zoompan'}
aria-pressed={gestureMode === 'zoompan'}
data-testid="gesture-mode-zoompan"
on:click={() => (gestureMode = 'zoompan')}
>Zoom / Pan</button>
<button
type="button"
class="mode-btn"
class:active={gestureMode === 'rotate'}
aria-pressed={gestureMode === 'rotate'}
data-testid="gesture-mode-rotate"
on:click={() => (gestureMode = 'rotate')}
>Rotate</button>
</div>
<!-- Same binding as Mask/Axis: oriented source + approved bitset.
PhotoStage paints opaque checkerboard on non-slab (iOS-safe under the
CSS pan/zoom/tilt transform — alpha punch left Zoom/Pan blank for Ty). -->
<PhotoStage
mode="tape"
blob={state.source.blob}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
{tape}
{cropFill}
{gestureMode}
onTapeChange={onTapeChange}
/>
<div class="controls">
<div>
<p class="ctl-label">Tilt</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input
type="range" min="-90" max="90" step="0.1"
value={clampTiltDeg(tiltShown)}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span>
</div>
</div>
<div>
<p class="ctl-label">Zoom</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
<input
type="range" min="50" max="300" step="1"
value={Math.round(tape.zoom * 100)}
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
/>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
<span class="val">{Math.round(tape.zoom * 100)}%</span>
</div>
<div class="row tight">
<button type="button" class="ghost" on:click={fit}>Reset view</button>
</div>
</div>
<div>
<p class="ctl-label">Frame fill</p>
<div class="croprow">
<input
type="range" min="50" max="100" step="1"
value={Math.round(cropFill * 100)}
aria-label="Frame fill"
on:input={(e) => onFill(Number(e.currentTarget.value))}
/>
<span class="val">{Math.round(cropFill * 100)}%</span>
</div>
</div>
</div>
<div class="fields">
<div class="field">
<span class="label">Length (in)</span>
<input
type="number"
aria-label="Length (in)"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={lengthIn}
on:blur={commitLength}
on:change={commitLength}
/>
</div>
<div class="field">
<span class="label">Thickness</span>
<input
type="number"
aria-label="Thickness"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={thicknessIn}
on:blur={commitThickness}
on:change={commitThickness}
/>
</div>
</div>
<div class="field species">
<label class="label" for="measure-species">Species</label>
{#if !speciesOptsReady}
<select id="measure-species" class="sel" data-testid="measure-species" aria-label="Species" disabled>
<option value="">Loading species…</option>
</select>
{:else}
<select
id="measure-species"
class="sel"
data-testid="measure-species"
aria-label="Species"
bind:value={speciesSel}
on:change={commitSpecies}
>
<option value="">Select species…</option>
{#each speciesOpts as o (o.id)}
<option value={String(o.id)}>{o.name}</option>
{/each}
</select>
{/if}
{#if speciesLoadErr}
<p class="species-err" role="status">{speciesLoadErr}</p>
{/if}
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || !canCalc}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{:else if state.results}
<div class="results" aria-label="Photos ready">
<div class="nums">
<div><span>Widths (avg {fmtEighths(state.results.widths.avg)})</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)}</span></div>
<div><span>Square feet</span><span>{state.results.sqft.toFixed(2)}</span></div>
<div><span>Board feet</span><span>{state.results.bdft.toFixed(2)}</span></div>
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
</div>
{#if state.results.sourceBelowBlock}
<div class="warn" role="alert" data-testid="size-floor-block">
This photo is too small. Take a closer or larger photo.
</div>
{:else if state.results.sourceBelowWarn}
<div class="warn" data-testid="size-floor-warn">
This photo is a bit small. A closer shot will look sharper on the listing.
</div>
{/if}
<ol class="lineup" aria-label="Photos for the listing">
{#each lineupItems as item, i (i)}
{#if item.kind === 'png'}
<li class="lineup-item png">
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
<span class="role">
{#if item.role === 'calibration'}
Reference photo
{:else if item.role === 'overlay'}
Photo with measurements
{:else}
Photo {(item.batchIndex ?? 0) + 1}
{/if}
</span>
{#if item.warn}
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
{/if}
</li>
{:else}
<li class="lineup-item error" role="alert">
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
<span class="err-msg">{item.message}</span>
</li>
{/if}
{/each}
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
<div class="png-wrap">
<img src={pngUrl} alt="Listing photo" />
</div>
{/if}
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
{#if onContinue}
<button type="button" class="primary" data-testid="continue-to-review" on:click={onContinue}>
Continue to Review
</button>
{/if}
</div>
{/if}
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.mode-toggle {
display: flex; gap: 0; margin: 10px 0 4px;
border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%;
}
.mode-btn {
min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
border: 0; background: #1d1d1d; color: #aaa; cursor: pointer;
}
.mode-btn + .mode-btn { border-left: 1px solid #444; }
.mode-btn.active { background: #00e5ff; color: #002; }
.nudge { color: #aaa; font-size: 0.8rem; margin: 6px 0 0; line-height: 1.45; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; }
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.field.species { margin-top: 16px; flex: none; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input, .sel {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums;
}
.species-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; }
.row { display: flex; gap: 10px; margin-top: 20px; }
.row.tight { margin-top: 8px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
.nums span:first-child { color: #aaa; }
.tab { font-variant-numeric: tabular-nums; text-align: right; }
.dim { color: #777; }
.results { margin-top: 8px; }
.results .nums { font-size: 1rem; }
.results .nums div { font-size: 1rem; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;
display: flex; flex-direction: column; gap: 14px;
}
.lineup-item.png {
text-align: center;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
background-color: #eee; border-radius: 8px; padding: 8px;
}
.lineup-item.png img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; border-radius: 4px; display: block; margin: 0 auto; }
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
.lineup-item.error {
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
}
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; border-radius: 4px; }
</style>