feat(photos): Photos-ready lineup after Calculate (#64)
Add buildPhotosReadyLineup (clean cal → overlay → batch inventory PNGs in capture order, plus empty-mask/floor error slots). MeasurePanel switches to a Photos ready end screen with Milestone 1 numbers and the inventory PNG lineup; Back still returns to Length axis.
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import { inToEighths, eighthsToIn } from './capture.types';
|
import { inToEighths, eighthsToIn } from './capture.types';
|
||||||
import { fmtEighths } from './session';
|
import { fmtEighths } from './session';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
|
import {
|
||||||
|
buildPhotosReadyLineup,
|
||||||
|
photosReadyPngAlt,
|
||||||
|
type PhotosReadyLineupItem,
|
||||||
|
} from './photosReadyLineup';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'measure' }>;
|
export let state: Extract<Capture, { k: 'measure' }>;
|
||||||
export let setLength: (e: number) => void;
|
export let setLength: (e: number) => void;
|
||||||
@@ -15,6 +21,32 @@
|
|||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||||
|
|
||||||
|
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,
|
||||||
|
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||||
|
});
|
||||||
|
lineupUrls = lineupItems.map((it) =>
|
||||||
|
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lineupItems = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
|
});
|
||||||
|
|
||||||
const positiveInches = (raw: string): number | null => {
|
const positiveInches = (raw: string): number | null => {
|
||||||
const inches = Number.parseFloat(raw);
|
const inches = Number.parseFloat(raw);
|
||||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||||
@@ -46,60 +78,61 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Measure</h1>
|
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||||
<PhotoStage
|
|
||||||
blob={state.source.blob}
|
|
||||||
bbox={state.axis.bbox}
|
|
||||||
maskBitset={state.approvedMask.bitset}
|
|
||||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
|
||||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="fields">
|
{#if !photosReady}
|
||||||
<div class="field">
|
<PhotoStage
|
||||||
<span class="label">Length L</span>
|
blob={state.source.blob}
|
||||||
<input
|
bbox={state.axis.bbox}
|
||||||
type="number"
|
maskBitset={state.approvedMask.bitset}
|
||||||
aria-label="Length L"
|
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||||
inputmode="decimal"
|
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||||
step="0.125"
|
/>
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
<div class="fields">
|
||||||
bind:value={lengthIn}
|
<div class="field">
|
||||||
on:blur={commitLength}
|
<span class="label">Length L</span>
|
||||||
on:change={commitLength}
|
<input
|
||||||
/>
|
type="number"
|
||||||
|
aria-label="Length L"
|
||||||
|
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>
|
||||||
<div class="field">
|
|
||||||
<span class="label">Thickness</span>
|
<div class="row">
|
||||||
<input
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
type="number"
|
<button
|
||||||
aria-label="Thickness"
|
type="button"
|
||||||
inputmode="decimal"
|
class="primary"
|
||||||
step="0.125"
|
disabled={calcBusy || !canCalc}
|
||||||
min="0"
|
on:click={doCalc}
|
||||||
placeholder="0"
|
>
|
||||||
bind:value={thicknessIn}
|
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||||
on:blur={commitThickness}
|
</button>
|
||||||
on:change={commitThickness}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else if state.results}
|
||||||
|
<div class="results" aria-label="Photos ready">
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if state.results}
|
|
||||||
<div class="results">
|
|
||||||
<div class="nums">
|
<div class="nums">
|
||||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||||
@@ -109,12 +142,42 @@
|
|||||||
{#if state.results.sourceBelowFloor}
|
{#if state.results.sourceBelowFloor}
|
||||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pngUrl && !state.results.sourceBelowFloor}
|
|
||||||
|
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||||
|
{#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'}
|
||||||
|
Clean calibration
|
||||||
|
{:else if item.role === 'overlay'}
|
||||||
|
Dimension overlay
|
||||||
|
{:else}
|
||||||
|
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</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.sourceBelowFloor}
|
||||||
<div class="png-wrap">
|
<div class="png-wrap">
|
||||||
<img src={pngUrl} alt="Inventory PNG" />
|
<img src={pngUrl} alt="Inventory PNG" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -136,11 +199,29 @@
|
|||||||
}
|
}
|
||||||
.primary:disabled { background: #333; color: #777; }
|
.primary:disabled { background: #333; color: #777; }
|
||||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
.results { margin-top: 20px; }
|
.results { margin-top: 8px; }
|
||||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||||
.nums span:first-child { color: #aaa; }
|
.nums span:first-child { color: #aaa; }
|
||||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||||
|
.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 { max-width: 100%; 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 { 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 { max-width: 100%; border-radius: 4px; }
|
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { BatchPhotoResult } from './capture.types';
|
||||||
|
|
||||||
|
/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */
|
||||||
|
export type PhotosReadyLineupItem =
|
||||||
|
| {
|
||||||
|
kind: 'png';
|
||||||
|
role: 'calibration' | 'overlay' | 'batch';
|
||||||
|
blob: Blob;
|
||||||
|
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||||
|
batchIndex?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'error';
|
||||||
|
batchIndex: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PhotosReadyLineupInput {
|
||||||
|
inventoryPng?: Blob | null;
|
||||||
|
overlayPng?: Blob | null;
|
||||||
|
batchApply?: BatchPhotoResult[] | null;
|
||||||
|
/** When true, omit the clean calibration PNG (M1 floor warn path). */
|
||||||
|
sourceBelowFloor?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Photos-ready lineup:
|
||||||
|
* 1. Clean calibration inventory PNG
|
||||||
|
* 2. Dimension-overlay copy
|
||||||
|
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||||
|
* with error slots for empty mask / floor failures.
|
||||||
|
*/
|
||||||
|
export function buildPhotosReadyLineup(
|
||||||
|
input: PhotosReadyLineupInput,
|
||||||
|
): PhotosReadyLineupItem[] {
|
||||||
|
const items: PhotosReadyLineupItem[] = [];
|
||||||
|
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||||
|
|
||||||
|
if (inventoryPng && !sourceBelowFloor) {
|
||||||
|
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||||
|
}
|
||||||
|
if (overlayPng) {
|
||||||
|
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchApply) {
|
||||||
|
for (let i = 0; i < batchApply.length; i++) {
|
||||||
|
const slot = batchApply[i];
|
||||||
|
if (slot === null) continue; // calibration lives in results.inventoryPng
|
||||||
|
if (slot.status === 'ok') {
|
||||||
|
items.push({
|
||||||
|
kind: 'png',
|
||||||
|
role: 'batch',
|
||||||
|
blob: slot.inventoryPng,
|
||||||
|
batchIndex: i,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
kind: 'error',
|
||||||
|
batchIndex: i,
|
||||||
|
message: slot.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
|
||||||
|
if (item.role === 'calibration') return 'Calibration inventory PNG';
|
||||||
|
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
|
||||||
|
const n = (item.batchIndex ?? 0) + 1;
|
||||||
|
return `Batch inventory PNG ${n}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user