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

252 lines
9.4 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import type { Capture, TapeLine } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
export let state: Extract<Capture, { k: 'axis' }>;
export let setTapeLine: (tapeLine: TapeLine) => void;
export let approveAxis: () => void;
export let back: () => 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 tickA: PhotoPoint = [...state.axis.tapeLine.start];
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
let angleDeg = 0;
let zoom = 1;
let panX = 0;
let panY = 0;
let dragging: 'start' | 'end' | 'pan' | null = null;
let selected: 'start' | 'end' = 'start';
let lastPointer: PhotoPoint = [0, 0];
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
function fittedScale(): number {
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
return Math.min(stageW / sourceW, stageH / sourceH);
}
function view(): PhotoViewTransform {
return {
width: sourceW,
height: sourceH,
centerX: stageW / 2,
centerY: stageH / 2,
scale: fittedScale(),
zoom,
angleDeg,
panX,
panY,
};
}
function draw(): void {
if (!canvas || !stageW || !stageH) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(stageW * dpr);
canvas.height = Math.round(stageH * dpr);
canvas.style.width = `${stageW}px`;
canvas.style.height = `${stageH}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, stageW, stageH);
const transform = view();
if (bitmap && transform.scale > 0) {
ctx.save();
ctx.translate(transform.centerX + panX, transform.centerY + panY);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.scale(transform.scale * zoom, transform.scale * zoom);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
ctx.restore();
}
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
if (which === selected) {
ctx.strokeStyle = '#ffd600';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.restore();
}
function pointerPosition(event: PointerEvent): PhotoPoint {
const rect = canvas!.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top];
}
function onPointerDown(event: PointerEvent): void {
const point = pointerPosition(event);
const transform = view();
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
if (dragging !== 'pan') selected = dragging;
lastPointer = point;
canvas?.setPointerCapture(event.pointerId);
}
function onPointerMove(event: PointerEvent): void {
if (!dragging) return;
const point = pointerPosition(event);
if (dragging === 'pan') {
panX += point[0] - lastPointer[0];
panY += point[1] - lastPointer[1];
} else {
const sourcePoint = screenToPhoto(point, view());
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
if (dragging === 'start') tickA = sourcePoint;
else tickB = sourcePoint;
}
lastPointer = point;
draw();
}
function onPointerUp(event: PointerEvent): void {
if (dragging === 'start' || dragging === 'end') {
setTapeLine({ start: [...tickA], end: [...tickB] });
}
dragging = null;
canvas?.releasePointerCapture(event.pointerId);
}
function zoomAt(x: number, y: number, factor: number): void {
const before = screenToPhoto([x, y], view());
zoom = Math.max(0.5, Math.min(5, zoom * factor));
const after = photoToScreen(before, view());
panX += x - after[0];
panY += y - after[1];
draw();
}
function onWheel(event: WheelEvent): void {
event.preventDefault();
const rect = canvas!.getBoundingClientRect();
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
}
function resetView(): void {
angleDeg = 0;
zoom = 1;
panX = 0;
panY = 0;
draw();
}
onMount(() => {
let alive = true;
const resize = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
stageW = entry.contentRect.width;
stageH = entry.contentRect.height;
draw();
});
if (stage) resize.observe(stage);
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
if (!alive) {
decoded.close();
return;
}
bitmap = decoded;
sourceW = decoded.width;
sourceH = decoded.height;
draw();
});
return () => {
alive = false;
resize.disconnect();
bitmap?.close();
bitmap = null;
};
});
</script>
<section class="panel">
<h1>Tape-line calibration</h1>
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
<div class="stage" bind:this={stage}>
<canvas
bind:this={canvas}
aria-label="Calibration photo with draggable tape endpoint ticks"
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel|nonpassive={onWheel}
></canvas>
</div>
<div class="control">
<label for="photo-angle">Rotate photo</label>
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
<span class="value">{angleDeg.toFixed(1)}°</span>
</div>
<div class="control">
<label for="photo-zoom">Zoom</label>
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
<span class="value">{Math.round(zoom * 100)}%</span>
<button type="button" class="minor" on:click={resetView}>Reset view</button>
</div>
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use 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.9rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
canvas { display: block; touch-action: none; cursor: grab; }
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.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; }
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>