Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/probe-viewer/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
188 changes: 188 additions & 0 deletions apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>();
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 (
<div ref={containerRef} className="viewer-canvas-surface">
{geometry && probe ? (
<canvas
ref={canvasRef}
role="img"
aria-label={`${entry.displayName} planar layout (double-sided)`}
data-zoom={zoom}
data-view-cx={centerX ?? undefined}
data-view-cy={centerY ?? undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onDoubleClick={handleDoubleClick}
/>
) : (
<div className="viewer-placeholder">
<p>No planar geometry available for this probe.</p>
</div>
)}
</div>
);
}
78 changes: 68 additions & 10 deletions apps/probe-viewer/src/components/ProbeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, number> = {};
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<HTMLDivElement>();

Expand Down Expand Up @@ -394,6 +418,28 @@ export function ProbeViewer() {
<button type="button" onClick={fullContactsView} title="Zoom to fit just the contacts">
Full Contacts View
</button>
{isDoubleSided && (
<div className="viewer-controls-sides">
<span className="viewer-controls-label">
Double-sided ·{" "}
{sideInfo.sides.map((side) => `${sideCounts[side] ?? 0} ${side}`).join(" / ")}
</span>
<div className="viewer-segmented" role="group" aria-label="Which face to show">
{sideInfo.sides.map((side) => (
<button
key={side}
type="button"
className={activeSide === side ? "is-active" : ""}
onClick={() => setOverlaySide(side)}
title={`Show the ${side} face channel map`}
>
<span className={`viewer-side-swatch viewer-side-swatch--${side}`} />
{side}
</button>
))}
</div>
</div>
)}
</section>
)}

Expand All @@ -405,16 +451,28 @@ export function ProbeViewer() {
)}
{status !== "error" && probeData && (
<>
<ProbeCanvas
entry={entry}
probeData={probeData}
camera={view.camera}
maxZoom={view.maxZoom}
showContactIds={view.showContactIds}
showScaleBar={view.showScaleBar}
onViewCenterChange={(x, y) => setViewCenter(x, y)}
onZoom={(value) => setZoom(value)}
/>
{isDoubleSided ? (
<DoubleSidedProbeCanvas
entry={entry}
probeData={probeData}
camera={view.camera}
showScaleBar={view.showScaleBar}
overlaySide={activeSide}
onViewCenterChange={(x, y) => setViewCenter(x, y)}
onZoom={(value) => setZoom(value)}
/>
) : (
<ProbeCanvas
entry={entry}
probeData={probeData}
camera={view.camera}
maxZoom={view.maxZoom}
showContactIds={view.showContactIds}
showScaleBar={view.showScaleBar}
onViewCenterChange={(x, y) => setViewCenter(x, y)}
onZoom={(value) => setZoom(value)}
/>
)}
{view.showOverview && (
<ProbeOverview
probeData={probeData}
Expand Down
Loading
Loading