Skip to content

Commit 15611f7

Browse files
v2: multi-modality support (EEG/MEG/iEEG/EMG/fNIRS) + flat-layout mode
- URL-driven auto-load: ?tsv=&coords= and ?montage=<id> alongside ?demo= - ?embed=1 compact mode for iframe embedding - Flat-layout pipeline for iEEG / EMG / fNIRS — bounding box + crosshair instead of scalp-specific chrome - Head silhouette overlay for iEEG and fNIRS (scalp-adjacent sensors); EMG stays pure-flat since muscle positions aren't head-referenced - fNIRS rendering distinguishes sources (orange) and detectors (blue) via the optodes.tsv type column - EMG multi-frame datasets (HySER's ed/ep/fd/fp muscle groups) spread across a 2x2 panel grid so the 4x64 electrodes don't stack - Loader returns sphere:null for flat layouts; right-rail stats and stage caption adapt rather than reporting meaningless sphere radii - Added 18-assertion regression harness (test-data/generate-flat-evidence.mjs) covering iEEG / EMG (real HySER fixture) / fNIRS / cross-modality hash isolation - Evidence PNGs committed under test-data/ for all three new modalities - Cache-busted to v=2 / v=6
1 parent 8a14348 commit 15611f7

13 files changed

Lines changed: 1192 additions & 48 deletions

README.md

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
# eegdash / electrodes
22

3-
Static BIDS electrode-layout viewer. Deployed to **https://electrodes.eegdash.org**.
3+
Static BIDS sensor-layout viewer. Deployed to **https://electrodes.eegdash.org**.
44

55
Pure HTML / SVG / vanilla JS — no build step, no Three.js, no backend.
6-
Loads `electrodes.tsv` + optional `coordsystem.json` via drag-drop or URL
7-
params, auto-detects units, rotates ALS coord systems to RAS+, and renders
8-
an MNE/EEGLAB-style azimuthal-equidistant topomap.
6+
Loads `electrodes.tsv` / `optodes.tsv` + optional `coordsystem.json` via
7+
drag-drop or URL params, auto-detects units, rotates ALS coord systems to
8+
RAS+, and renders the layout in a 2D viewer.
9+
10+
## Supported modalities
11+
12+
| Modality | Source file | Rendering |
13+
|---|---|---|
14+
| **EEG** (scalp) | `_electrodes.tsv` | Sphere mode — MNE/EEGLAB azimuthal-equidistant topomap with head, nose, ears, 10-10 reference rings |
15+
| **MEG** | raw header (FIF / CTF `.ds` / KIT) | Sphere mode — helmet approximates sphere |
16+
| **iEEG** (ECoG / depth) | `_electrodes.tsv` (brain space) | Flat-scatter mode with **faint head silhouette** for orientation |
17+
| **fNIRS** | `_optodes.tsv` | Flat-scatter mode with head silhouette, **sources orange / detectors blue** |
18+
| **EMG** (BEP-030) | `_electrodes.tsv` (body landmarks) | Flat-scatter mode, no head (multi-group datasets laid out in a 2×2 panel grid) |
919

1020
## URL shapes
1121

12-
| URL | Purpose |
13-
|-------------------------------------------------------------------|-------------------------------------------|
14-
| `/` | Drag-drop playground (default 10-20 shown)|
15-
| `/?demo=<prefix>` | Local fixture from `test-data/` |
16-
| `/?tsv=<url>&coords=<url>` | Direct URL fetch (e.g. OpenNeuro S3) |
17-
| `/?montage=<registry_id>` | Fetch from eegdash registry (forward-looking) |
18-
| `/?...&embed=1` | Iframe-embed mode (rails hidden) |
19-
| `/?...&tweaks=1` | Show the tweaks panel (debugging) |
22+
| URL | Purpose |
23+
|---|---|
24+
| `/` | Drag-drop playground (default 10-20 shown) |
25+
| `/?demo=<prefix>` | Local fixture from `test-data/` |
26+
| `/?tsv=<url>&coords=<url>` | Direct URL fetch (e.g. OpenNeuro S3) |
27+
| `/?tsv=<url>&modality=<eeg\|ieeg\|emg\|nirs\|meg>` | Explicit modality (otherwise inferred from `coordsystem.json` keys) |
28+
| `/?montage=<registry_id>` | Fetch from eegdash registry (forward-looking) |
29+
| `/?...&embed=1` | Iframe-embed mode (rails hidden) |
30+
| `/?...&tweaks=1` | Show the tweaks panel (debugging) |
2031

