Skip to content

Commit b08a8de

Browse files
committed
Add a manufacturer landing page and open the viewer fitted to a probe's contacts with a per-probe zoom cap
1 parent bc3b270 commit b08a8de

17 files changed

Lines changed: 726 additions & 181 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Manufacturer logos
2+
3+
Drop a logo here named `<manufacturer-key>.png` to replace the wordmark on that
4+
manufacturer's landing-page card. The key is the manufacturer folder name in the
5+
repo root, e.g.:
6+
7+
- `cambridgeneurotech.png`
8+
- `diagnosticbiochips.png`
9+
- `imec.png`
10+
- `neuronexus.png`
11+
- `plexon.png`
12+
- `sinaps-research-platform.png`
13+
14+
If no file is present, the card falls back to a brand-colored wordmark
15+
(see `ProbeIndex.tsx`). Logos are landscape-friendly (rendered at 16:9, contained
16+
on a white background).
17+
18+
Only add logos you have the right to use. Manufacturer logos are trademarks;
19+
they are intentionally not committed here by default.
24.6 KB
Loading
61.2 KB
Loading
10.5 KB
Loading

apps/probe-viewer/public/logos/neuronexus.svg

Lines changed: 8 additions & 0 deletions
Loading
11.8 KB
Loading
Lines changed: 1 addition & 0 deletions
Loading

