diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index d7c0d14..564c79d 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -438,6 +438,66 @@ border: 0; } +/* Double-sided probe layout controls */ +.viewer-controls-sides { + display: flex; + 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-segmented button { + border-radius: 0; + text-transform: capitalize; +} + +.viewer-segmented button:first-child { + border-top-left-radius: 0.75rem; + border-bottom-left-radius: 0.75rem; +} + +.viewer-segmented button:last-child { + border-top-right-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; +} + +.viewer-segmented button:not(:first-child) { + border-left: none; +} + +.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/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index 5b5ffdb..0d97206 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -4,8 +4,10 @@ import { useSearchParams } from "react-router-dom"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useAppStore } from "../state/useAppStore"; import { exportProbeAsPng, exportProbeAsSvg } from "../utils/exportUtils"; +import { getSideInfo } from "../geometry/sides"; import type { ContactShapeParams, ProbeInterfaceProbe } from "../types/probe"; import { ProbeCanvas } from "./ProbeCanvas"; +import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; import { ProbeOverview } from "./ProbeOverview"; const CANVAS_PADDING = 40; @@ -176,6 +178,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) { @@ -200,6 +203,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(); @@ -394,6 +418,28 @@ export function ProbeViewer() { + {isDoubleSided && ( +
+ + Double-sided ·{" "} + {sideInfo.sides.map((side) => `${sideCounts[side] ?? 0} ${side}`).join(" / ")} + +
+ {sideInfo.sides.map((side) => ( + + ))} +
+
+ )} )} @@ -405,16 +451,28 @@ 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 a6bb6f2..2904f13 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; // Per-probe zoom ceiling, computed from geometry so the smallest contact can // fill the viewport regardless of probe length (see setMaxZoom callers). maxZoom: number; @@ -49,6 +52,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; @@ -68,6 +72,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", maxZoom: VIEW_ZOOM_MAX, }; @@ -225,6 +231,7 @@ export const useAppStore = create((set, get) => ({ view: { ...INITIAL_VIEW_STATE, showContactIds: state.view.showContactIds, + overlaySide: state.view.overlaySide, // The cap is a property of the probe, not the camera; keep it across a reset. maxZoom: state.view.maxZoom, }, @@ -256,6 +263,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[][]; }