2132
## Embedding in eegdash docs
2233

@@ -32,15 +43,30 @@ python3 -m http.server 9876
3243
open http://localhost:9876/?demo=ds002578_sub-002
3344
```
3445

35-
## Regression test
46+
## Regression tests
3647

3748
```sh
49+
# EEG pipeline (13 assertions)
3850
node test-data/generate-evidence.mjs
51+
52+
# iEEG + EMG (real HySER) + fNIRS flat-layout pipeline (18 assertions)
53+
node test-data/generate-flat-evidence.mjs
3954
```
4055

41-
Runs the full loader pipeline against synthetic 10-20 data with 13 assertions.
56+
The EMG test fetches HySER sub-01 from NEMAR (nm000108) — first run needs
57+
network; subsequent runs use the cached TSV under `test-data/`.
58+
59+
## What's visible from each mode
60+
61+
- **Sphere mode (EEG/MEG)** — head circle, nose, ears, dashed 10-10 reference
62+
rings at r=0.25/0.5/0.75, nasion/inion/LPA/RPA landmarks.
63+
- **Flat + head reference (iEEG/fNIRS)** — bounding box + crosshair + faint
64+
dashed head silhouette with nose/ears for orientation.
65+
- **Flat only (EMG)** — bounding box + crosshair; multi-frame datasets
66+
(HySER's ed/ep/fd/fp) partitioned into a 2×2 panel grid.
4267

43-
## Docs / roadmap
68+
## Integration plan
4469

4570
See `PLAN.md` in the parent
46-
[`eegdash`](https://github.com/eegdash) repo for the full integration plan.
71+
[`eegdash`](https://github.com/eegdash) repo for the full backend +
72+
Sphinx injection roadmap.

bids-loader.js

Lines changed: 174 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
}
3030
const iType = col('type'), iMat = col('material');
3131

32+
// Optional BIDS columns we preserve if present: `coordinate_system` and
33+
// `group` drive multi-frame EMG panelling; `hemisphere` helps iEEG.
34+
const iCoordSys = headers.indexOf('coordinate_system');
35+
const iGroup = headers.indexOf('group');
36+
const iHemi = headers.indexOf('hemisphere');
37+
3238
const rows = [];
3339
for (let i = 1; i < lines.length; i++) {
3440
const c = lines[i].split('\t');
@@ -38,11 +44,21 @@
3844
const z = parseFloat(c[iZ]);
3945
// BIDS uses "n/a" for missing; parseFloat → NaN → skip.
4046
if (!name || !isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
41-
rows.push({
47+
const row = {
4248
name, x, y, z,
4349
type: iType >= 0 ? (c[iType] || '').trim() : '',
4450
material: iMat >= 0 ? (c[iMat] || '').trim() : '',
45-
});
51+
};
52+
if (iCoordSys >= 0 && c[iCoordSys] && c[iCoordSys].trim() && c[iCoordSys].trim().toLowerCase() !== 'n/a') {
53+
row.coordinate_system = c[iCoordSys].trim();
54+
}
55+
if (iGroup >= 0 && c[iGroup] && c[iGroup].trim() && c[iGroup].trim().toLowerCase() !== 'n/a') {
56+
row.group = c[iGroup].trim();
57+
}
58+
if (iHemi >= 0 && c[iHemi] && c[iHemi].trim() && c[iHemi].trim().toLowerCase() !== 'n/a') {
59+
row.hemisphere = c[iHemi].trim();
60+
}
61+
rows.push(row);
4662
}
4763
if (rows.length < 4) throw new Error('Need at least 4 electrodes with finite x,y,z');
4864
return rows;
@@ -51,7 +67,13 @@
5167
// ---- coordsystem.json ---------------------------------------
5268
api.parseCoordsystem = function (jsonOrText) {
5369
const obj = typeof jsonOrText === 'string' ? JSON.parse(jsonOrText) : jsonOrText;
54-
const prefix = ['EEG', 'iEEG', 'MEG'].find(p => obj[p + 'CoordinateSystem']) || 'EEG';
70+
// BIDS prefixes coordinate keys by datatype: EEGCoordinateSystem,
71+
// iEEGCoordinateSystem, MEGCoordinateSystem, EMGCoordinateSystem (BEP-030),
72+
// NIRSCoordinateSystem. Pick whichever prefix has a match.
73+
const prefixes = ['EEG', 'iEEG', 'MEG', 'EMG', 'NIRS'];
74+
const prefix = prefixes.find(
75+
p => obj[p + 'CoordinateSystem'] || obj[p + 'CoordinateUnits']
76+
) || 'EEG';
5577
return {
5678
space: obj[prefix + 'CoordinateSystem'] || 'Other',
5779
units: (obj[prefix + 'CoordinateUnits'] || 'm').toLowerCase(),
@@ -236,22 +258,160 @@
236258
return matches / electrodes.length >= 0.7 ? 'label' : 'position';
237259
}
238260

261+
// Modalities the EEG-style sphere pipeline applies to. Other modalities
262+
// (iEEG in brain space, EMG on body landmarks, fNIRS when the coordsystem
263+
// isn't obviously a scalp frame) bypass sphere-fit + unit-inference and
264+
// go through a "flat" pipeline that just normalises the bounding box.
265+
const SPHERE_MODALITIES = new Set(['eeg', 'meg']);
266+
267+
// ---- Flat pipeline (iEEG / EMG / fNIRS / anything non-spherical) ----
268+
// Normalises raw (x, y, z) to a [-1, 1] cube around the centroid. Skips
269+
// axis rotation, unit inference, and sphere-fit. The viewer renders these
270+
// as a plain scatter — no head outline, no 10-10 rings, just coordinate
271+
// axes and a bounding box.
272+
//
273+
// For EMG datasets with multiple anatomical frames in one file (HySER's
274+
// ed/ep/fd/fp), we spread the groups across a 2×2 grid so they don't
275+
// stack at the same normalised coords. Detection: the raw parser
276+
// preserves the `coordinate_system` column when present.
277+
function buildFlatMontage({ raw, meta, label, modality }) {
278+
const electrodes = raw.slice();
279+
280+
// Group-based offsets for EMG multi-frame files. Each group gets its
281+
// own sub-panel in a grid. Groups are laid out 2-across.
282+
const groupKey = (e) => e.coordinate_system || e.group || '';
283+
const groups = [...new Set(electrodes.map(groupKey).filter(k => k !== ''))];
284+
const hasGroups = groups.length > 1;
285+
const perGroupPanel = {}; // groupName -> {ox, oy} offset in normalised space
286+
if (hasGroups) {
287+
const cols = Math.ceil(Math.sqrt(groups.length));
288+
groups.forEach((g, i) => {
289+
const row = Math.floor(i / cols);
290+
const col = i % cols;
291+
// Each sub-panel fits in roughly [-0.45, 0.45]; offset centres
292+
// them on a (cols × rows) grid centered on (0, 0).
293+
const span = 1 / cols;
294+
const ox = (col - (cols - 1) / 2) * span * 2;
295+
const oy = ((cols - 1) / 2 - row) * span * 2; // row 0 on top
296+
perGroupPanel[g] = { ox, oy, span };
297+
});
298+
}
299+
300+
// Normalise each group (or the whole cloud) to [-0.45, 0.45].
301+
const normalise = (pts) => {
302+
const xs = pts.map(p => p.x), ys = pts.map(p => p.y), zs = pts.map(p => p.z);
303+
const xmin = Math.min(...xs), xmax = Math.max(...xs);
304+
const ymin = Math.min(...ys), ymax = Math.max(...ys);
305+
const zmin = Math.min(...zs), zmax = Math.max(...zs);
306+
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
307+
const span = Math.max(xmax - xmin, ymax - ymin, 1e-9);
308+
return { cx, cy, cz, span };
309+
};
310+
311+
const out = [];
312+
if (hasGroups) {
313+
for (const g of groups) {
314+
const members = electrodes.filter(e => groupKey(e) === g);
315+
const { cx, cy, cz, span } = normalise(members);
316+
const { ox, oy, span: panelSpan } = perGroupPanel[g];
317+
const scale = panelSpan * 0.9; // leave 10% margin per panel
318+
for (const e of members) {
319+
const nx = (e.x - cx) / span * scale + ox;
320+
const ny = (e.y - cy) / span * scale + oy;
321+
// In flat mode ux/uy are the final 2D coords; uz stays raw for
322+
// completeness but the renderer uses only ux/uy.
323+
out.push({
324+
name: e.name,
325+
x: +(e.x).toFixed(5), y: +(e.y).toFixed(5), z: +(e.z).toFixed(5),
326+
ux: nx, uy: ny, uz: 0,
327+
region: 'other',
328+
type: e.type || modality.toUpperCase(),
329+
material: e.material || '',
330+
group: e.group, coordinate_system: e.coordinate_system,
331+
});
332+
}
333+
}
334+
} else {
335+
const { cx, cy, cz, span } = normalise(electrodes);
336+
for (const e of electrodes) {
337+
out.push({
338+
name: e.name,
339+
x: +(e.x).toFixed(5), y: +(e.y).toFixed(5), z: +(e.z).toFixed(5),
340+
ux: (e.x - cx) / span * 0.9,
341+
uy: (e.y - cy) / span * 0.9,
342+
uz: (e.z - cz) / span * 0.9,
343+
region: 'other',
344+
type: e.type || modality.toUpperCase(),
345+
material: e.material || '',
346+
});
347+
}
348+
}
349+
350+
return {
351+
label: label || `Loaded · ${out.length}ch`,
352+
count: out.length,
353+
electrodes: out,
354+
space: meta.space,
355+
units: meta.units,
356+
// Flat layouts have no sphere geometry. Consumers that read `.sphere`
357+
// must handle null explicitly (rail stats, caption, etc.).
358+
sphere: null,
359+
inferredUnits: meta.units,
360+
declaredUnits: meta.units,
361+
unitsMismatch: false,
362+
axisTransform: null,
363+
regionStrategy: 'none',
364+
layoutStyle: 'flat',
365+
modality,
366+
groups: hasGroups ? groups : null,
367+
};
368+
}
369+
370+
// Best-effort modality inference when the caller doesn't supply it.
371+
// Inspects the coordsystem.json keys — BIDS prefixes the system/units
372+
// keys with the datatype (EEGCoordinateSystem, iEEGCoordinateSystem, etc.).
373+
function inferModalityFromMeta(meta, coordsystemJson) {
374+
if (coordsystemJson) {
375+
const obj = typeof coordsystemJson === 'string'
376+
? (() => { try { return JSON.parse(coordsystemJson); } catch { return {}; } })()
377+
: coordsystemJson;
378+
for (const prefix of ['iEEG', 'EEG', 'MEG', 'EMG', 'NIRS']) {
379+
if (obj[prefix + 'CoordinateSystem'] || obj[prefix + 'CoordinateUnits']) {
380+
return prefix.toLowerCase();
381+
}
382+
}
383+
}
384+
return null;
385+
}
386+
239387
// ---- Main entry ---------------------------------------------
240-
// Returns { label, count, electrodes, space, units, sphere }
241-
// matching the shape of MONTAGES[key].
242-
api.buildMontageFromBIDS = function ({ tsvText, coordsystemJson, label }) {
388+
// Returns { label, count, electrodes, space, units, sphere, layoutStyle,
389+
// modality } matching the shape of MONTAGES[key]. The `modality` field
390+
// drives the viewer's rendering path ('sphere' vs 'flat').
391+
api.buildMontageFromBIDS = function ({ tsvText, coordsystemJson, label, modality }) {
243392
const parsed = api.parseElectrodesTSV(tsvText);
244393
const meta = coordsystemJson
245394
? api.parseCoordsystem(coordsystemJson)
246395
: { space: 'Other', units: null, landmarks: null };
247396

248-
// Step 0: axis convention. Rotate into RAS+ if the declared space uses
249-
// ALS (EEGLAB/CTF/4D/KIT). The transform is a pure permutation + sign
250-
// flip, so it's safe to apply before sphere fitting.
397+
// Resolve modality: explicit > inferred from coordsystem.json keys > "eeg".
398+
const resolved = (
399+
(modality || '').toLowerCase() ||
400+
inferModalityFromMeta(meta, coordsystemJson) ||
401+
'eeg'
402+
);
403+
404+
// Flat pipeline for non-spherical modalities. No sphere-fit, no unit
405+
// inference, no axis rotation. The viewer renders a simple scatter.
406+
if (!SPHERE_MODALITIES.has(resolved)) {
407+
return buildFlatMontage({ raw: parsed, meta, label, modality: resolved });
408+
}
409+
410+
// Rotate into RAS+ if the declared space uses ALS (EEGLAB/CTF/4D/KIT).
411+
// Pure permutation + sign flip, safe to apply before sphere fitting.
251412
const axisXform = axisTransformForSpace(meta.space);
252413
const raw = axisXform ? parsed.map(axisXform.apply) : parsed;
253414

254-
// Step 1: fit a sphere in whatever units the TSV actually uses.
255415
const rawSphere = api.fitSphere(raw);
256416
if (!rawSphere) {
257417
throw new Error(
@@ -260,10 +420,8 @@
260420
);
261421
}
262422

263-
// Step 2: infer the scale from the fitted radius, not the metadata.
264-
// The data is the ground truth; coordsystem.json frequently lies about
265-
// units. If the raw radius is a plausible head radius in m/mm/cm we pick
266-
// the matching scale; otherwise we bail with a clear error.
423+
// Infer scale from the fitted radius, not the metadata. coordsystem.json
424+
// frequently lies about units (see ds002578 mm-vs-m).
267425
const inferred = inferMetersScaleFromRadius(rawSphere[3]);
268426
if (!inferred) {
269427
throw new Error(
@@ -273,13 +431,10 @@
273431
);
274432
}
275433

276-
// Step 3: apply the inferred scale. Everything below is meters.
277434
const s = inferred.scale;
278435
const scaled = raw.map(e => ({ ...e, x: e.x * s, y: e.y * s, z: e.z * s }));
279436
const [cx, cy, cz, R] = rawSphere.map(v => v * s);
280437

281-
// Flag a disagreement between declared and inferred units — useful for the
282-
// UI and for diagnosing datasets with bad coordsystem.json files.
283438
const declaredUnits = meta.units || null;
284439
const unitsMismatch = declaredUnits && declaredUnits !== inferred.unit;
285440

@@ -316,6 +471,8 @@
316471
unitsMismatch,
317472
axisTransform: axisXform ? axisXform.name : null,
318473
regionStrategy,
474+
layoutStyle: 'sphere',
475+
modality: resolved,
319476
};
320477
};
321478

index.html

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" />
1010
<link rel="stylesheet" href="styles.css?v=4" />
1111
<script src="montages.js?v=4"></script>
12-
<script src="bids-loader.js?v=1"></script>
13-
<script src="topo2d.js?v=5"></script>
12+
<script src="bids-loader.js?v=2"></script>
13+
<script src="topo2d.js?v=6"></script>
1414
</head>
1515
<body>
1616

@@ -441,8 +441,14 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
441441
const m = MONTAGES[state.montage];
442442
if (!m) { caption.textContent = ''; return; }
443443
const parts = [`n = ${m.count}`];
444-
const rMeters = m.sphere?.R ?? HEAD_RADIUS_M;
445-
parts.push(`r = ${(rMeters * 1000).toFixed(0)} mm`);
444+
// Sphere layouts get a real radius. Flat layouts get the modality tag
445+
// instead — the fitted-sphere radius isn't meaningful for body-space
446+
// or brain-space data.
447+
if (m.sphere) {
448+
parts.push(`r = ${(m.sphere.R * 1000).toFixed(0)} mm`);
449+
} else if (m.modality) {
450+
parts.push(m.modality);
451+
}
446452
if (m.space) parts.push(m.space);
447453
caption.textContent = '';
448454
parts.forEach((p, i) => {
@@ -533,7 +539,22 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
533539
}
534540

535541
function updateStats() {
542+
const m = MONTAGES[state.montage];
536543
const els = currentElectrodes();
544+
// "Mean radius" and "Hemisphere" only make sense for spherical scalp
545+
// layouts. Flat layouts (iEEG/EMG/fNIRS) show a body-space bounding-box
546+
// span instead and skip the hemisphere split entirely.
547+
if (m && m.layoutStyle === 'flat') {
548+
const xs = els.map(e => e.x), ys = els.map(e => e.y), zs = els.map(e => e.z);
549+
const span = Math.max(
550+
Math.max(...xs) - Math.min(...xs),
551+
Math.max(...ys) - Math.min(...ys),
552+
Math.max(...zs) - Math.min(...zs),
553+
);
554+
document.getElementById('stat-r').textContent = `span ${span.toFixed(3)} ${m.units || ''}`.trim();
555+
document.getElementById('stat-hemi').textContent = '—';
556+
return;
557+
}
537558
const r = els.reduce((s,e) => s + Math.hypot(e.x,e.y,e.z), 0) / els.length;
538559
document.getElementById('stat-r').textContent = (r*100).toFixed(1) + ' cm';
539560
const left = els.filter(e => e.x < -0.001).length;

test-data/evidence-emg-hyser.png

60.8 KB
Loading

0 commit comments

Comments
 (0)