feat: add zoomable image canvas with detection overlays

Load OpenCV in the browser via a script tag: the bundler's CJS interop
copies the module's then() onto the import namespace, which never settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 14:55:55 +02:00
1 parent 4b19c7ea4e
commit bec28fd0c1
4 files changed
+261 -5

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import type { AppState } from '../lib/app-state.svelte';
import type { Point, Shape } from '../lib/types';
import { fitView, screenToImage, zoomAt, type View } from '../lib/view';
let { app }: { app: AppState } = $props();
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let size = $state({ width: 0, height: 0 });
let view = $state<View>({ scale: 1, offsetX: 0, offsetY: 0 });
let drag: { start: Point; last: Point; moved: boolean } | null = null;
const photo = $derived.by(() => {
const image = app.image;
if (!image) return null;
const c = document.createElement('canvas');
c.width = image.width;
c.height = image.height;
c.getContext('2d')!.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
return c;
});
$effect(() => {
const observer = new ResizeObserver(([entry]) => {
size = { width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height) };
});
observer.observe(container);
return () => observer.disconnect();
});
$effect(() => {
if (app.image && size.width > 0 && size.height > 0) {
view = fitView(app.image.width, app.image.height, size.width, size.height);
}
});
// Registered manually: wheel listeners must be non-passive to stop page scrolling.
$effect(() => {
const onwheel = (e: WheelEvent) => {
e.preventDefault();
view = zoomAt(view, local(e), Math.exp(-e.deltaY * 0.001));
};
canvas.addEventListener('wheel', onwheel, { passive: false });
return () => canvas.removeEventListener('wheel', onwheel);
});
$effect(() => {
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!photo) return;
ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
ctx.drawImage(photo, 0, 0);
const px = 1 / view.scale; // one screen pixel in image units
if (app.shapePx) drawShape(ctx, app.shapePx, px);
if (app.mode === 'manual') {
for (const ring of [app.manualOuter, ...app.manualHoles]) drawPolyline(ctx, ring, true, '#ff9800', px, [6 * px, 4 * px]);
}
if (app.drawing.length > 0) drawPolyline(ctx, app.drawing, false, '#ff9800', px, []);
drawScale(ctx, app.scalePoints, app.lengthMm, px);
if (app.seed) drawSeed(ctx, app.seed, px);
});
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape, px: number) {
ctx.beginPath();
for (const ring of [shape.outer, ...shape.holes]) traceRing(ctx, ring, true);
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
ctx.fill('evenodd');
ctx.strokeStyle = '#00c853';
ctx.lineWidth = 2 * px;
ctx.setLineDash([]);
ctx.stroke();
}
function drawPolyline(ctx: CanvasRenderingContext2D, points: Point[], closed: boolean, color: string, px: number, dash: number[]) {
ctx.beginPath();
traceRing(ctx, points, closed);
ctx.strokeStyle = color;
ctx.lineWidth = 2 * px;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
ctx.fill();
}
}
function traceRing(ctx: CanvasRenderingContext2D, ring: Point[], closed: boolean) {
ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
if (closed) ctx.closePath();
}
function drawScale(ctx: CanvasRenderingContext2D, points: Point[], lengthMm: number | null, px: number) {
if (points.length === 0) return;
drawPolyline(ctx, points, false, '#e53935', px, []);
if (points.length === 2 && lengthMm) {
const mid = { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
ctx.font = `${14 * px}px sans-serif`;
ctx.fillStyle = '#e53935';
ctx.fillText(`${lengthMm} mm`, mid.x + 6 * px, mid.y - 6 * px);
}
}
function drawSeed(ctx: CanvasRenderingContext2D, p: Point, px: number) {
const r = 8 * px;
ctx.beginPath();
ctx.moveTo(p.x - r, p.y);
ctx.lineTo(p.x + r, p.y);
ctx.moveTo(p.x, p.y - r);
ctx.lineTo(p.x, p.y + r);
ctx.strokeStyle = '#2962ff';
ctx.lineWidth = 2 * px;
ctx.stroke();
}
function local(e: PointerEvent | WheelEvent): Point {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onpointerdown(e: PointerEvent) {
canvas.setPointerCapture(e.pointerId);
const p = local(e);
drag = { start: p, last: p, moved: false };
}
function onpointermove(e: PointerEvent) {
if (!drag) return;
const p = local(e);
if (Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 4) drag.moved = true;
if (drag.moved) {
view = { ...view, offsetX: view.offsetX + p.x - drag.last.x, offsetY: view.offsetY + p.y - drag.last.y };
}
drag.last = p;
}
function onpointerup(e: PointerEvent) {
if (drag && !drag.moved) app.handleImageClick(screenToImage(local(e), view));
drag = null;
}
</script>
<div class="canvas-wrap" bind:this={container}>
<canvas
bind:this={canvas}
class:picking={app.tool !== 'none'}
{onpointerdown}
{onpointermove}
{onpointerup}
></canvas>
</div>
<style>
.canvas-wrap {
position: absolute;
inset: 0;
overflow: hidden;
}
canvas {
display: block;
cursor: grab;
touch-action: none;
}
canvas.picking {
cursor: crosshair;
}
</style>