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:
1 parent
4b19c7ea4e
commit
bec28fd0c1
4 files changed
+261
-5
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user