Skip to content

Commit a184e1c

Browse files
committed
feat: ephys visualization
1 parent 6d1399b commit a184e1c

6 files changed

Lines changed: 925 additions & 8 deletions

File tree

web/src/__tests__/coord-systems.test.js

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { describe, it, expect } from 'vitest';
12-
import { parseTranslation, parseDeviceConfigCoords } from '../lib/coord-systems.js';
12+
import { parseTranslation, parseDeviceConfigCoords, computeProbeDirection } from '../lib/coord-systems.js';
1313

1414
// ── Fixtures ────────────────────────────────────────────────────────────────
1515

@@ -220,3 +220,114 @@ describe('parseDeviceConfigCoords', () => {
220220
expect(result.depth).toBeNull();
221221
});
222222
});
223+
224+
// ── computeProbeDirection ───────────────────────────────────────────────────
225+
226+
describe('computeProbeDirection', () => {
227+
it('returns +Z unit vector when no transforms are given (probe points anterior)', () => {
228+
const [x, y, z] = computeProbeDirection([]);
229+
expect(x).toBeCloseTo(0);
230+
expect(y).toBeCloseTo(0);
231+
expect(z).toBeCloseTo(1);
232+
});
233+
234+
it('returns +Z when only a zero rotation is given', () => {
235+
const [x, y, z] = computeProbeDirection([
236+
{ object_type: 'Rotation', angles: [0, 0, 0] },
237+
]);
238+
expect(x).toBeCloseTo(0);
239+
expect(y).toBeCloseTo(0);
240+
expect(z).toBeCloseTo(1);
241+
});
242+
243+
it('ignores Translation objects (only rotations affect direction)', () => {
244+
const [x, y, z] = computeProbeDirection([
245+
{ object_type: 'Translation', translation: [10, 20, 30] },
246+
]);
247+
expect(x).toBeCloseTo(0);
248+
expect(y).toBeCloseTo(0);
249+
expect(z).toBeCloseTo(1);
250+
});
251+
252+
it('Rx(90°) rotates +Z to -Y (ventral)', () => {
253+
// Rx: y1 = cos(90)*z_y - sin(90)*z_z = 0 - 1 = -1, z1 = sin(90)*0 + cos(90)*1 = 0
254+
const [x, y, z] = computeProbeDirection([
255+
{ object_type: 'Rotation', angles: [90, 0, 0] },
256+
]);
257+
expect(x).toBeCloseTo(0);
258+
expect(y).toBeCloseTo(-1);
259+
expect(z).toBeCloseTo(0);
260+
});
261+
262+
it('Rx(-90°) rotates +Z to +Y (dorsal)', () => {
263+
// Rx(-90): y1 = cos(-90)*0 - sin(-90)*1 = 1, z1 = sin(-90)*0 + cos(-90)*1 = 0
264+
const [x, y, z] = computeProbeDirection([
265+
{ object_type: 'Rotation', angles: [-90, 0, 0] },
266+
]);
267+
expect(x).toBeCloseTo(0);
268+
expect(y).toBeCloseTo(1);
269+
expect(z).toBeCloseTo(0);
270+
});
271+
272+
it('Ry(90°) rotates +Z to +X (right/ML)', () => {
273+
// Ry: x2 = cos(90)*0 + sin(90)*1 = 1, z2 = -sin(90)*0 + cos(90)*1 = 0
274+
const [x, y, z] = computeProbeDirection([
275+
{ object_type: 'Rotation', angles: [0, 90, 0] },
276+
]);
277+
expect(x).toBeCloseTo(1);
278+
expect(y).toBeCloseTo(0);
279+
expect(z).toBeCloseTo(0);
280+
});
281+
282+
it('Rz(90°) does not affect +Z (Rz only rotates the XY plane)', () => {
283+
const [x, y, z] = computeProbeDirection([
284+
{ object_type: 'Rotation', angles: [0, 0, 90] },
285+
]);
286+
expect(x).toBeCloseTo(0);
287+
expect(y).toBeCloseTo(0);
288+
expect(z).toBeCloseTo(1);
289+
});
290+
291+
it('Rz(-90°) does not affect +Z', () => {
292+
const [x, y, z] = computeProbeDirection([
293+
{ object_type: 'Rotation', angles: [0, 0, -90] },
294+
]);
295+
expect(x).toBeCloseTo(0);
296+
expect(y).toBeCloseTo(0);
297+
expect(z).toBeCloseTo(1);
298+
});
299+
300+
it('result is a unit vector', () => {
301+
const dir = computeProbeDirection([
302+
{ object_type: 'Rotation', angles: [30, 45, -20] },
303+
]);
304+
const len = Math.sqrt(dir[0] ** 2 + dir[1] ** 2 + dir[2] ** 2);
305+
expect(len).toBeCloseTo(1, 6);
306+
});
307+
308+
it('applies multiple rotations in sequence (extrinsic)', () => {
309+
// Rx(-90) makes +Z → +Y (dorsal), then Ry(90) rotates +Y unchanged → +Y
310+
// because Ry only rotates the XZ plane.
311+
const [x, y, z] = computeProbeDirection([
312+
{ object_type: 'Rotation', angles: [-90, 0, 0] }, // +Z → +Y
313+
{ object_type: 'Rotation', angles: [0, 90, 0] }, // Ry on (0,1,0): Y unaffected
314+
]);
315+
expect(x).toBeCloseTo(0);
316+
expect(y).toBeCloseTo(1);
317+
expect(z).toBeCloseTo(0);
318+
});
319+
320+
it('handles null transforms array gracefully', () => {
321+
const [x, y, z] = computeProbeDirection(null);
322+
expect(x).toBeCloseTo(0);
323+
expect(y).toBeCloseTo(0);
324+
expect(z).toBeCloseTo(1);
325+
});
326+
327+
it('handles missing angles array gracefully', () => {
328+
const [x, y, z] = computeProbeDirection([
329+
{ object_type: 'Rotation' }, // no angles property — defaults to [0,0,0]
330+
]);
331+
expect(z).toBeCloseTo(1); // unchanged
332+
});
333+
});
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* ephys-view.test.js — Unit tests for ephys-related pure helpers in details.js.
3+
*
4+
* Tests hasEphysAssemblies and buildEphysProbeCard, which are Node-safe (no DOM/Three.js).
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
9+
// Mock Three.js and OBJLoader so the ephys-viz-3d import doesn't fail in Node
10+
vi.mock('three', () => ({ default: {} }));
11+
vi.mock('three/addons/loaders/OBJLoader.js', () => ({ OBJLoader: class {} }));
12+
13+
// Mock brain-viz-3d exports needed by ephys-viz-3d
14+
vi.mock('../subject/brain-viz-3d.js', () => ({
15+
STRUCTURE_COLORS: {},
16+
TARGET_X: 0, TARGET_Y: -3.668, TARGET_Z: -1.2,
17+
makeCCFMatrix: () => ({}),
18+
cssHexToThree: (h) => parseInt(h.replace('#', ''), 16),
19+
surfaceY: () => null,
20+
loadBrainMesh: () => {},
21+
createBrainViz3D: () => document.createElement('div'),
22+
}));
23+
24+
// Mock brain-viz for ITEM_COLORS
25+
vi.mock('../subject/brain-viz.js', () => ({
26+
ITEM_COLORS: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
27+
createBrainVizCanvas: () => ({ canvas: document.createElement('canvas') }),
28+
}));
29+
30+
// Mock assets/view.js
31+
vi.mock('../assets/view.js', () => ({
32+
buildQcLink: () => null,
33+
buildMetadataLink: () => null,
34+
buildCoLink: () => null,
35+
buildS3ConsoleUrl: () => null,
36+
}));
37+
38+
import { hasEphysAssemblies, buildEphysProbeCard } from '../subject/details.js';
39+
40+
// ---------------------------------------------------------------------------
41+
// Fixtures
42+
// ---------------------------------------------------------------------------
43+
44+
const EPHYS_CONFIG = {
45+
object_type: 'Ephys assembly config',
46+
device_name: 'Ephys Assembly 46121',
47+
modules: [
48+
{ object_type: 'MIS module config', arc_angle: -10, module_angle: -15, angle_unit: 'degrees' },
49+
],
50+
probes: [
51+
{
52+
device_name: '46121',
53+
dye: 'DiD',
54+
notes: 'Some notes here.',
55+
primary_targeted_structure: { acronym: 'PAL', id: '803', name: 'Pallidum' },
56+
other_targeted_structure: null,
57+
coordinate_system: {
58+
name: 'PROBE_RUFD',
59+
origin: 'Tip',
60+
axis_unit: 'micrometer',
61+
axes: [
62+
{ direction: 'Left_to_right', name: 'X' },
63+
{ direction: 'Down_to_up', name: 'Y' },
64+
{ direction: 'Back_to_front', name: 'Z' },
65+
{ direction: 'Up_to_down', name: 'Depth' },
66+
],
67+
},
68+
transform: [
69+
{ object_type: 'Rotation', angles: [90, 0, -90], angles_unit: 'degrees' },
70+
{ object_type: 'Translation', translation: [3.96, -12.10, -4.04, 3.611] },
71+
],
72+
},
73+
],
74+
};
75+
76+
const ACQUISITION_WITH_EPHYS = {
77+
data_streams: [
78+
{
79+
object_type: 'Data stream',
80+
configurations: [EPHYS_CONFIG],
81+
},
82+
],
83+
};
84+
85+
const ACQUISITION_WITHOUT_EPHYS = {
86+
data_streams: [
87+
{
88+
object_type: 'Data stream',
89+
configurations: [
90+
{ object_type: 'Camera config', device_name: 'Camera 1' },
91+
],
92+
},
93+
],
94+
};
95+
96+
// ---------------------------------------------------------------------------
97+
// hasEphysAssemblies
98+
// ---------------------------------------------------------------------------
99+
100+
describe('hasEphysAssemblies', () => {
101+
it('returns true when acquisition has at least one Ephys assembly config', () => {
102+
expect(hasEphysAssemblies(ACQUISITION_WITH_EPHYS)).toBe(true);
103+
});
104+
105+
it('returns false when no Ephys assembly config exists', () => {
106+
expect(hasEphysAssemblies(ACQUISITION_WITHOUT_EPHYS)).toBe(false);
107+
});
108+
109+
it('returns false for empty data_streams', () => {
110+
expect(hasEphysAssemblies({ data_streams: [] })).toBe(false);
111+
});
112+
113+
it('returns false for null input', () => {
114+
expect(hasEphysAssemblies(null)).toBe(false);
115+
});
116+
117+
it('returns false for undefined input', () => {
118+
expect(hasEphysAssemblies(undefined)).toBe(false);
119+
});
120+
121+
it('finds ephys config nested inside a stream with mixed configs', () => {
122+
const mixed = {
123+
data_streams: [
124+
{
125+
configurations: [
126+
{ object_type: 'Camera config' },
127+
{ object_type: 'Ephys assembly config', probes: [] },
128+
],
129+
},
130+
],
131+
};
132+
expect(hasEphysAssemblies(mixed)).toBe(true);
133+
});
134+
});
135+
136+
// ---------------------------------------------------------------------------
137+
// buildEphysProbeCard
138+
// ---------------------------------------------------------------------------
139+
140+
describe('buildEphysProbeCard', () => {
141+
const probe = {
142+
name: '46121',
143+
dye: 'DiD',
144+
notes: 'Probe notes.',
145+
ap: -4.04,
146+
ml: 3.96,
147+
depth: 3.611,
148+
probeDir: [-0.258, 0.070, 0.964],
149+
modules: [{ arc_angle: -10, module_angle: -15 }],
150+
primaryStructure: { acronym: 'PAL', id: '803', name: 'Pallidum' },
151+
otherStructures: [],
152+
structureIds: ['803'],
153+
};
154+
155+
it('renders the probe name in a heading', () => {
156+
const html = buildEphysProbeCard(probe, 0);
157+
expect(html).toContain('46121');
158+
});
159+
160+
it('renders the primary targeted structure', () => {
161+
const html = buildEphysProbeCard(probe, 0);
162+
expect(html).toContain('Pallidum');
163+
expect(html).toContain('PAL');
164+
});
165+
166+
it('renders the dye', () => {
167+
const html = buildEphysProbeCard(probe, 0);
168+
expect(html).toContain('DiD');
169+
});
170+
171+
it('renders module angles', () => {
172+
const html = buildEphysProbeCard(probe, 0);
173+
expect(html).toContain('arc -10');
174+
expect(html).toContain('module -15');
175+
});
176+
177+
it('renders position and depth', () => {
178+
const html = buildEphysProbeCard(probe, 0);
179+
expect(html).toContain('-4.04');
180+
expect(html).toContain('3.96');
181+
expect(html).toContain('3.61');
182+
});
183+
184+
it('renders notes', () => {
185+
const html = buildEphysProbeCard(probe, 0);
186+
expect(html).toContain('Probe notes.');
187+
});
188+
189+
it('does not render other targets section when otherStructures is empty', () => {
190+
const html = buildEphysProbeCard(probe, 0);
191+
expect(html).not.toContain('Other targets');
192+
});
193+
194+
it('renders other targeted structures when present', () => {
195+
const p2 = {
196+
...probe,
197+
otherStructures: [{ acronym: 'MD', id: '362', name: 'Mediodorsal nucleus of thalamus' }],
198+
};
199+
const html = buildEphysProbeCard(p2, 0);
200+
expect(html).toContain('Mediodorsal nucleus of thalamus');
201+
expect(html).toContain('Other targets');
202+
});
203+
204+
it('omits dye row when dye is null', () => {
205+
const p2 = { ...probe, dye: null };
206+
const html = buildEphysProbeCard(p2, 0);
207+
expect(html).not.toContain('DiD');
208+
});
209+
210+
it('omits notes row when notes is null', () => {
211+
const p2 = { ...probe, notes: null };
212+
const html = buildEphysProbeCard(p2, 1);
213+
expect(html).not.toContain('Probe notes.');
214+
});
215+
216+
it('handles probe with no modules gracefully', () => {
217+
const p2 = { ...probe, modules: [] };
218+
const html = buildEphysProbeCard(p2, 0);
219+
expect(html).not.toContain('Module angles');
220+
});
221+
222+
it('shows "Probe N:" prefix using 1-based index', () => {
223+
const html = buildEphysProbeCard(probe, 0);
224+
expect(html).toContain('Probe 1:');
225+
});
226+
227+
it('index 2 shows "Probe 3:"', () => {
228+
const html = buildEphysProbeCard(probe, 2);
229+
expect(html).toContain('Probe 3:');
230+
});
231+
});

0 commit comments

Comments
 (0)