Files
SlabUploader/frontend/src/capture/AxisPanel.svelte
T

424 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 { 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>