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

+63 -2
View File
@@ -1,3 +1,64 @@
<script lang="ts"></script> <script lang="ts">
import { onMount } from 'svelte';
import ImageCanvas from './components/ImageCanvas.svelte';
import { AppState } from './lib/app-state.svelte';
import { loadOpenCV } from './lib/opencv-loader';
<h1>Scrap Tracer</h1> const app = new AppState();
onMount(() => {
loadOpenCV().then(
({ cv }) => app.setCv(cv),
() => (app.cvError = 'Could not load image processing (OpenCV).'),
);
});
function ondrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (file) app.loadFile(file);
}
</script>
<div class="layout">
<aside>
<input type="file" accept="image/*" onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}} />
</aside>
<main ondragover={(e) => e.preventDefault()} {ondrop}>
{#if app.image}
<ImageCanvas {app} />
{:else}
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p>
{/if}
</main>
</div>
<style>
:global(body) {
margin: 0;
font-family: system-ui, sans-serif;
color: #222;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
aside {
overflow-y: auto;
border-right: 1px solid #ddd;
padding: 16px;
}
main {
position: relative;
background: #2b2b2b;
}
.empty {
color: #bbb;
text-align: center;
margin-top: 40vh;
}
</style>
+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>
+22 -3
View File
@@ -1,12 +1,11 @@
export type CV = typeof import('@techstark/opencv-js'); export type CV = typeof import('@techstark/opencv-js');
// Everything returns { cv }: the cv module is a thenable, so resolving with it directly never settles.
let loading: Promise<{ cv: CV }> | null = null; let loading: Promise<{ cv: CV }> | null = null;
// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles.
export function loadOpenCV(): Promise<{ cv: CV }> { export function loadOpenCV(): Promise<{ cv: CV }> {
loading ??= (async () => { loading ??= (async () => {
const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV }; const { cv } = typeof document === 'undefined' ? await importModule() : await loadScript();
const cv = mod.default;
if (!cv.Mat) { if (!cv.Mat) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
(cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve(); (cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve();
@@ -16,3 +15,23 @@ export function loadOpenCV(): Promise<{ cv: CV }> {
})(); })();
return loading; return loading;
} }
/** Node (tests). The variable specifier keeps the bundler from emitting a second OpenCV chunk. */
async function importModule(): Promise<{ cv: CV }> {
const specifier = '@techstark/opencv-js';
const mod = (await import(/* @vite-ignore */ specifier)) as { default: CV };
return { cv: mod.default };
}
// A script tag, not import(): bundler CJS interop copies `then` and the import never settles.
async function loadScript(): Promise<{ cv: CV }> {
const { default: url } = await import('@techstark/opencv-js/dist/opencv.js?url');
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load OpenCV'));
document.head.append(script);
});
return { cv: (window as unknown as { cv: CV }).cv };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />