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[] = [];