apps/probe-viewer/src/App.css

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,205 @@
463463
padding: 1rem;
464464
}
465465
}
466+
467+
/* ===== Catalog landing page ===== */
468+
.index {
469+
height: 100%;
470+
overflow-y: auto;
471+
padding: 2rem 2.5rem 3rem;
472+
}
473+
474+
.index-header {
475+
max-width: 1200px;
476+
margin: 0 auto 1.5rem;
477+
}
478+
479+
.index-title {
480+
margin: 0 0 0.25rem;
481+
font-size: 1.6rem;
482+
color: #0f172a;
483+
}
484+
485+
.index-subtitle {
486+
margin: 0 0 1rem;
487+
color: #475569;
488+
}
489+
490+
.index-controls {
491+
display: flex;
492+
flex-wrap: wrap;
493+
gap: 0.75rem;
494+
}
495+
496+
.index-search,
497+
.index-manufacturer {
498+
font: inherit;
499+
padding: 0.5rem 0.75rem;
500+
border-radius: 0.6rem;
501+
border: 1px solid rgba(148, 163, 184, 0.6);
502+
background: #fff;
503+
}
504+
505+
.index-search {
506+
flex: 1 1 16rem;
507+
min-width: 12rem;
508+
}
509+
510+
.index-hint,
511+
.index-error {
512+
max-width: 1200px;
513+
margin: 1rem auto;
514+
color: #64748b;
515+
}
516+
517+
.index-error {
518+
color: #b91c1c;
519+
}
520+
521+
.index-grid {
522+
max-width: 1200px;
523+
margin: 0 auto;
524+
display: grid;
525+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
526+
gap: 1rem;
527+
}
528+
529+
/* Manufacturer landing: fewer, larger cards. */
530+
.index-grid--manufacturers {
531+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
532+
gap: 1.5rem;
533+
}
534+
535+
.probe-card {
536+
display: flex;
537+
flex-direction: column;
538+
text-align: left;
539+
padding: 0;
540+
border: 1px solid rgba(148, 163, 184, 0.45);
541+
border-radius: 0.9rem;
542+
background: #fff;
543+
cursor: pointer;
544+
overflow: hidden;
545+
font: inherit;
546+
transition: transform 0.12s ease, box-shadow 0.2s ease, border-color 0.2s ease;
547+
}
548+
549+
.probe-card:hover {
550+
transform: translateY(-2px);
551+
border-color: rgba(37, 99, 235, 0.6);
552+
box-shadow: 0 12px 22px rgba(15, 23, 42, 0.12);
553+
}
554+
555+
.probe-card-image {
556+
width: 100%;
557+
aspect-ratio: 4 / 3;
558+
object-fit: contain;
559+
background: #f8fafc;
560+
border-bottom: 1px solid rgba(148, 163, 184, 0.3);
561+
}
562+
563+
.probe-card-image--empty {
564+
display: flex;
565+
align-items: center;
566+
justify-content: center;
567+
color: #94a3b8;
568+
font-size: 0.85rem;
569+
}
570+
571+
/* Manufacturer logo: centered in a uniform tile, sized by height (with a width
572+
cap) so logos of different aspect ratios read at a consistent scale. */
573+
.probe-card-logo-tile {
574+
aspect-ratio: 5 / 2;
575+
background: #fff;
576+
display: flex;
577+
align-items: center;
578+
justify-content: center;
579+
padding: 0.5rem 0.75rem;
580+
}
581+
582+
.probe-card-logo-img {
583+
max-height: 5rem;
584+
max-width: 97%;
585+
width: auto;
586+
height: auto;
587+
object-fit: contain;
588+
}
589+
590+
/* Manufacturer wordmark cards (fallback when no logo file is present). */
591+
.probe-card-logo {
592+
width: 100%;
593+
aspect-ratio: 16 / 9;
594+
display: flex;
595+
align-items: center;
596+
justify-content: center;
597+
padding: 1.25rem;
598+
text-align: center;
599+
background: #334155;
600+
}
601+
602+
.probe-card-logo-text {
603+
color: #fff;
604+
font-size: 1.4rem;
605+
font-weight: 700;
606+
letter-spacing: 0.01em;
607+
line-height: 1.2;
608+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
609+
}
610+
611+
.probe-card-logo--cambridgeneurotech {
612+
background: linear-gradient(135deg, #0e7490, #06b6d4);
613+
}
614+
.probe-card-logo--imec {
615+
background: linear-gradient(135deg, #9f1239, #e11d48);
616+
}
617+
.probe-card-logo--neuronexus {
618+
background: linear-gradient(135deg, #1e40af, #3b82f6);
619+
}
620+
.probe-card-logo--diagnosticbiochips {
621+
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
622+
}
623+
.probe-card-logo--plexon {
624+
background: linear-gradient(135deg, #9a3412, #f97316);
625+
}
626+
.probe-card-logo--sinaps-research-platform {
627+
background: linear-gradient(135deg, #115e59, #14b8a6);
628+
}
629+
630+
.probe-card-body {
631+
display: flex;
632+
flex-direction: column;
633+
gap: 0.2rem;
634+
padding: 0.7rem 0.85rem 0.9rem;
635+
}
636+
637+
.probe-card-title {
638+
font-weight: 600;
639+
color: #0f172a;
640+
}
641+
642+
.probe-card-manufacturer {
643+
font-size: 0.85rem;
644+
color: #2563eb;
645+
}
646+
647+
.probe-card-meta {
648+
font-size: 0.8rem;
649+
color: #64748b;
650+
}
651+
652+
.sidebar-home {
653+
align-self: flex-start;
654+
background: none;
655+
border: none;
656+
padding: 0;
657+
margin-bottom: 0.6rem;
658+
font: inherit;
659+
font-size: 0.85rem;
660+
font-weight: 600;
661+
color: #2563eb;
662+
cursor: pointer;
663+
}
664+
665+
.sidebar-home:hover {
666+
text-decoration: underline;
667+
}

apps/probe-viewer/src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect } from "react";
2+
import { useParams } from "react-router-dom";
23

4+
import { ProbeIndex } from "./components/ProbeIndex";
35
import { ProbeViewer } from "./components/ProbeViewer";
46
import { Sidebar } from "./components/Sidebar";
57
import { useAppStore } from "./state/useAppStore";
@@ -10,6 +12,8 @@ import "./App.css";
1012

1113
function App() {
1214
const loadManifest = useAppStore((state) => state.loadManifest);
15+
// Present on /probes/:manufacturer/:model, absent on the bare "/" landing.
16+
const { model } = useParams();
1317

1418
useEffect(() => {
1519
void loadManifest();
@@ -24,6 +28,11 @@ function App() {
2428
useRestoreCameraFromUrl();
2529
useSyncCameraToUrl();
2630

31+
// No probe in the route: show the catalog landing instead of a probe view.
32+
if (!model) {
33+
return <ProbeIndex />;
34+
}
35+
2736
return (
2837
<div className="app-shell">
2938
<aside className="app-sidebar">

apps/probe-viewer/src/components/ProbeCanvas.tsx

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
} from "react";
1414

1515
import { useResizeObserver } from "../hooks/useResizeObserver";
16-
import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore";
16+
import { VIEW_ZOOM_MIN } from "../state/useAppStore";
1717
import type {
1818
ContactShapeParams,
1919
ManifestEntry,
@@ -25,6 +25,7 @@ interface ProbeCanvasProps {
2525
entry: ManifestEntry;
2626
probeData: ProbeInterfaceFile;
2727
camera: ProbeViewerCamera;
28+
maxZoom: number;
2829
showContactIds: boolean;
2930
showScaleBar: boolean;
3031
onViewCenterChange: (x: number | null, y: number | null) => void;
@@ -83,6 +84,7 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
8384
entry,
8485
probeData,
8586
camera,
87+
maxZoom,
8688
showContactIds,
8789
showScaleBar,
8890
onViewCenterChange,
@@ -110,6 +112,36 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
110112
const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]);
111113
const probe = useMemo(() => probeData.probes?.[0], [probeData]);
112114

115+
// For uniform contact-id sizing: the widest id label (so one font fits the
116+
// longest) and the smallest contact box in micrometers (so it fits every pad).
117+
// These are zoom-independent, so they are computed once per probe.
118+
const idLabelInfo = useMemo(() => {
119+
const ids = probe?.contact_ids;
120+
const positions = probe?.contact_positions;
121+
if (!ids || !positions || positions.length === 0) return null;
122+
const shapes = probe.contact_shapes ?? [];
123+
const params = probe.contact_shape_params ?? [];
124+
let widestLabel = "";
125+
let minWidthUm = Infinity;
126+
let minHeightUm = Infinity;
127+
for (let i = 0; i < positions.length; i++) {
128+
const label = String(ids[i] ?? i);
129+
if (label.length > widestLabel.length) widestLabel = label;
130+
const shape = shapes[i] ?? "";
131+
const p = params[i] ?? {};
132+
const widthUm = shape === "circle" ? 2 * (p.radius ?? 5) : p.width ?? 10;
133+
const heightUm =
134+
shape === "circle"
135+
? 2 * (p.radius ?? 5)
136+
: shape === "rect"
137+
? p.height ?? 15
138+
: p.width ?? 10;
139+
if (widthUm < minWidthUm) minWidthUm = widthUm;
140+
if (heightUm < minHeightUm) minHeightUm = heightUm;
141+
}
142+
return { widestLabel, minWidthUm, minHeightUm };
143+
}, [probe]);
144+
113145
// Calculate effective view center (use geometry center if null)
114146
const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0;
115147
const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0;
@@ -263,16 +295,28 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
263295
ctx.stroke();
264296
});
265297

266-
if (showContactIds && probe.contact_ids) {
298+
if (showContactIds && probe.contact_ids && idLabelInfo) {
267299
const contactIds = probe.contact_ids;
268-
ctx.font = `${Math.max(10, Math.min(14, 10 * (scale / 100)))}px "Inter", sans-serif`;
300+
const { widestLabel, minWidthUm, minHeightUm } = idLabelInfo;
301+
// One font for the whole probe: the size at which the widest id fits the
302+
// smallest contact (by width and height). Text width scales linearly with
303+
// font size, so measure the widest label once at a reference size and
304+
// solve. Tracks zoom and real contact size; never overflows a pad.
305+
const REF_FONT = 100;
306+
ctx.font = `${REF_FONT}px "Inter", sans-serif`;
307+
const widestWidthAtRef = Math.max(1, ctx.measureText(widestLabel).width);
308+
const fontByWidth = (REF_FONT * minWidthUm * scale) / widestWidthAtRef;
309+
const fontByHeight = minHeightUm * scale;
310+
const fontPx = Math.min(fontByWidth, fontByHeight) * 0.85;
311+
312+
ctx.font = `${fontPx}px "Inter", sans-serif`;
269313
ctx.textAlign = "center";
270-
ctx.textBaseline = "top";
314+
ctx.textBaseline = "middle";
271315
ctx.fillStyle = "rgba(15, 23, 42, 0.95)";
272316
contactPositions.forEach((position, index) => {
273317
const [x, y] = projectPoint(position);
274318
// Show the probe's actual contact id, not the array index.
275-
ctx.fillText(String(contactIds[index] ?? index), x, y + 4);
319+
ctx.fillText(String(contactIds[index] ?? index), x, y);
276320
});
277321
}
278322

@@ -343,11 +387,11 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
343387
if (showScaleBar) {
344388
renderScaleBar();
345389
}
346-
}, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]);
390+
}, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, idLabelInfo, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]);
347391

348392
const clampZoom = useCallback(
349-
(value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)),
350-
[],
393+
(value: number) => Math.min(maxZoom, Math.max(VIEW_ZOOM_MIN, value)),
394+
[maxZoom],
351395
);
352396

353397
// Helper to calculate scale (needed for coordinate conversion in handlers)

0 commit comments

Comments
 (0)