diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx
index 11b7977..0464daa 100644
--- a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx
+++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx
@@ -2,7 +2,14 @@ import { useEffect, useMemo, useRef } from "react";
import { useResizeObserver } from "../hooks/useResizeObserver";
import { useProbeViewport } from "../hooks/useProbeViewport";
-import { CONTACT_COLORS, drawContactShape, renderScaleBar } from "../geometry/draw";
+import { computeGeometry } from "../geometry/viewport";
+import {
+ CONTACT_COLORS,
+ computeIdLabelInfo,
+ drawContactIds,
+ drawContactShape,
+ renderScaleBar,
+} from "../geometry/draw";
import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe";
interface DoubleSidedProbeCanvasProps {
@@ -16,34 +23,6 @@ interface DoubleSidedProbeCanvasProps {
onZoom: (zoom: number) => void;
}
-interface GeometrySummary {
- width: number;
- height: number;
- centerX: number;
- centerY: number;
-}
-
-// Bounds over the raw contacts and contour (true positions — the overlay never
-// displaces a face, so framing is just the probe's own extent).
-function computeGeometry(positions: number[][], contour: number[][]): GeometrySummary | null {
- let minX = Number.POSITIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
- const update = (point: number[]) => {
- if (point[0] < minX) minX = point[0];
- if (point[0] > maxX) maxX = point[0];
- if (point[1] < minY) minY = point[1];
- if (point[1] > maxY) maxY = point[1];
- };
- positions.forEach(update);
- contour.forEach(update);
- if (!Number.isFinite(minX)) return null;
- const width = Math.max(10, maxX - minX);
- const height = Math.max(10, maxY - minY);
- return { width, height, centerX: minX + width / 2, centerY: minY + height / 2 };
-}
-
function colorForSide(side: string | undefined) {
return side === "back" ? CONTACT_COLORS.back : CONTACT_COLORS.front;
}
@@ -67,6 +46,10 @@ export function DoubleSidedProbeCanvas({
return computeGeometry(probe.contact_positions ?? [], probe.probe_planar_contour ?? []);
}, [probe]);
+ // Uniform contact-id sizing info (widest label + smallest pad in µm), shared
+ // with the single-sided canvas so labels track contact size the same way.
+ const labelInfo = useMemo(() => computeIdLabelInfo(probe), [probe]);
+
const {
canvasRef,
getProjection,
@@ -144,23 +127,23 @@ export function DoubleSidedProbeCanvas({
});
// Contact IDs make the isolated face a channel map (the point of the view).
- if (probe.contact_ids) {
- const contactIds = probe.contact_ids;
- ctx.font = `${Math.max(10, Math.min(14, 10 * (scale / 100)))}px "Inter", sans-serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "top";
- ctx.fillStyle = "rgba(15, 23, 42, 0.95)";
- positions.forEach((position, index) => {
- if ((sides[index] ?? "front") !== overlaySide) return;
- const [x, y] = projectPoint(position);
- ctx.fillText(String(contactIds[index] ?? index), x, y + 4);
+ // Same fit-to-contact sizing as the single-sided canvas, restricted to the
+ // face on screen so labels stay legible and never overflow their pads.
+ if (probe.contact_ids && labelInfo) {
+ drawContactIds(ctx, {
+ positions,
+ contactIds: probe.contact_ids,
+ labelInfo,
+ scale,
+ projectPoint,
+ shouldDraw: (index) => (sides[index] ?? "front") === overlaySide,
});
}
if (showScaleBar) {
renderScaleBar(ctx, scale, heightPx);
}
- }, [canvasRef, entry.id, geometry, getProjection, overlaySide, probe, showScaleBar, size.height, size.width, zoom, centerX, centerY]);
+ }, [canvasRef, entry.id, geometry, getProjection, labelInfo, overlaySide, probe, showScaleBar, size.height, size.width, zoom, centerX, centerY]);
return (
diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx
index bd6be1b..159c213 100644
--- a/apps/probe-viewer/src/components/ProbeCanvas.tsx
+++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx
@@ -1,21 +1,16 @@
-import {
- forwardRef,
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
-} from "react";
-import type {
- MouseEvent as ReactMouseEvent,
- PointerEvent as ReactPointerEvent,
-} from "react";
+import { useEffect, useMemo, useRef } from "react";
import { useResizeObserver } from "../hooks/useResizeObserver";
-import { VIEW_ZOOM_MIN } from "../state/useAppStore";
+import { useProbeViewport } from "../hooks/useProbeViewport";
+import { computeGeometry } from "../geometry/viewport";
+import {
+ CONTACT_COLORS,
+ computeIdLabelInfo,
+ drawContactIds,
+ drawContactShape,
+ renderScaleBar,
+} from "../geometry/draw";
import type {
- ContactShapeParams,
ManifestEntry,
ProbeInterfaceFile,
ProbeViewerCamera,
@@ -32,130 +27,63 @@ interface ProbeCanvasProps {
onZoom: (zoom: number) => void;
}
-interface GeometrySummary {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- width: number;
- height: number;
- centerX: number;
- centerY: number;
-}
-
-function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary | null {
- const probe = probeData.probes?.[0];
- if (!probe) {
- return null;
- }
-
- const positions = probe.contact_positions ?? [];
- if (positions.length === 0) {
- return null;
- }
-
- let minX = Number.POSITIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
-
- const updateBounds = (point: number[]) => {
- const [x, y] = point;
- if (x < minX) minX = x;
- if (x > maxX) maxX = x;
- if (y < minY) minY = y;
- if (y > maxY) maxY = y;
- };
-
- positions.forEach(updateBounds);
- (probe.probe_planar_contour ?? []).forEach(updateBounds);
-
- const width = Math.max(10, maxX - minX);
- const height = Math.max(10, maxY - minY);
- const centerX = minX + width / 2;
- const centerY = minY + height / 2;
-
- return { minX, maxX, minY, maxY, width, height, centerX, centerY };
-}
-
-export const ProbeCanvas = forwardRef
(
- function ProbeCanvas(
- {
- entry,
- probeData,
- camera,
- maxZoom,
- showContactIds,
- showScaleBar,
- onViewCenterChange,
- onZoom,
- },
- ref
- ) {
+export function ProbeCanvas({
+ entry,
+ probeData,
+ camera,
+ maxZoom,
+ showContactIds,
+ showScaleBar,
+ onViewCenterChange,
+ onZoom,
+}: ProbeCanvasProps) {
const { zoom, centerX, centerY } = camera;
- const canvasRef = useRef(null);
-
- // Expose canvas to parent for export
- useImperativeHandle(ref, () => canvasRef.current!, []);
const { ref: containerRef, size } = useResizeObserver();
- const [isDragging, setIsDragging] = useState(false);
- const dragOriginRef = useRef<{ x: number; y: number; viewCenterX: number; viewCenterY: number } | null>(null);
// Track the last applied canvas backing-store size so we only reallocate (an
// expensive clear + realloc of the whole pixel buffer) when the size or
// device-pixel-ratio actually changes, not on every pan/zoom redraw.
const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 });
- // Coalesce pan updates to one per animation frame: pointermove fires far more
- // often than the screen repaints, so we keep only the latest target.
- const panRafRef = useRef(0);
- const pendingViewCenterRef = useRef<{ x: number; y: number } | null>(null);
- const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]);
const probe = useMemo(() => probeData.probes?.[0], [probeData]);
-
- // For uniform contact-id sizing: the widest id label (so one font fits the
- // longest) and the smallest contact box in micrometers (so it fits every pad).
- // These are zoom-independent, so they are computed once per probe.
- const idLabelInfo = useMemo(() => {
- const ids = probe?.contact_ids;
- const positions = probe?.contact_positions;
- if (!ids || !positions || positions.length === 0) return null;
- const shapes = probe.contact_shapes ?? [];
- const params = probe.contact_shape_params ?? [];
- let widestLabel = "";
- let minWidthUm = Infinity;
- let minHeightUm = Infinity;
- for (let i = 0; i < positions.length; i++) {
- const label = String(ids[i] ?? i);
- if (label.length > widestLabel.length) widestLabel = label;
- const shape = shapes[i] ?? "";
- const p = params[i] ?? {};
- const widthUm = shape === "circle" ? 2 * (p.radius ?? 5) : p.width ?? 10;
- const heightUm =
- shape === "circle"
- ? 2 * (p.radius ?? 5)
- : shape === "rect"
- ? p.height ?? 15
- : p.width ?? 10;
- if (widthUm < minWidthUm) minWidthUm = widthUm;
- if (heightUm < minHeightUm) minHeightUm = heightUm;
- }
- return { widestLabel, minWidthUm, minHeightUm };
+ const geometry = useMemo(() => {
+ if (!probe) return null;
+ return computeGeometry(
+ probe.contact_positions ?? [],
+ probe.probe_planar_contour ?? [],
+ );
}, [probe]);
- // Calculate effective view center (use geometry center if null)
- const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0;
- const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0;
+ // Uniform contact-id sizing info (widest label + smallest pad in µm). These are
+ // zoom-independent, so they are computed once per probe.
+ const labelInfo = useMemo(() => computeIdLabelInfo(probe), [probe]);
+
+ const {
+ canvasRef,
+ getProjection,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ handleDoubleClick,
+ } = useProbeViewport({
+ geometry,
+ camera,
+ size,
+ maxZoom,
+ onViewCenterChange,
+ onZoom,
+ });
useEffect(() => {
if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) {
return;
}
-
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
- if (!ctx) {
- return;
- }
+ if (!ctx) return;
+
+ const projection = getProjection();
+ if (!projection) return;
+ const { scale, projectPoint } = projection;
const devicePixelRatio = window.devicePixelRatio || 1;
const widthPx = size.width;
@@ -175,49 +103,21 @@ export const ProbeCanvas = forwardRef(
lastCanvasSizeRef.current = { w: targetW, h: targetH, dpr: devicePixelRatio };
}
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
-
ctx.clearRect(0, 0, widthPx, heightPx);
-
- const padding = 40;
- const availableWidth = Math.max(10, widthPx - padding * 2);
- const availableHeight = Math.max(10, heightPx - padding * 2);
- const baseScale = Math.min(
- availableWidth / geometry.width,
- availableHeight / geometry.height,
- );
- const scale = baseScale * zoom;
-
- // Calculate pixel pan from view center in probe coordinates
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
-
- const offsetX = widthPx / 2 + panX;
- const offsetY = heightPx / 2 + panY;
-
- const projectPoint = (point: number[]) => {
- const [x, y] = point;
- const normX = (x - geometry.centerX) * scale + offsetX;
- const normY = -(y - geometry.centerY) * scale + offsetY;
- return [normX, normY];
- };
-
ctx.lineCap = "round";
ctx.lineJoin = "round";
- if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
+ // Technical line-art: a faint cool wash so the shank reads as a region, with
+ // a thin precise outline. No fill gradient or shadow.
+ const contour = probe.probe_planar_contour ?? [];
+ if (contour.length > 1) {
ctx.beginPath();
- probe.probe_planar_contour.forEach((point, index) => {
- const [px, py] = projectPoint(point);
- if (index === 0) {
- ctx.moveTo(px, py);
- } else {
- ctx.lineTo(px, py);
- }
+ contour.forEach((point, index) => {
+ const [x, y] = projectPoint(point);
+ if (index === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
});
ctx.closePath();
-
- // Technical line-art: a faint cool wash so the shank reads as a region,
- // with a thin precise outline. No fill gradient or shadow.
ctx.fillStyle = "rgba(51, 65, 85, 0.05)";
ctx.fill();
ctx.strokeStyle = "rgba(51, 65, 85, 0.9)";
@@ -225,371 +125,57 @@ export const ProbeCanvas = forwardRef(
ctx.stroke();
}
- const contactPositions = probe.contact_positions ?? [];
+ const positions = probe.contact_positions ?? [];
const contactShapes = probe.contact_shapes ?? [];
const contactShapeParams = probe.contact_shape_params ?? [];
- // Draws one contact path centered on the current origin (callers translate
- // the context to the pad position first). Rectangular pads get lightly
- // rounded corners so they read as real electrode pads, not hard tiles.
- const drawContactShape = (shape: string, params: ContactShapeParams) => {
- ctx.beginPath();
- switch (shape) {
- case "circle": {
- const radius = (params.radius ?? 5) * scale;
- ctx.arc(0, 0, radius, 0, Math.PI * 2);
- break;
- }
- case "square":
- case "rect": {
- const w = (params.width ?? 10) * scale;
- const h = (shape === "square" ? (params.width ?? 10) : (params.height ?? 15)) * scale;
- const r = Math.min(w, h) * 0.12;
- if (typeof ctx.roundRect === "function") {
- ctx.roundRect(-w / 2, -h / 2, w, h, r);
- } else {
- ctx.rect(-w / 2, -h / 2, w, h);
- }
- break;
- }
- default: {
- // Unknown/missing shape: a dot with an X to flag missing data.
- const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100)));
- ctx.arc(0, 0, markerSize * 0.4, 0, Math.PI * 2);
- ctx.moveTo(-markerSize, -markerSize);
- ctx.lineTo(markerSize, markerSize);
- ctx.moveTo(markerSize, -markerSize);
- ctx.lineTo(-markerSize, markerSize);
- }
- }
- };
-
// Flat gold contacts (the recognizable electrode convention), with a defined
- // bronze outline and no gradient or shadow — focal without the metallic
- // shine that was pulling focus.
- contactPositions.forEach((position, index) => {
+ // bronze outline and no gradient or shadow.
+ ctx.fillStyle = CONTACT_COLORS.front.fill;
+ ctx.strokeStyle = CONTACT_COLORS.front.stroke;
+ ctx.lineWidth = Math.max(1, Math.min(1.8, 2.5 * (scale / 150)));
+ positions.forEach((position, index) => {
const [x, y] = projectPoint(position);
- const shape = contactShapes[index] ?? "";
- const params = contactShapeParams[index] ?? {};
-
- ctx.save();
- ctx.translate(x, y);
- drawContactShape(shape, params);
- ctx.fillStyle = "rgba(212, 175, 55, 1)";
+ drawContactShape(
+ ctx,
+ x,
+ y,
+ contactShapes[index] ?? "",
+ contactShapeParams[index] ?? {},
+ scale,
+ );
ctx.fill();
- ctx.lineWidth = Math.max(1, Math.min(1.8, 2.5 * (scale / 150)));
- ctx.strokeStyle = "rgba(110, 80, 25, 0.9)";
ctx.stroke();
- ctx.restore();
});
- if (showContactIds && probe.contact_ids && idLabelInfo) {
- const contactIds = probe.contact_ids;
- const { widestLabel, minWidthUm, minHeightUm } = idLabelInfo;
- // One font for the whole probe: the size at which the widest id fits the
- // smallest contact (by width and height). Text width scales linearly with
- // font size, so measure the widest label once at a reference size and
- // solve. Tracks zoom and real contact size; never overflows a pad.
- const REF_FONT = 100;
- ctx.font = `${REF_FONT}px "Inter", sans-serif`;
- const widestWidthAtRef = Math.max(1, ctx.measureText(widestLabel).width);
- const fontByWidth = (REF_FONT * minWidthUm * scale) / widestWidthAtRef;
- const fontByHeight = minHeightUm * scale;
- const fontPx = Math.min(fontByWidth, fontByHeight) * 0.85;
-
- ctx.font = `${fontPx}px "Inter", sans-serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillStyle = "rgba(15, 23, 42, 0.95)";
- contactPositions.forEach((position, index) => {
- const [x, y] = projectPoint(position);
- // Show the probe's actual contact id, not the array index.
- ctx.fillText(String(contactIds[index] ?? index), x, y);
+ if (showContactIds && probe.contact_ids && labelInfo) {
+ drawContactIds(ctx, {
+ positions,
+ contactIds: probe.contact_ids,
+ labelInfo,
+ scale,
+ projectPoint,
});
}
- // === L-Shaped Scale Bar ===
- // Renders a scale bar in the bottom-left corner showing reference lengths
- // for both X and Y dimensions. The length adapts to zoom level using "nice" numbers.
- const renderScaleBar = () => {
- // Calculate adaptive scale bar length using "nice" numbers
- const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
- const targetPixels = 80; // Target bar length in pixels
- const targetUm = targetPixels / scale;
- const scaleBarUm = niceNumbers.reduce((prev, curr) =>
- Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
- );
- const scaleBarPixels = scaleBarUm * scale;
-
- // Position: bottom-left corner
- const margin = 20;
- const cornerX = margin;
- const cornerY = heightPx - margin;
- const tickSize = 4;
-
- // Style for L shape
- ctx.strokeStyle = "rgba(15, 23, 42, 0.9)";
- ctx.lineWidth = 2;
- ctx.lineCap = "square";
-
- // Draw L shape
- ctx.beginPath();
- // Vertical arm (Y) - goes up from corner
- ctx.moveTo(cornerX, cornerY);
- ctx.lineTo(cornerX, cornerY - scaleBarPixels);
- // Horizontal arm (X) - goes right from corner
- ctx.moveTo(cornerX, cornerY);
- ctx.lineTo(cornerX + scaleBarPixels, cornerY);
- ctx.stroke();
-
- // End ticks
- ctx.beginPath();
- // Top of vertical arm
- ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels);
- ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels);
- // Right of horizontal arm
- ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize);
- ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize);
- ctx.stroke();
-
- // Labels
- const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
- ctx.font = '11px "Inter", sans-serif';
- ctx.fillStyle = "rgba(15, 23, 42, 0.9)";
-
- // X label (below horizontal arm)
- ctx.textAlign = "center";
- ctx.textBaseline = "top";
- ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5);
-
- // Y label (rotated, to the left of vertical arm)
- ctx.save();
- ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2);
- ctx.rotate(-Math.PI / 2);
- ctx.textAlign = "center";
- ctx.textBaseline = "bottom";
- ctx.fillText(label, 0, 0);
- ctx.restore();
- };
-
if (showScaleBar) {
- renderScaleBar();
+ renderScaleBar(ctx, scale, heightPx);
}
- }, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, idLabelInfo, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]);
-
- const clampZoom = useCallback(
- (value: number) => Math.min(maxZoom, Math.max(VIEW_ZOOM_MIN, value)),
- [maxZoom],
- );
-
- // Helper to calculate scale (needed for coordinate conversion in handlers)
- const getScale = useCallback(() => {
- if (!size.width || !size.height || !geometry) return 1;
- const padding = 40;
- const availableWidth = Math.max(10, size.width - padding * 2);
- const availableHeight = Math.max(10, size.height - padding * 2);
- const baseScale = Math.min(
- availableWidth / geometry.width,
- availableHeight / geometry.height,
- );
- return baseScale * zoom;
- }, [geometry, size.width, size.height, zoom]);
-
- // Wheel-to-zoom is attached as a NATIVE, non-passive listener (not React's
- // onWheel) so preventDefault() actually stops the page from scrolling. React
- // registers wheel handlers as passive by default, which ignores preventDefault()
- // and lets the page scroll while we zoom. The listener lives only on the canvas.
- // Live values are read through a ref so the listener does not re-subscribe on
- // every zoom/pan change; it only re-attaches when the canvas itself changes.
- const wheelStateRef = useRef({
- zoom,
- effectiveViewCenterX,
- effectiveViewCenterY,
+ }, [
+ canvasRef,
+ entry.id,
geometry,
- getScale,
- clampZoom,
- onViewCenterChange,
- onZoom,
- });
- wheelStateRef.current = {
+ getProjection,
+ labelInfo,
+ probe,
+ showContactIds,
+ showScaleBar,
+ size.height,
+ size.width,
zoom,
- effectiveViewCenterX,
- effectiveViewCenterY,
- geometry,
- getScale,
- clampZoom,
- onViewCenterChange,
- onZoom,
- };
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const handleWheel = (event: WheelEvent) => {
- event.preventDefault();
- const {
- zoom,
- effectiveViewCenterX,
- effectiveViewCenterY,
- geometry,
- getScale,
- clampZoom,
- onViewCenterChange,
- onZoom,
- } = wheelStateRef.current;
- if (!geometry) return;
-
- // Normalize wheel units so zoom speed is consistent across devices: mouse
- // wheels often report "line" deltas, trackpads report pixels.
- const unit =
- event.deltaMode === 1
- ? 16 // lines -> ~16px
- : event.deltaMode === 2
- ? canvas.clientHeight // pages -> viewport height
- : 1; // already pixels
- // Holding Shift moves the scroll onto the horizontal axis on most systems.
- const delta = (event.deltaY || event.deltaX) * unit;
-
- const rect = canvas.getBoundingClientRect();
- const offsetFromCenterX = event.clientX - rect.left - rect.width / 2;
- const offsetFromCenterY = event.clientY - rect.top - rect.height / 2;
-
- const scale = getScale();
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
-
- const zoomFactor = Math.exp(-delta * 0.002);
- const nextZoom = clampZoom(zoom * zoomFactor);
- const actualZoomFactor = nextZoom / zoom;
-
- // Keep the point under the cursor fixed. The (1 - factor) sign anchors the
- // zoom at the cursor; (factor - 1) would anchor at the cursor's mirror across
- // the center, which is what made zoom feel like it pulled toward the middle.
- const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor);
- const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor);
-
- // Convert back to probe coordinates.
- const newScale = scale * actualZoomFactor;
- const newViewCenterX = geometry.centerX - newPanX / newScale;
- const newViewCenterY = geometry.centerY + newPanY / newScale;
-
- onViewCenterChange(newViewCenterX, newViewCenterY);
- onZoom(nextZoom);
- };
-
- canvas.addEventListener("wheel", handleWheel, { passive: false });
- return () => canvas.removeEventListener("wheel", handleWheel);
- }, [geometry, probe]);
-
- const handlePointerDown = useCallback((event: ReactPointerEvent) => {
- event.preventDefault();
- setIsDragging(true);
- dragOriginRef.current = {
- x: event.clientX,
- y: event.clientY,
- viewCenterX: effectiveViewCenterX,
- viewCenterY: effectiveViewCenterY,
- };
- (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId);
- }, [effectiveViewCenterX, effectiveViewCenterY]);
-
- const handlePointerMove = useCallback((event: ReactPointerEvent) => {
- if (!isDragging || !dragOriginRef.current) {
- return;
- }
- event.preventDefault();
- const deltaX = event.clientX - dragOriginRef.current.x;
- const deltaY = event.clientY - dragOriginRef.current.y;
-
- // Convert pixel delta to probe coordinate delta, but only apply one update
- // per animation frame so a flood of pointermove events collapses into a
- // single redraw.
- const scale = getScale();
- pendingViewCenterRef.current = {
- x: dragOriginRef.current.viewCenterX - deltaX / scale,
- y: dragOriginRef.current.viewCenterY + deltaY / scale,
- };
- if (!panRafRef.current) {
- panRafRef.current = requestAnimationFrame(() => {
- panRafRef.current = 0;
- const pending = pendingViewCenterRef.current;
- if (pending) onViewCenterChange(pending.x, pending.y);
- });
- }
- }, [getScale, isDragging, onViewCenterChange]);
-
- const handlePointerUp = useCallback((event: ReactPointerEvent) => {
- if (isDragging) {
- event.preventDefault();
- // Flush any pending coalesced pan so the final position is exact.
- if (panRafRef.current) {
- cancelAnimationFrame(panRafRef.current);
- panRafRef.current = 0;
- }
- const pending = pendingViewCenterRef.current;
- if (pending) {
- onViewCenterChange(pending.x, pending.y);
- pendingViewCenterRef.current = null;
- }
- setIsDragging(false);
- dragOriginRef.current = null;
- (event.target as HTMLCanvasElement).releasePointerCapture(event.pointerId);
- }
- }, [isDragging, onViewCenterChange]);
-
- // Cancel any pending pan frame on unmount.
- useEffect(() => {
- return () => {
- if (panRafRef.current) cancelAnimationFrame(panRafRef.current);
- };
- }, []);
-
- const handleDoubleClick = useCallback(
- (event: ReactMouseEvent) => {
- event.preventDefault();
- if (!geometry) return;
-
- // Get click position relative to canvas
- const canvas = canvasRef.current;
- if (!canvas) return;
- const rect = canvas.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
-
- // Canvas center
- const canvasCenterX = rect.width / 2;
- const canvasCenterY = rect.height / 2;
-
- // Mouse offset from center
- const offsetFromCenterX = mouseX - canvasCenterX;
- const offsetFromCenterY = mouseY - canvasCenterY;
-
- // Calculate scale and pan in pixels
- const scale = getScale();
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
-
- // Calculate new zoom
- const zoomFactor = event.shiftKey ? 1 / 1.5 : 1.5;
- const nextZoom = clampZoom(zoom * zoomFactor);
- const actualZoomFactor = nextZoom / zoom;
-
- // Adjust pan so the clicked point stays fixed (see wheel handler note on
- // the (1 - factor) sign that anchors at the cursor rather than its mirror).
- const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor);
- const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor);
-
- // Convert back to probe coordinates
- const newScale = scale * actualZoomFactor;
- const newViewCenterX = geometry.centerX - newPanX / newScale;
- const newViewCenterY = geometry.centerY + newPanY / newScale;
-
- onViewCenterChange(newViewCenterX, newViewCenterY);
- onZoom(nextZoom);
- },
- [clampZoom, effectiveViewCenterX, effectiveViewCenterY, geometry, getScale, onViewCenterChange, onZoom, zoom],
- );
+ centerX,
+ centerY,
+ ]);
return (
@@ -617,4 +203,4 @@ export const ProbeCanvas = forwardRef(
)}
);
-});
+}
diff --git a/apps/probe-viewer/src/geometry/draw.ts b/apps/probe-viewer/src/geometry/draw.ts
index 239d012..797fbbc 100644
--- a/apps/probe-viewer/src/geometry/draw.ts
+++ b/apps/probe-viewer/src/geometry/draw.ts
@@ -1,4 +1,4 @@
-import type { ContactShapeParams } from "../types/probe";
+import type { ContactShapeParams, ProbeInterfaceProbe } from "../types/probe";
// Trace a single contact's outline onto the current path. Caller sets fill/stroke
// and paints. Shared by the single-sided and double-sided canvases so the two
@@ -18,15 +18,17 @@ export function drawContactShape(
ctx.arc(x, y, radius, 0, Math.PI * 2);
break;
}
- case "square": {
- const side = (params.width ?? 10) * scale;
- ctx.rect(x - side / 2, y - side / 2, side, side);
- break;
- }
+ case "square":
case "rect": {
const w = (params.width ?? 10) * scale;
- const h = (params.height ?? 15) * scale;
- ctx.rect(x - w / 2, y - h / 2, w, h);
+ const h = (shape === "square" ? params.width ?? 10 : params.height ?? 15) * scale;
+ // Lightly rounded corners so pads read as electrodes, not hard tiles.
+ const r = Math.min(w, h) * 0.12;
+ if (typeof ctx.roundRect === "function") {
+ ctx.roundRect(x - w / 2, y - h / 2, w, h, r);
+ } else {
+ ctx.rect(x - w / 2, y - h / 2, w, h);
+ }
break;
}
default: {
@@ -105,3 +107,81 @@ export const CONTACT_COLORS = {
front: { fill: "rgba(212, 175, 55, 1.0)", stroke: "rgba(80, 60, 15, 0.9)" },
back: { fill: "rgba(70, 130, 180, 1.0)", stroke: "rgba(25, 55, 90, 0.9)" },
} as const;
+
+// Per-probe inputs for uniform contact-id sizing: the widest id label (so one
+// font fits the longest) and the smallest contact box in micrometers (so it
+// fits every pad). Zoom-independent, so a caller computes it once per probe.
+export interface IdLabelInfo {
+ widestLabel: string;
+ minWidthUm: number;
+ minHeightUm: number;
+}
+
+export function computeIdLabelInfo(
+ probe: ProbeInterfaceProbe | undefined,
+): IdLabelInfo | null {
+ const ids = probe?.contact_ids;
+ const positions = probe?.contact_positions;
+ if (!ids || !positions || positions.length === 0) return null;
+ const shapes = probe.contact_shapes ?? [];
+ const params = probe.contact_shape_params ?? [];
+ let widestLabel = "";
+ let minWidthUm = Infinity;
+ let minHeightUm = Infinity;
+ for (let i = 0; i < positions.length; i++) {
+ const label = String(ids[i] ?? i);
+ if (label.length > widestLabel.length) widestLabel = label;
+ const shape = shapes[i] ?? "";
+ const p = params[i] ?? {};
+ const widthUm = shape === "circle" ? 2 * (p.radius ?? 5) : p.width ?? 10;
+ const heightUm =
+ shape === "circle"
+ ? 2 * (p.radius ?? 5)
+ : shape === "rect"
+ ? p.height ?? 15
+ : p.width ?? 10;
+ if (widthUm < minWidthUm) minWidthUm = widthUm;
+ if (heightUm < minHeightUm) minHeightUm = heightUm;
+ }
+ return { widestLabel, minWidthUm, minHeightUm };
+}
+
+// Draw contact ids at a single per-probe font size: the size at which the widest
+// id fits the smallest contact (by width and height). Text width scales linearly
+// with font size, so we measure the widest label once at a reference size and
+// solve. The font tracks zoom and real contact size, so labels never overflow a
+// pad and stay a constant fraction of it — shared by both canvases so this holds
+// for single-sided and double-sided probes alike. `shouldDraw` lets the caller
+// restrict labels to one face (the double-sided overlay draws a single side).
+export function drawContactIds(
+ ctx: CanvasRenderingContext2D,
+ options: {
+ positions: number[][];
+ contactIds: (string | number)[];
+ labelInfo: IdLabelInfo;
+ scale: number;
+ projectPoint: (point: number[]) => [number, number];
+ shouldDraw?: (index: number) => boolean;
+ },
+): void {
+ const { positions, contactIds, labelInfo, scale, projectPoint, shouldDraw } = options;
+ const { widestLabel, minWidthUm, minHeightUm } = labelInfo;
+
+ const REF_FONT = 100;
+ ctx.font = `${REF_FONT}px "Inter", sans-serif`;
+ const widestWidthAtRef = Math.max(1, ctx.measureText(widestLabel).width);
+ const fontByWidth = (REF_FONT * minWidthUm * scale) / widestWidthAtRef;
+ const fontByHeight = minHeightUm * scale;
+ const fontPx = Math.min(fontByWidth, fontByHeight) * 0.85;
+
+ ctx.font = `${fontPx}px "Inter", sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillStyle = "rgba(15, 23, 42, 0.95)";
+ positions.forEach((position, index) => {
+ if (shouldDraw && !shouldDraw(index)) return;
+ const [x, y] = projectPoint(position);
+ // Show the probe's actual contact id, not the array index.
+ ctx.fillText(String(contactIds[index] ?? index), x, y);
+ });
+}
diff --git a/apps/probe-viewer/src/geometry/viewport.ts b/apps/probe-viewer/src/geometry/viewport.ts
new file mode 100644
index 0000000..fec56b6
--- /dev/null
+++ b/apps/probe-viewer/src/geometry/viewport.ts
@@ -0,0 +1,90 @@
+import type { ProbeViewerCamera } from "../types/probe";
+
+// The probe-space bounding box the viewport frames.
+export interface ViewportGeometry {
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+}
+
+export interface ViewportSize {
+ width: number;
+ height: number;
+}
+
+// Projection from probe coordinates (micrometers, y-up) to canvas pixels
+// (y-down). `scale` is pixels per micrometer at the current zoom.
+export interface Projection {
+ scale: number;
+ offsetX: number;
+ offsetY: number;
+ projectPoint: (point: number[]) => [number, number];
+}
+
+// Margin (px) reserved around the probe when fitting it to the viewport.
+export const VIEWPORT_PADDING = 40;
+
+// Bounds over the contacts and contour (true positions frame the probe). Shared
+// by every viewport consumer so framing is computed one way everywhere.
+export function computeGeometry(
+ positions: number[][],
+ contour: number[][],
+): ViewportGeometry | null {
+ if (positions.length === 0) return null;
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ const update = (point: number[]) => {
+ if (point[0] < minX) minX = point[0];
+ if (point[0] > maxX) maxX = point[0];
+ if (point[1] < minY) minY = point[1];
+ if (point[1] > maxY) maxY = point[1];
+ };
+ positions.forEach(update);
+ contour.forEach(update);
+ if (!Number.isFinite(minX)) return null;
+ const width = Math.max(10, maxX - minX);
+ const height = Math.max(10, maxY - minY);
+ return { width, height, centerX: minX + width / 2, centerY: minY + height / 2 };
+}
+
+// Pixels-per-micrometer: the base scale that fits the probe into the padded
+// viewport, times the current zoom.
+export function computeScale(
+ geometry: ViewportGeometry | null,
+ size: ViewportSize,
+ zoom: number,
+): number {
+ if (!size.width || !size.height || !geometry) return 1;
+ const availableWidth = Math.max(10, size.width - VIEWPORT_PADDING * 2);
+ const availableHeight = Math.max(10, size.height - VIEWPORT_PADDING * 2);
+ const baseScale = Math.min(
+ availableWidth / geometry.width,
+ availableHeight / geometry.height,
+ );
+ return baseScale * zoom;
+}
+
+// Projection for the current camera. Returns null when there is nothing to
+// frame yet (no geometry or an unmeasured canvas).
+export function computeProjection(
+ geometry: ViewportGeometry | null,
+ camera: ProbeViewerCamera,
+ size: ViewportSize,
+): Projection | null {
+ if (!geometry || !size.width || !size.height) return null;
+ const effectiveViewCenterX = camera.centerX ?? geometry.centerX;
+ const effectiveViewCenterY = camera.centerY ?? geometry.centerY;
+ const scale = computeScale(geometry, size, camera.zoom);
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+ const offsetX = size.width / 2 + panX;
+ const offsetY = size.height / 2 + panY;
+ const projectPoint = (point: number[]): [number, number] => [
+ (point[0] - geometry.centerX) * scale + offsetX,
+ -(point[1] - geometry.centerY) * scale + offsetY,
+ ];
+ return { scale, offsetX, offsetY, projectPoint };
+}
diff --git a/apps/probe-viewer/src/hooks/useProbeViewport.ts b/apps/probe-viewer/src/hooks/useProbeViewport.ts
index 3c1934c..f09f75b 100644
--- a/apps/probe-viewer/src/hooks/useProbeViewport.ts
+++ b/apps/probe-viewer/src/hooks/useProbeViewport.ts
@@ -3,40 +3,27 @@ import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent }
import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore";
import type { ProbeViewerCamera } from "../types/probe";
+import {
+ computeProjection,
+ computeScale,
+ type Projection,
+ type ViewportGeometry,
+ type ViewportSize,
+} from "../geometry/viewport";
-// The probe-space bounding box the viewport frames. Both the single-sided and
-// double-sided canvases compute one of these and hand it to the hook.
-export interface ViewportGeometry {
- width: number;
- height: number;
- centerX: number;
- centerY: number;
-}
-
-export interface ViewportSize {
- width: number;
- height: number;
-}
-
-// Projection from probe coordinates (micrometers, y-up) to canvas pixels
-// (y-down). `scale` is pixels per micrometer at the current zoom.
-export interface Projection {
- scale: number;
- offsetX: number;
- offsetY: number;
- projectPoint: (point: number[]) => [number, number];
-}
+export type { Projection, ViewportGeometry, ViewportSize };
interface UseProbeViewportArgs {
geometry: ViewportGeometry | null;
camera: ProbeViewerCamera;
size: ViewportSize;
+ // Per-probe zoom ceiling (the geometry-derived cap so the smallest contact can
+ // fill the viewport). Defaults to the global VIEW_ZOOM_MAX when omitted.
+ maxZoom?: number;
onViewCenterChange: (x: number | null, y: number | null) => void;
onZoom: (zoom: number) => void;
}
-const PADDING = 40;
-
// Camera + interaction logic shared by every probe canvas. This is a verbatim
// extraction of the pan/zoom/projection math that used to live inside
// ProbeCanvas; keeping it in one place means a scroll-zoom or drag fix lands for
@@ -46,6 +33,7 @@ export function useProbeViewport({
geometry,
camera,
size,
+ maxZoom,
onViewCenterChange,
onZoom,
}: UseProbeViewportArgs) {
@@ -68,36 +56,21 @@ export function useProbeViewport({
const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0;
const clampZoom = useCallback(
- (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)),
- [],
+ (value: number) => Math.min(maxZoom ?? VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)),
+ [maxZoom],
);
- const getScale = useCallback(() => {
- if (!size.width || !size.height || !geometry) return 1;
- const availableWidth = Math.max(10, size.width - PADDING * 2);
- const availableHeight = Math.max(10, size.height - PADDING * 2);
- const baseScale = Math.min(
- availableWidth / geometry.width,
- availableHeight / geometry.height,
- );
- return baseScale * zoom;
- }, [geometry, size.width, size.height, zoom]);
+ const getScale = useCallback(
+ () => computeScale(geometry, size, zoom),
+ [geometry, size, zoom],
+ );
// Current projection from probe coordinates to canvas pixels. Recomputed on
// demand so the draw effect always sees the live camera.
- const getProjection = useCallback((): Projection | null => {
- if (!geometry || !size.width || !size.height) return null;
- const scale = getScale();
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
- const offsetX = size.width / 2 + panX;
- const offsetY = size.height / 2 + panY;
- const projectPoint = (point: number[]): [number, number] => [
- (point[0] - geometry.centerX) * scale + offsetX,
- -(point[1] - geometry.centerY) * scale + offsetY,
- ];
- return { scale, offsetX, offsetY, projectPoint };
- }, [geometry, size.width, size.height, getScale, effectiveViewCenterX, effectiveViewCenterY]);
+ const getProjection = useCallback(
+ (): Projection | null => computeProjection(geometry, camera, size),
+ [geometry, camera, size],
+ );
// Wheel-to-zoom is attached as a NATIVE, non-passive listener (not React's
// onWheel) so preventDefault() actually stops the page from scrolling. React
diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts
index 74ea67e..67feac2 100644
--- a/apps/probe-viewer/src/utils/exportUtils.ts
+++ b/apps/probe-viewer/src/utils/exportUtils.ts
@@ -1,56 +1,12 @@
import type { ProbeInterfaceFile, ContactShapeParams, ProbeViewerCamera } from "../types/probe";
+import { computeGeometry, computeProjection } from "../geometry/viewport";
+import { CONTACT_COLORS, drawContactShape, renderScaleBar } from "../geometry/draw";
interface CanvasSize {
width: number;
height: number;
}
-interface GeometrySummary {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- width: number;
- height: number;
- centerX: number;
- centerY: number;
-}
-
-function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary | null {
- const probe = probeData.probes?.[0];
- if (!probe) {
- return null;
- }
-
- const positions = probe.contact_positions ?? [];
- if (positions.length === 0) {
- return null;
- }
-
- let minX = Number.POSITIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
-
- const updateBounds = (point: number[]) => {
- const [x, y] = point;
- if (x < minX) minX = x;
- if (x > maxX) maxX = x;
- if (y < minY) minY = y;
- if (y > maxY) maxY = y;
- };
-
- positions.forEach(updateBounds);
- (probe.probe_planar_contour ?? []).forEach(updateBounds);
-
- const width = Math.max(10, maxX - minX);
- const height = Math.max(10, maxY - minY);
- const centerX = minX + width / 2;
- const centerY = minY + height / 2;
-
- return { minX, maxX, minY, maxY, width, height, centerX, centerY };
-}
-
/**
* Export probe visualization as PNG with white background.
* Re-renders the probe without contact IDs. Scale bar included if enabled.
@@ -108,7 +64,10 @@ export function exportProbeAsSvg(
/**
* Render probe to a 2D canvas context (used for PNG export).
- * Mirrors the ProbeCanvas rendering logic but without contact IDs.
+ * Uses the same shared geometry/projection and draw primitives as the on-screen
+ * canvas, plus export-only touches: a white background (set by the caller), a
+ * drop-shadow depth pass, and an opaque contour so the shank reads in a
+ * standalone image. Contact IDs are intentionally omitted.
*/
function renderProbeToContext(
ctx: CanvasRenderingContext2D,
@@ -117,44 +76,23 @@ function renderProbeToContext(
canvasSize: CanvasSize,
showScaleBar: boolean
): void {
- const geometry = computeGeometrySummary(probeData);
const probe = probeData.probes?.[0];
- if (!geometry || !probe) return;
-
- const { zoom, centerX, centerY } = camera;
- const { width: widthPx, height: heightPx } = canvasSize;
-
- // Calculate effective view center (use geometry center if null)
- const effectiveViewCenterX = centerX ?? geometry.centerX;
- const effectiveViewCenterY = centerY ?? geometry.centerY;
-
- const padding = 40;
- const availableWidth = Math.max(10, widthPx - padding * 2);
- const availableHeight = Math.max(10, heightPx - padding * 2);
- const baseScale = Math.min(
- availableWidth / geometry.width,
- availableHeight / geometry.height
+ if (!probe) return;
+ const geometry = computeGeometry(
+ probe.contact_positions ?? [],
+ probe.probe_planar_contour ?? []
);
- const scale = baseScale * zoom;
-
- // Calculate pixel pan from view center
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+ const projection = computeProjection(geometry, camera, canvasSize);
+ if (!geometry || !projection) return;
- const offsetX = widthPx / 2 + panX;
- const offsetY = heightPx / 2 + panY;
-
- const projectPoint = (point: number[]) => {
- const [x, y] = point;
- const normX = (x - geometry.centerX) * scale + offsetX;
- const normY = -(y - geometry.centerY) * scale + offsetY;
- return [normX, normY];
- };
+ const { scale, projectPoint } = projection;
+ const { height: heightPx } = canvasSize;
ctx.lineCap = "round";
ctx.lineJoin = "round";
- // Draw probe contour
+ // Probe contour: opaque gray so the shank reads as a region in a standalone
+ // image (the on-screen view uses a fainter wash over the app background).
if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
ctx.beginPath();
probe.probe_planar_contour.forEach((point, index) => {
@@ -177,122 +115,43 @@ function renderProbeToContext(
const contactShapes = probe.contact_shapes ?? [];
const contactShapeParams = probe.contact_shape_params ?? [];
- const drawContactShape = (
- x: number,
- y: number,
- shape: string,
- params: ContactShapeParams
- ) => {
- ctx.beginPath();
- switch (shape) {
- case "circle": {
- const radius = (params.radius ?? 5) * scale;
- ctx.arc(x, y, radius, 0, Math.PI * 2);
- break;
- }
- case "square": {
- const side = (params.width ?? 10) * scale;
- ctx.rect(x - side / 2, y - side / 2, side, side);
- break;
- }
- case "rect": {
- const w = (params.width ?? 10) * scale;
- const h = (params.height ?? 15) * scale;
- ctx.rect(x - w / 2, y - h / 2, w, h);
- break;
- }
- default: {
- const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100)));
- ctx.arc(x, y, markerSize * 0.4, 0, Math.PI * 2);
- ctx.closePath();
- ctx.moveTo(x - markerSize, y - markerSize);
- ctx.lineTo(x + markerSize, y + markerSize);
- ctx.moveTo(x + markerSize, y - markerSize);
- ctx.lineTo(x - markerSize, y + markerSize);
- }
- }
- };
-
- // Shadow offset for depth effect - subtle, proportional to scale
- const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth
-
- // First pass: draw shadows
+ // First pass: drop shadows for a subtle depth effect (export only).
+ const shadowOffset = 0.4 * scale; // 0.4 micrometer offset
+ ctx.fillStyle = "rgba(30, 20, 5, 0.7)";
contactPositions.forEach((position, index) => {
const [x, y] = projectPoint(position);
- const shape = contactShapes[index] ?? "";
- const params = contactShapeParams[index] ?? {};
-
- drawContactShape(x + shadowOffset, y + shadowOffset, shape, params);
- ctx.fillStyle = "rgba(30, 20, 5, 0.7)";
+ drawContactShape(
+ ctx,
+ x + shadowOffset,
+ y + shadowOffset,
+ contactShapes[index] ?? "",
+ contactShapeParams[index] ?? {},
+ scale
+ );
ctx.fill();
});
- // Second pass: draw gold contacts
+ // Second pass: flat gold contacts (fully opaque to cover the shadow), the same
+ // front-face style as the on-screen canvas.
+ ctx.fillStyle = CONTACT_COLORS.front.fill;
+ ctx.strokeStyle = CONTACT_COLORS.front.stroke;
+ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150));
contactPositions.forEach((position, index) => {
const [x, y] = projectPoint(position);
- const shape = contactShapes[index] ?? "";
- const params = contactShapeParams[index] ?? {};
-
- drawContactShape(x, y, shape, params);
-
- ctx.fillStyle = "rgba(212, 175, 55, 1.0)"; // Fully opaque to cover shadow
- ctx.strokeStyle = "rgba(80, 60, 15, 0.9)";
- ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150));
+ drawContactShape(
+ ctx,
+ x,
+ y,
+ contactShapes[index] ?? "",
+ contactShapeParams[index] ?? {},
+ scale
+ );
ctx.fill();
ctx.stroke();
});
- // Scale bar (L-shaped, bottom-left corner)
if (showScaleBar) {
- const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
- const targetPixels = 80;
- const targetUm = targetPixels / scale;
- const scaleBarUm = niceNumbers.reduce((prev, curr) =>
- Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
- );
- const scaleBarPixels = scaleBarUm * scale;
-
- const margin = 20;
- const cornerX = margin;
- const cornerY = heightPx - margin;
- const tickSize = 4;
-
- ctx.strokeStyle = "rgba(15, 23, 42, 0.9)";
- ctx.lineWidth = 2;
- ctx.lineCap = "square";
-
- // Draw L shape
- ctx.beginPath();
- ctx.moveTo(cornerX, cornerY);
- ctx.lineTo(cornerX, cornerY - scaleBarPixels);
- ctx.moveTo(cornerX, cornerY);
- ctx.lineTo(cornerX + scaleBarPixels, cornerY);
- ctx.stroke();
-
- // End ticks
- ctx.beginPath();
- ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels);
- ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels);
- ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize);
- ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize);
- ctx.stroke();
-
- // Labels
- const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
- ctx.font = '11px "Inter", sans-serif';
- ctx.fillStyle = "rgba(15, 23, 42, 0.9)";
-
- ctx.textAlign = "center";
- ctx.textBaseline = "top";
- ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5);
-
- ctx.save();
- ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2);
- ctx.rotate(-Math.PI / 2);
- ctx.textAlign = "center";
- ctx.textBaseline = "bottom";
- ctx.fillText(label, 0, 0);
- ctx.restore();
+ renderScaleBar(ctx, scale, heightPx);
}
}
@@ -301,6 +160,10 @@ function renderProbeToContext(
* Transparent background, no contact IDs. Scale bar included if enabled.
* Contacts outside the current frame are omitted, so the export matches what is
* on screen and stays small even when zoomed into a long probe.
+ *
+ * Shares the geometry and projection math with the canvas, but the shapes and
+ * scale bar are emitted as SVG markup (not canvas calls), so that part cannot
+ * reuse the canvas draw primitives.
*/
function generateProbeSvgString(
probeData: ProbeInterfaceFile,
@@ -308,42 +171,19 @@ function generateProbeSvgString(
canvasSize: CanvasSize,
showScaleBar: boolean
): string {
- const geometry = computeGeometrySummary(probeData);
const probe = probeData.probes?.[0];
-
- if (!geometry || !probe) {
- return ``;
- }
-
- const { zoom, centerX, centerY } = camera;
- const { width: widthPx, height: heightPx } = canvasSize;
-
- // Calculate effective view center (use geometry center if null)
- const effectiveViewCenterX = centerX ?? geometry.centerX;
- const effectiveViewCenterY = centerY ?? geometry.centerY;
-
- const padding = 40;
- const availableWidth = Math.max(10, widthPx - padding * 2);
- const availableHeight = Math.max(10, heightPx - padding * 2);
- const baseScale = Math.min(
- availableWidth / geometry.width,
- availableHeight / geometry.height
+ const geometry = computeGeometry(
+ probe?.contact_positions ?? [],
+ probe?.probe_planar_contour ?? []
);
- const scale = baseScale * zoom;
-
- // Calculate pixel pan from view center
- const panX = (geometry.centerX - effectiveViewCenterX) * scale;
- const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+ const projection = computeProjection(geometry, camera, canvasSize);
- const offsetX = widthPx / 2 + panX;
- const offsetY = heightPx / 2 + panY;
+ const { width: widthPx, height: heightPx } = canvasSize;
+ if (!geometry || !projection || !probe) {
+ return ``;
+ }
- const projectPoint = (point: number[]): [number, number] => {
- const [x, y] = point;
- const normX = (x - geometry.centerX) * scale + offsetX;
- const normY = -(y - geometry.centerY) * scale + offsetY;
- return [normX, normY];
- };
+ const { scale, projectPoint } = projection;
const elements: string[] = [];