424 lines
17 KiB
Svelte
424 lines
17 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import type { Capture, PhotoTransform, TapeLine } from './capture.types';
|
||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||
import {
|
||
applyApprovedMask,
|
||
CALIBRATION_FRAME,
|
||
cropTickPositions,
|
||
DEFAULT_PHOTO_TRANSFORM,
|
||
pinchPhotoTransform,
|
||
photoFitScale,
|
||
photoToStage,
|
||
stageToPhoto,
|
||
tapeLineFromStageTicks,
|
||
} from './tapeCalibration';
|
||
|
||
export let state: Extract<Capture, { k: 'axis' }>;
|
||
export let setTapeLine: (tapeLine: TapeLine, transform: PhotoTransform) => void;
|
||
export let approveAxis: () => void;
|
||
export let back: () => void;
|
||
export let cropFill = 90;
|
||
export let setCropFill: (pct: number) => void = () => {};
|
||
|
||
let stage: HTMLDivElement | undefined;
|
||
let canvas: HTMLCanvasElement | undefined;
|
||
let bitmap: ImageBitmap | null = null;
|
||
let sourceW = state.source.widthPx;
|
||
let sourceH = state.source.heightPx;
|
||
let stageW = 0;
|
||
let stageH = 0;
|
||
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? DEFAULT_PHOTO_TRANSFORM) };
|
||
let tickA = photoToStage(state.axis.tapeLine.start, sourceW, sourceH, transform)[1];
|
||
let tickB = photoToStage(state.axis.tapeLine.end, sourceW, sourceH, transform)[1];
|
||
let focusedTick: 'A' | 'B' = 'A';
|
||
let drag: 'tickA' | 'tickB' | 'pan' | 'tilt' | 'pinch' | null = null;
|
||
let lastPointer: [number, number] = [0, 0];
|
||
let pointerStart: [number, number] = [0, 0];
|
||
let transformStart = 0;
|
||
let pointers = new Map<number, [number, number]>();
|
||
let pinchStart: { distance: number; anchor: [number, number] } | null = null;
|
||
let panTapCandidate = false;
|
||
let previousTap: { time: number; point: [number, number] } | null = null;
|
||
let lastCropFill = cropFill;
|
||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const frame = CALIBRATION_FRAME;
|
||
const lineX = frame.width / 2;
|
||
const tiltX = frame.width / 2;
|
||
const tiltY = frame.height - 26;
|
||
const tickHitRadius = 38; // 76 logical px keeps the drag target above 44px on a 320px viewport.
|
||
|
||
function stagePoint(event: PointerEvent | WheelEvent): [number, number] {
|
||
const rect = canvas!.getBoundingClientRect();
|
||
return [
|
||
((event.clientX - rect.left) / rect.width) * frame.width,
|
||
((event.clientY - rect.top) / rect.height) * frame.height,
|
||
];
|
||
}
|
||
|
||
function redraw(): void {
|
||
if (!canvas || !stageW || !stageH) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = Math.round(stageW * dpr);
|
||
canvas.height = Math.round(stageH * dpr);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr * stageW / frame.width, 0, 0, dpr * stageH / frame.height, 0, 0);
|
||
ctx.fillStyle = '#161616';
|
||
ctx.fillRect(0, 0, frame.width, frame.height);
|
||
|
||
if (bitmap && sourceW > 0 && sourceH > 0) {
|
||
const scale = photoFitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
|
||
ctx.save();
|
||
ctx.translate(lineX + transform.panX, frame.height / 2 + transform.panY);
|
||
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
|
||
ctx.scale(scale, scale);
|
||
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.lineCap = 'round';
|
||
ctx.strokeStyle = 'rgba(255,255,255,.92)';
|
||
ctx.lineWidth = 6;
|
||
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineWidth = 2.4;
|
||
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
|
||
for (const [y, id] of [[tickA, 'A'], [tickB, 'B']] as const) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,.95)';
|
||
ctx.lineWidth = 6;
|
||
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineWidth = 2.4;
|
||
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(lineX, y, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = focusedTick === id ? '#ff3d71' : '#000';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
}
|
||
ctx.beginPath(); ctx.arc(tiltX, tiltY, 17, 0, Math.PI * 2);
|
||
ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fill();
|
||
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.fillStyle = '#00e5ff'; ctx.font = '15px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('↻', tiltX, tiltY + 2);
|
||
ctx.fillStyle = '#aaa'; ctx.font = '10.5px system-ui'; ctx.textAlign = 'left';
|
||
ctx.fillText('drag to tilt the slab', tiltX + 24, tiltY + 4);
|
||
ctx.restore();
|
||
}
|
||
|
||
function photoPoint(point: [number, number]): [number, number] {
|
||
return stageToPhoto(point, sourceW, sourceH, transform);
|
||
}
|
||
|
||
function syncCalibration(): void {
|
||
const tapeLine = tapeLineFromStageTicks(tickA, tickB, sourceW, sourceH, transform);
|
||
setTapeLine(tapeLine, { ...transform });
|
||
}
|
||
|
||
function moveTickA(y: number): void {
|
||
tickA = Math.max(20, Math.min(tickB - 40, y));
|
||
}
|
||
|
||
function moveTickB(y: number): void {
|
||
tickB = Math.max(tickA + 40, Math.min(frame.height - 20, y));
|
||
}
|
||
|
||
function queuePersist(): void {
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(() => {
|
||
persistTimer = null;
|
||
syncCalibration();
|
||
}, 250);
|
||
}
|
||
|
||
function flushCalibration(): void {
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
syncCalibration();
|
||
}
|
||
|
||
function approveCalibration(): void {
|
||
flushCalibration();
|
||
approveAxis();
|
||
}
|
||
|
||
function backToMask(): void {
|
||
flushCalibration();
|
||
back();
|
||
}
|
||
|
||
function updatePointer(event: PointerEvent): [number, number] {
|
||
const point = stagePoint(event);
|
||
pointers.set(event.pointerId, point);
|
||
return point;
|
||
}
|
||
|
||
function onPointerDown(event: PointerEvent): void {
|
||
const point = updatePointer(event);
|
||
if (pointers.size > 1) {
|
||
const [a, b] = [...pointers.values()];
|
||
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
pinchStart = {
|
||
distance: Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1])),
|
||
anchor: photoPoint(center),
|
||
};
|
||
drag = 'pinch';
|
||
panTapCandidate = false;
|
||
canvas?.setPointerCapture(event.pointerId);
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
const distA = Math.hypot(point[0] - lineX, point[1] - tickA);
|
||
const distB = Math.hypot(point[0] - lineX, point[1] - tickB);
|
||
const coreA = distA <= 12;
|
||
const coreB = distB <= 12;
|
||
if (coreA || coreB) {
|
||
focusedTick = distA <= distB ? 'A' : 'B';
|
||
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
|
||
panTapCandidate = false;
|
||
} else if (Math.hypot(point[0] - tiltX, point[1] - tiltY) < 18) {
|
||
drag = 'tilt';
|
||
pointerStart = point;
|
||
transformStart = transform.tiltDeg;
|
||
panTapCandidate = false;
|
||
} else if (distA <= tickHitRadius || distB <= tickHitRadius) {
|
||
focusedTick = distA <= distB ? 'A' : 'B';
|
||
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
|
||
panTapCandidate = false;
|
||
} else {
|
||
drag = 'pan';
|
||
lastPointer = point;
|
||
pointerStart = point;
|
||
panTapCandidate = true;
|
||
}
|
||
canvas?.setPointerCapture(event.pointerId);
|
||
redraw();
|
||
event.preventDefault();
|
||
}
|
||
|
||
function onPointerMove(event: PointerEvent): void {
|
||
if (!drag) return;
|
||
const point = updatePointer(event);
|
||
if (drag === 'pinch' && pinchStart && pointers.size > 1) {
|
||
const [a, b] = [...pointers.values()];
|
||
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
const distance = Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1]));
|
||
transform = pinchPhotoTransform(
|
||
transform, pinchStart.anchor, center, distance / pinchStart.distance, sourceW, sourceH,
|
||
);
|
||
pinchStart.distance = distance;
|
||
} else if (drag === 'tickA') moveTickA(point[1]);
|
||
else if (drag === 'tickB') moveTickB(point[1]);
|
||
else if (drag === 'tilt') transform.tiltDeg = transformStart + (point[0] - pointerStart[0]) * 0.6;
|
||
else {
|
||
if (drag === 'pan' && Math.hypot(point[0] - pointerStart[0], point[1] - pointerStart[1]) > 4) panTapCandidate = false;
|
||
transform.panX += point[0] - lastPointer[0];
|
||
transform.panY += point[1] - lastPointer[1];
|
||
lastPointer = point;
|
||
}
|
||
redraw();
|
||
queuePersist();
|
||
}
|
||
|
||
function onPointerUp(event: PointerEvent): void {
|
||
pointers.delete(event.pointerId);
|
||
if (!drag) return;
|
||
try { canvas?.releasePointerCapture(event.pointerId); } catch { /* already released */ }
|
||
if (drag === 'pinch' && pointers.size > 0) {
|
||
drag = 'pan';
|
||
pinchStart = null;
|
||
panTapCandidate = false;
|
||
lastPointer = [...pointers.values()][0];
|
||
queuePersist();
|
||
redraw();
|
||
return;
|
||
}
|
||
const wasSlabTap = drag === 'pan' && panTapCandidate;
|
||
const tapPoint = stagePoint(event);
|
||
drag = null;
|
||
pinchStart = null;
|
||
panTapCandidate = false;
|
||
flushCalibration();
|
||
if (wasSlabTap) {
|
||
const now = performance.now();
|
||
if (previousTap && now - previousTap.time < 350
|
||
&& Math.hypot(tapPoint[0] - previousTap.point[0], tapPoint[1] - previousTap.point[1]) < 12) {
|
||
previousTap = null;
|
||
fitSlab();
|
||
} else previousTap = { time: now, point: tapPoint };
|
||
}
|
||
redraw();
|
||
}
|
||
|
||
function zoomAt(point: [number, number], factor: number): void {
|
||
const anchor = photoPoint(point);
|
||
transform.zoom = Math.max(0.5, Math.min(3, transform.zoom * factor));
|
||
const after = photoToStage(anchor, sourceW, sourceH, transform);
|
||
transform.panX += point[0] - after[0];
|
||
transform.panY += point[1] - after[1];
|
||
redraw();
|
||
queuePersist();
|
||
}
|
||
|
||
function onWheel(event: WheelEvent): void {
|
||
event.preventDefault();
|
||
zoomAt(stagePoint(event), Math.exp(-event.deltaY * 0.0015));
|
||
}
|
||
|
||
function fitSlab(): void {
|
||
transform = { ...transform, zoom: 1, panX: 0, panY: 0 };
|
||
redraw();
|
||
syncCalibration();
|
||
}
|
||
|
||
function changeTilt(degrees: number): void {
|
||
transform = { ...transform, tiltDeg: transform.tiltDeg + degrees };
|
||
redraw();
|
||
syncCalibration();
|
||
}
|
||
|
||
function changeZoom(factor: number): void {
|
||
zoomAt([frame.width / 2, frame.height / 2], factor);
|
||
}
|
||
|
||
function onKeyDown(event: KeyboardEvent): void {
|
||
const fine = event.shiftKey ? 0.1 : 1;
|
||
if (event.key === 'ArrowLeft') changeTilt(-fine);
|
||
else if (event.key === 'ArrowRight') changeTilt(fine);
|
||
else if (event.key === 'ArrowUp') {
|
||
if (focusedTick === 'A') moveTickA(tickA - fine);
|
||
else moveTickB(tickB - fine);
|
||
syncCalibration(); redraw();
|
||
} else if (event.key === 'ArrowDown') {
|
||
if (focusedTick === 'A') moveTickA(tickA + fine);
|
||
else moveTickB(tickB + fine);
|
||
syncCalibration(); redraw();
|
||
} else if (event.key === '+' || event.key === '=') changeZoom(1.1);
|
||
else if (event.key === '-' || event.key === '_') changeZoom(1 / 1.1);
|
||
else return;
|
||
event.preventDefault();
|
||
}
|
||
|
||
function changeCropFill(event: Event): void {
|
||
const pct = Number((event.currentTarget as HTMLInputElement).value);
|
||
setCropFill(pct);
|
||
[tickA, tickB] = cropTickPositions(pct);
|
||
syncCalibration();
|
||
redraw();
|
||
}
|
||
|
||
$: if (cropFill !== lastCropFill) {
|
||
lastCropFill = cropFill;
|
||
[tickA, tickB] = cropTickPositions(cropFill);
|
||
syncCalibration();
|
||
redraw();
|
||
}
|
||
|
||
onMount(() => {
|
||
let alive = true;
|
||
const resize = new ResizeObserver((entries) => {
|
||
const entry = entries[0];
|
||
if (!entry) return;
|
||
stageW = entry.contentRect.width;
|
||
stageH = entry.contentRect.height;
|
||
redraw();
|
||
});
|
||
if (stage) resize.observe(stage);
|
||
void decodeOrientedBitmap(state.source.blob).then(async (decoded) => {
|
||
if (!alive) { decoded.close(); return; }
|
||
sourceW = decoded.width;
|
||
sourceH = decoded.height;
|
||
if (state.approvedMask.bitset.length !== sourceW * sourceH) {
|
||
decoded.close();
|
||
return;
|
||
}
|
||
const maskedCanvas = document.createElement('canvas');
|
||
maskedCanvas.width = sourceW; maskedCanvas.height = sourceH;
|
||
const maskedCtx = maskedCanvas.getContext('2d');
|
||
if (!maskedCtx) { decoded.close(); return; }
|
||
maskedCtx.drawImage(decoded, 0, 0);
|
||
const pixels = maskedCtx.getImageData(0, 0, sourceW, sourceH);
|
||
pixels.data.set(applyApprovedMask(pixels.data, state.approvedMask.bitset));
|
||
maskedCtx.putImageData(pixels, 0, 0);
|
||
const maskedBitmap = await createImageBitmap(maskedCanvas);
|
||
decoded.close();
|
||
if (!alive) { maskedBitmap.close(); return; }
|
||
bitmap = maskedBitmap;
|
||
redraw();
|
||
}).catch(() => {});
|
||
return () => {
|
||
alive = false;
|
||
resize.disconnect();
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
bitmap?.close(); bitmap = null;
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<section class="panel">
|
||
<h1>Tape line</h1>
|
||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||
<div class="stage" bind:this={stage}>
|
||
<canvas
|
||
bind:this={canvas}
|
||
tabindex="0"
|
||
aria-label="Fixed tape line over the masked slab. Drag the slab to pan, use the tilt control, and drag ticks along the line."
|
||
on:pointerdown={onPointerDown}
|
||
on:pointermove={onPointerMove}
|
||
on:pointerup={onPointerUp}
|
||
on:pointercancel={onPointerUp}
|
||
on:wheel|nonpassive={onWheel}
|
||
on:keydown={onKeyDown}
|
||
></canvas>
|
||
</div>
|
||
<p class="hint">The black line is fixed in the middle of the frame. Move the slab instead: drag to pan, wheel or pinch to zoom, and use the rotate handle or arrow keys to tilt until the slab's long axis lies on the line.</p>
|
||
<div class="control">
|
||
<label for="photo-angle">Tilt the slab</label>
|
||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => changeTilt(-1)}>−</button>
|
||
<input id="photo-angle" type="range" min="0" max="359" step="1" value={((transform.tiltDeg % 360) + 360) % 360}
|
||
on:input={(event) => { transform.tiltDeg = Number(event.currentTarget.value); redraw(); syncCalibration(); }} />
|
||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => changeTilt(1)}>+</button>
|
||
<span class="value">{((transform.tiltDeg % 360) + 360) % 360}°</span>
|
||
</div>
|
||
<div class="control">
|
||
<label for="photo-zoom">Zoom the slab</label>
|
||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => changeZoom(1 / 1.1)}>−</button>
|
||
<input id="photo-zoom" type="range" min="50" max="300" step="1" value={Math.round(transform.zoom * 100)}
|
||
on:input={(event) => { changeZoom(Number(event.currentTarget.value) / (transform.zoom * 100)); }} />
|
||
<span class="value">{Math.round(transform.zoom * 100)}%</span>
|
||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => changeZoom(1.1)}>+</button>
|
||
<button type="button" class="minor" on:click={fitSlab}>Fit</button>
|
||
</div>
|
||
<div class="control">
|
||
<label for="crop-fill">Crop fill %</label>
|
||
<input id="crop-fill" type="range" min="1" max="100" step="1" value={cropFill} on:input={changeCropFill} />
|
||
<span class="value">{cropFill}%</span>
|
||
</div>
|
||
<p class="hint">Arrow keys tilt the slab; Shift + arrow adjusts by 0.1°. The last tick you grabbed takes the up/down keys (1px, or 0.1px with Shift). Use +/− to zoom; double-click the slab to fit.</p>
|
||
<div class="row">
|
||
<button type="button" class="ghost" on:click={backToMask}>Back</button>
|
||
<button type="button" class="primary" on:click={approveCalibration}>Approve tape line</button>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.panel { padding: 16px; color: #eee; }
|
||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||
.hint { color: #aaa; font-size: 0.85rem; margin: 0 0 12px; line-height: 1.45; }
|
||
.stage { width: min(100%, 420px); aspect-ratio: 3 / 4; margin: 0 auto; overflow: hidden; background: #161616; border: 1px solid #333; border-radius: 3px; }
|
||
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; outline-offset: 2px; }
|
||
.control { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
|
||
.control label { min-width: 86px; color: #bbb; font-size: 0.82rem; }
|
||
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
||
.value { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||
.thumb, .minor { min-width: 44px; min-height: 44px; border: 1px solid #444; background: #1d1d1d; color: #eee; padding: 6px 9px; }
|
||
.thumb { border-radius: 50%; font-size: 1.2rem; flex: none; }
|
||
.minor { border-radius: 3px; }
|
||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||
@media (max-width: 360px) { .control { gap: 6px; } .control label { min-width: 64px; } }
|
||
</style>
|