20a7d7d9d5
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)
453 lines
17 KiB
Svelte
453 lines
17 KiB
Svelte
<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>
|