From f85fba91a49c72dfe4f8643ad4dc5b22156cc0b4 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 Jul 2026 10:07:49 -0600 Subject: [PATCH 1/2] Share contact-id label rendering between single- and double-sided canvases Move the fit-to-contact contact-id sizing (computeIdLabelInfo + drawContactIds) and the contact-shape corner rounding into geometry/draw.ts, and thread the per-probe zoom cap through useProbeViewport. ProbeCanvas now sits on top of the shared hook and drawing layer instead of reimplementing them, and DoubleSidedProbeCanvas calls the same drawContactIds (restricted to the visible face) so double-sided probes get the same label sizing as single-sided ones. Drops ProbeCanvas's dead forwardRef; net ~290 fewer lines. --- .../src/components/DoubleSidedProbeCanvas.tsx | 34 +- .../src/components/ProbeCanvas.tsx | 600 ++++-------------- apps/probe-viewer/src/geometry/draw.ts | 96 ++- .../src/hooks/useProbeViewport.ts | 8 +- 4 files changed, 224 insertions(+), 514 deletions(-) diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx index 11b7977..d4e982d 100644 --- a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx @@ -2,7 +2,13 @@ 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 { + CONTACT_COLORS, + computeIdLabelInfo, + drawContactIds, + drawContactShape, + renderScaleBar, +} from "../geometry/draw"; import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; interface DoubleSidedProbeCanvasProps { @@ -67,6 +73,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 +154,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..d6258f0 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -1,21 +1,15 @@ -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 { + CONTACT_COLORS, + computeIdLabelInfo, + drawContactIds, + drawContactShape, + renderScaleBar, +} from "../geometry/draw"; import type { - ContactShapeParams, ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera, @@ -33,129 +27,93 @@ interface ProbeCanvasProps { } 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; - } - +// Bounds over the contacts and contour (true positions frame the probe). +function computeGeometry( + positions: number[][], + contour: number[][], +): GeometrySummary | 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 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; + 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(updateBounds); - (probe.probe_planar_contour ?? []).forEach(updateBounds); - + 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); - const centerX = minX + width / 2; - const centerY = minY + height / 2; - - return { minX, maxX, minY, maxY, width, height, centerX, centerY }; + return { width, height, centerX: minX + width / 2, centerY: minY + height / 2 }; } -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 +133,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 +155,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 +233,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/hooks/useProbeViewport.ts b/apps/probe-viewer/src/hooks/useProbeViewport.ts index 3c1934c..db8d520 100644 --- a/apps/probe-viewer/src/hooks/useProbeViewport.ts +++ b/apps/probe-viewer/src/hooks/useProbeViewport.ts @@ -31,6 +31,9 @@ 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; } @@ -46,6 +49,7 @@ export function useProbeViewport({ geometry, camera, size, + maxZoom, onViewCenterChange, onZoom, }: UseProbeViewportArgs) { @@ -68,8 +72,8 @@ 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(() => { From f4b5a7454505e80381e870d2560bbeacfd0800cc Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 Jul 2026 10:20:15 -0600 Subject: [PATCH 2/2] Unify viewport math and PNG export onto the shared drawing layer Extract computeGeometry/computeScale/computeProjection into geometry/viewport.ts and use it from useProbeViewport, both canvases, and exportUtils, removing the duplicate copies. The PNG export now draws contacts and the scale bar through geometry/draw.ts (keeping its export-only white background, drop-shadow, and opaque contour); the SVG export shares geometry/projection but keeps its own markup. PNG-exported contacts gain the rounded corners the screen and SVG already used. --- .../src/components/DoubleSidedProbeCanvas.tsx | 29 +- .../src/components/ProbeCanvas.tsx | 32 +-- apps/probe-viewer/src/geometry/viewport.ts | 90 ++++++ .../src/hooks/useProbeViewport.ts | 63 ++-- apps/probe-viewer/src/utils/exportUtils.ts | 268 ++++-------------- 5 files changed, 162 insertions(+), 320 deletions(-) create mode 100644 apps/probe-viewer/src/geometry/viewport.ts diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx index d4e982d..0464daa 100644 --- a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useProbeViewport } from "../hooks/useProbeViewport"; +import { computeGeometry } from "../geometry/viewport"; import { CONTACT_COLORS, computeIdLabelInfo, @@ -22,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; } diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index d6258f0..159c213 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useProbeViewport } from "../hooks/useProbeViewport"; +import { computeGeometry } from "../geometry/viewport"; import { CONTACT_COLORS, computeIdLabelInfo, @@ -26,37 +27,6 @@ interface ProbeCanvasProps { onZoom: (zoom: number) => void; } -interface GeometrySummary { - width: number; - height: number; - centerX: number; - centerY: number; -} - -// Bounds over the contacts and contour (true positions frame the probe). -function computeGeometry( - positions: number[][], - contour: number[][], -): GeometrySummary | 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 }; -} - export function ProbeCanvas({ entry, probeData, 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 db8d520..f09f75b 100644 --- a/apps/probe-viewer/src/hooks/useProbeViewport.ts +++ b/apps/probe-viewer/src/hooks/useProbeViewport.ts @@ -3,29 +3,15 @@ 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; @@ -38,8 +24,6 @@ interface UseProbeViewportArgs { 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 @@ -76,32 +60,17 @@ export function useProbeViewport({ [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[] = [];