diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index ff5e061..d8eb3fc 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -330,6 +330,65 @@ color: #1e293b; } +/* Double-sided probe layout controls */ +.viewer-controls-sides { + align-items: center; + gap: 0.6rem; +} + +.viewer-controls-label { + font-size: 0.9rem; + font-weight: 600; + color: #1e293b; +} + +/* Segmented button group: adjacent buttons read as one control, the active + segment highlighted. */ +.viewer-segmented { + display: inline-flex; + gap: 0; +} + +.viewer-controls .viewer-segmented button { + border-radius: 0; + text-transform: capitalize; +} + +.viewer-controls .viewer-segmented button:first-child { + border-top-left-radius: 0.75rem; + border-bottom-left-radius: 0.75rem; +} + +.viewer-controls .viewer-segmented button:last-child { + border-top-right-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; +} + +.viewer-controls .viewer-segmented button:not(:first-child) { + border-left: none; +} + +.viewer-controls .viewer-segmented button.is-active { + background: #2563eb; + color: #fff; +} + +/* Color key matching the canvas: gold front, steel-blue back. */ +.viewer-side-swatch { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + display: inline-block; +} + +.viewer-side-swatch--front { + background: rgb(212, 175, 55); +} + +.viewer-side-swatch--back { + background: rgb(70, 130, 180); +} + .viewer-canvas { position: relative; min-height: 360px; diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx new file mode 100644 index 0000000..11b7977 --- /dev/null +++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx @@ -0,0 +1,188 @@ +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 type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; + +interface DoubleSidedProbeCanvasProps { + entry: ManifestEntry; + probeData: ProbeInterfaceFile; + camera: ProbeViewerCamera; + showScaleBar: boolean; + // "both" overlays the faces (registration view); a side name isolates one. + overlaySide: string; + onViewCenterChange: (x: number | null, y: number | null) => void; + 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; +} + +export function DoubleSidedProbeCanvas({ + entry, + probeData, + camera, + showScaleBar, + overlaySide, + onViewCenterChange, + onZoom, +}: DoubleSidedProbeCanvasProps) { + const { zoom, centerX, centerY } = camera; + const { ref: containerRef, size } = useResizeObserver(); + const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 }); + + const probe = probeData.probes?.[0]; + const geometry = useMemo(() => { + if (!probe) return null; + return computeGeometry(probe.contact_positions ?? [], probe.probe_planar_contour ?? []); + }, [probe]); + + const { + canvasRef, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + } = useProbeViewport({ geometry, camera, size, 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; + + const projection = getProjection(); + if (!projection) return; + const { scale, projectPoint } = projection; + + const devicePixelRatio = window.devicePixelRatio || 1; + const widthPx = size.width; + const heightPx = size.height; + const targetW = Math.round(widthPx * devicePixelRatio); + const targetH = Math.round(heightPx * devicePixelRatio); + const lastSize = lastCanvasSizeRef.current; + if (lastSize.w !== targetW || lastSize.h !== targetH || lastSize.dpr !== devicePixelRatio) { + canvas.width = targetW; + canvas.height = targetH; + canvas.style.width = `${widthPx}px`; + canvas.style.height = `${heightPx}px`; + lastCanvasSizeRef.current = { w: targetW, h: targetH, dpr: devicePixelRatio }; + } + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + ctx.clearRect(0, 0, widthPx, heightPx); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // Shared shank outline (both faces occupy the same shank). + const contour = probe.probe_planar_contour ?? []; + if (contour.length > 1) { + ctx.beginPath(); + contour.forEach((point, index) => { + const [x, y] = projectPoint(point); + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.fillStyle = "rgba(180, 185, 195, 0.7)"; + ctx.strokeStyle = "rgba(100, 105, 115, 0.95)"; + ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 100)); + ctx.fill(); + ctx.stroke(); + } + + const positions = probe.contact_positions ?? []; + const sides = probe.contact_sides ?? []; + const contactShapes = probe.contact_shapes ?? []; + const contactShapeParams = probe.contact_shape_params ?? []; + + // The view shows one face at a time as its own channel map: that face's + // contacts in the face color, drawn solid. Front and back share positions, + // so only one set is ever on screen, which is why the IDs below never collide. + const colors = colorForSide(overlaySide); + ctx.fillStyle = colors.fill; + ctx.strokeStyle = colors.stroke; + ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150)); + positions.forEach((position, index) => { + if ((sides[index] ?? "front") !== overlaySide) return; + const [x, y] = projectPoint(position); + drawContactShape(ctx, x, y, contactShapes[index] ?? "", contactShapeParams[index] ?? {}, scale); + ctx.fill(); + ctx.stroke(); + }); + + // 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); + }); + } + + if (showScaleBar) { + renderScaleBar(ctx, scale, heightPx); + } + }, [canvasRef, entry.id, geometry, getProjection, overlaySide, probe, showScaleBar, size.height, size.width, zoom, centerX, centerY]); + + return ( +
+ {geometry && probe ? ( + + ) : ( +
+

No planar geometry available for this probe.

+
+ )} +
+ ); +} diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index 8059139..92d1730 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -1,21 +1,9 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import type { - MouseEvent as ReactMouseEvent, - PointerEvent as ReactPointerEvent, -} from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; -import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; +import { useProbeViewport } from "../hooks/useProbeViewport"; +import { drawContactShape, renderScaleBar } from "../geometry/draw"; import type { - ContactShapeParams, ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera, @@ -91,28 +79,26 @@ export const ProbeCanvas = forwardRef( ref ) { 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]); - // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; - const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; + const { + canvasRef, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + } = useProbeViewport({ geometry, camera, size, onViewCenterChange, onZoom }); + + // Expose canvas to parent for export + useImperativeHandle(ref, () => canvasRef.current!, [canvasRef]); useEffect(() => { if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) { @@ -125,6 +111,10 @@ export const ProbeCanvas = forwardRef( return; } + const projection = getProjection(); + if (!projection) return; + const { scale, projectPoint } = projection; + const devicePixelRatio = window.devicePixelRatio || 1; const widthPx = size.width; const heightPx = size.height; @@ -146,29 +136,6 @@ export const ProbeCanvas = forwardRef( 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"; @@ -194,46 +161,6 @@ export const ProbeCanvas = forwardRef( const contactShapes = probe.contact_shapes ?? []; const contactShapeParams = probe.contact_shape_params ?? []; - // Helper to draw a contact shape - 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: { - // Unknown/missing shape: draw a dot with X to indicate missing data - const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100))); - // Draw small circle - ctx.arc(x, y, markerSize * 0.4, 0, Math.PI * 2); - ctx.closePath(); - // Draw X through the center - 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 @@ -243,7 +170,7 @@ export const ProbeCanvas = forwardRef( const shape = contactShapes[index] ?? ""; const params = contactShapeParams[index] ?? {}; - drawContactShape(x + shadowOffset, y + shadowOffset, shape, params); + drawContactShape(ctx, x + shadowOffset, y + shadowOffset, shape, params, scale); ctx.fillStyle = "rgba(30, 20, 5, 0.7)"; // Even darker and more opaque ctx.fill(); }); @@ -254,7 +181,7 @@ export const ProbeCanvas = forwardRef( const shape = contactShapes[index] ?? ""; const params = contactShapeParams[index] ?? {}; - drawContactShape(x, y, shape, params); + drawContactShape(ctx, x, y, shape, params, scale); ctx.fillStyle = "rgba(212, 175, 55, 1.0)"; // Gold contacts - fully opaque to cover shadow ctx.strokeStyle = "rgba(80, 60, 15, 0.9)"; // Dark bronze outline @@ -276,288 +203,10 @@ export const ProbeCanvas = forwardRef( }); } - // === 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(); - } - }, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]); - - const clampZoom = useCallback( - (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)), - [], - ); - - // 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, - geometry, - getScale, - clampZoom, - onViewCenterChange, - onZoom, - }); - wheelStateRef.current = { - 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; + renderScaleBar(ctx, scale, heightPx); } - 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], - ); + }, [canvasRef, entry.id, geometry, getProjection, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom, centerX, centerY]); return (
diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index ae11786..db98c30 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useAppStore, VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; import { exportProbeAsPng, exportProbeAsSvg } from "../utils/exportUtils"; +import { getSideInfo } from "../geometry/sides"; import { ProbeCanvas } from "./ProbeCanvas"; +import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; import { ProbeOverview } from "./ProbeOverview"; const ZoomInIcon = ( @@ -62,6 +64,7 @@ export function ProbeViewer() { const toggleContactIds = useAppStore((state) => state.toggleContactIds); const toggleScaleBar = useAppStore((state) => state.toggleScaleBar); const toggleOverview = useAppStore((state) => state.toggleOverview); + const setOverlaySide = useAppStore((state) => state.setOverlaySide); useEffect(() => { if (selectedProbeId) { @@ -86,6 +89,27 @@ export function ProbeViewer() { // Only offer the "Show contact IDs" toggle when the probe actually carries them. const hasContactIds = !!probeData?.probes?.[0]?.contact_ids?.length; + // Double-sided probes (front + back contacts at the same positions) get a + // dedicated canvas and a layout control; single-sided probes are unaffected. + const sideInfo = useMemo( + () => getSideInfo(probeData?.probes?.[0]), + [probeData], + ); + const isDoubleSided = sideInfo.isDoubleSided; + // Fall back to the probe's first side if the stored selection is not one of + // this probe's sides (e.g. a stale value from a previous probe). + const activeSide = sideInfo.sides.includes(view.overlaySide) + ? view.overlaySide + : sideInfo.sides[0]; + // Per-side contact counts, for the "double-sided" badge. + const sideCounts = useMemo(() => { + const counts: Record = {}; + for (const side of probeData?.probes?.[0]?.contact_sides ?? []) { + counts[side] = (counts[side] ?? 0) + 1; + } + return counts; + }, [probeData]); + // Track canvas container size for minimap const { ref: canvasContainerRef, size: canvasSize } = useResizeObserver(); @@ -341,6 +365,28 @@ export function ProbeViewer() { Overview
+ {isDoubleSided && ( +
+ + Double-sided ·{" "} + {sideInfo.sides.map((side) => `${sideCounts[side] ?? 0} ${side}`).join(" / ")} + +
+ {sideInfo.sides.map((side) => ( + + ))} +
+
+ )}
@@ -351,15 +397,27 @@ export function ProbeViewer() { )} {status !== "error" && probeData && ( <> - setViewCenter(x, y)} - onZoom={(value) => setZoom(value)} - /> + {isDoubleSided ? ( + setViewCenter(x, y)} + onZoom={(value) => setZoom(value)} + /> + ) : ( + setViewCenter(x, y)} + onZoom={(value) => setZoom(value)} + /> + )} {view.showOverview && ( + 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"; + + 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(); + + 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(); + + 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(); +} + +// Gold for the (single-sided or "front") face, steel-blue for the back face. +// Single-sided probes keep the original gold look exactly. +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; diff --git a/apps/probe-viewer/src/geometry/sides.ts b/apps/probe-viewer/src/geometry/sides.ts new file mode 100644 index 0000000..2d2b163 --- /dev/null +++ b/apps/probe-viewer/src/geometry/sides.ts @@ -0,0 +1,25 @@ +import type { ProbeInterfaceProbe } from "../types/probe"; + +// Double-sided probes carry front and back contacts at the same (x, y) positions +// (distinguished by `contact_sides`). They are drawn as a registration overlay: +// both faces in one true-scale frame, color-coded and semi-transparent, like a +// two-layer PCB view. A Both / front / back selector isolates a single face. + +export interface SideInfo { + // True when the probe carries contacts on more than one face. + isDoubleSided: boolean; + // Distinct side labels in first-seen order, e.g. ["front", "back"]. + sides: string[]; +} + +export function getSideInfo(probe: ProbeInterfaceProbe | undefined): SideInfo { + const sides = probe?.contact_sides; + if (!sides || sides.length === 0) { + return { isDoubleSided: false, sides: [] }; + } + const distinct: string[] = []; + for (const side of sides) { + if (!distinct.includes(side)) distinct.push(side); + } + return { isDoubleSided: distinct.length > 1, sides: distinct }; +} diff --git a/apps/probe-viewer/src/hooks/useProbeViewport.ts b/apps/probe-viewer/src/hooks/useProbeViewport.ts new file mode 100644 index 0000000..3c1934c --- /dev/null +++ b/apps/probe-viewer/src/hooks/useProbeViewport.ts @@ -0,0 +1,317 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react"; + +import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; +import type { ProbeViewerCamera } from "../types/probe"; + +// 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]; +} + +interface UseProbeViewportArgs { + geometry: ViewportGeometry | null; + camera: ProbeViewerCamera; + size: ViewportSize; + 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 +// both the single-sided and double-sided views at once. The hook owns no +// drawing: each canvas component runs its own draw effect using getProjection(). +export function useProbeViewport({ + geometry, + camera, + size, + onViewCenterChange, + onZoom, +}: UseProbeViewportArgs) { + const { zoom, centerX, centerY } = camera; + const canvasRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const dragOriginRef = useRef<{ + x: number; + y: number; + viewCenterX: number; + viewCenterY: number; + } | null>(null); + // 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); + + // Use geometry center when the camera has no explicit center yet. + const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; + const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; + + const clampZoom = useCallback( + (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)), + [], + ); + + 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]); + + // 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]); + + // 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. 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, + geometry, + getScale, + clampZoom, + onViewCenterChange, + onZoom, + }); + wheelStateRef.current = { + 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); + // Re-run once geometry becomes available so the listener attaches after the + // canvas is actually rendered (it is conditional on geometry being non-null). + // Live values are still read through wheelStateRef, so this never needs to + // re-attach on plain zoom/pan changes. + }, [geometry]); + + 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; + + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const canvasCenterX = rect.width / 2; + const canvasCenterY = rect.height / 2; + const offsetFromCenterX = mouseX - canvasCenterX; + const offsetFromCenterY = mouseY - canvasCenterY; + + const scale = getScale(); + const panX = (geometry.centerX - effectiveViewCenterX) * scale; + const panY = (effectiveViewCenterY - geometry.centerY) * scale; + + // Shift-double-click zooms out; plain double-click zooms in. + 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); + + 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], + ); + + return { + canvasRef, + isDragging, + effectiveViewCenterX, + effectiveViewCenterY, + getScale, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + }; +} diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index f4b93a9..2013ea0 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -16,6 +16,9 @@ interface ViewState { showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; + // Double-sided probes: which face to show as a channel map. A side name + // ("front"/"back"); resolved against the probe's actual sides at render time. + overlaySide: string; } interface AppState { @@ -45,6 +48,7 @@ interface AppState { toggleContactIds: (value?: boolean) => void; toggleScaleBar: (value?: boolean) => void; toggleOverview: (value?: boolean) => void; + setOverlaySide: (side: string) => void; } export const VIEW_ZOOM_MIN = 0.1; @@ -61,6 +65,8 @@ const INITIAL_VIEW_STATE: ViewState = { showContactIds: false, showScaleBar: true, showOverview: true, + // Default to the front face; resolved to the probe's first side if absent. + overlaySide: "front", }; function clamp(value: number, min: number, max: number) { @@ -201,6 +207,7 @@ export const useAppStore = create((set, get) => ({ view: { ...INITIAL_VIEW_STATE, showContactIds: state.view.showContactIds, + overlaySide: state.view.overlaySide, }, })), @@ -230,6 +237,9 @@ export const useAppStore = create((set, get) => ({ value !== undefined ? value : !state.view.showOverview, }, })), + + setOverlaySide: (side) => + set((state) => ({ view: { ...state.view, overlaySide: side } })), })); export type { AppState, LoadStatus, ManifestEntry, ProbeInterfaceFile }; diff --git a/apps/probe-viewer/src/types/probe.ts b/apps/probe-viewer/src/types/probe.ts index 68f6158..9172e40 100644 --- a/apps/probe-viewer/src/types/probe.ts +++ b/apps/probe-viewer/src/types/probe.ts @@ -45,7 +45,11 @@ export interface ProbeInterfaceProbe { contact_shapes?: string[]; // "circle" | "square" | "rect" contact_shape_params?: ContactShapeParams[]; contact_ids?: (string | number)[]; - shank_ids?: number[]; + shank_ids?: (string | number)[]; + // Per-contact face of the shank, e.g. "front" / "back". Present on + // double-sided probes (Cambridge NeuroTech ASSY-325D-*), where front and back + // contacts share the same (x, y) position. + contact_sides?: string[]; probe_planar_contour?: number[][]; }