Skip to content

Commit 331e910

Browse files
feat(viewer): resolve ?montage=<hash> against the registry
Extracts buildMontageFromSensors from the TSV path so both the BIDS drag-drop loader and the new registry loader share sphere-fit, unit inference, and region assignment. - bids-loader.js: new buildMontageFromSensors (refactor of the post-parse half of buildMontageFromBIDS) + buildMontageFromRegistryDoc which consumes either the wrapped {database, data: doc} response or the bare doc. - index.html: auto-load IIFE branches on qMontage first, fetches {api}/api/{db}/montages/{hash} via the new loadMontageFromRegistry helper, and builds the viewer montage directly (skipping the TSV round-trip). ?api= and ?db= pin the base URL and database name for dev/staging. Cache-bust: bids-loader.js?v=3. - test-data/mock-api/: refreshed fixtures to match the real registry response shape ({database, data: {hash, modality, n_sensors, sensors, space_declared, units_declared}}); README rewritten.
1 parent 07eec2f commit 331e910

5 files changed

Lines changed: 2090 additions & 66 deletions

File tree

bids-loader.js

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -384,33 +384,28 @@
384384
return null;
385385
}
386386

387-
// ---- Main entry ---------------------------------------------
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 }) {
392-
const parsed = api.parseElectrodesTSV(tsvText);
393-
const meta = coordsystemJson
394-
? api.parseCoordsystem(coordsystemJson)
395-
: { space: 'Other', units: null, landmarks: null };
396-
397-
// Resolve modality: explicit > inferred from coordsystem.json keys > "eeg".
387+
// ---- Core sensor → montage pipeline -------------------------
388+
// Used by both the TSV path (parse first) and the registry path (sensors
389+
// already parsed server-side). Input is an array of ``{name, x, y, z,
390+
// type?, material?}`` rows plus a ``meta = {space, units}`` object and
391+
// the caller's chosen modality (explicit or resolved).
392+
api.buildMontageFromSensors = function ({ sensors, meta, label, modality }) {
398393
const resolved = (
399394
(modality || '').toLowerCase() ||
400-
inferModalityFromMeta(meta, coordsystemJson) ||
395+
inferModalityFromMeta(meta, null) ||
401396
'eeg'
402397
);
403398

404399
// Flat pipeline for non-spherical modalities. No sphere-fit, no unit
405400
// inference, no axis rotation. The viewer renders a simple scatter.
406401
if (!SPHERE_MODALITIES.has(resolved)) {
407-
return buildFlatMontage({ raw: parsed, meta, label, modality: resolved });
402+
return buildFlatMontage({ raw: sensors, meta, label, modality: resolved });
408403
}
409404

410405
// Rotate into RAS+ if the declared space uses ALS (EEGLAB/CTF/4D/KIT).
411406
// Pure permutation + sign flip, safe to apply before sphere fitting.
412407
const axisXform = axisTransformForSpace(meta.space);
413-
const raw = axisXform ? parsed.map(axisXform.apply) : parsed;
408+
const raw = axisXform ? sensors.map(axisXform.apply) : sensors;
414409

415410
const rawSphere = api.fitSphere(raw);
416411
if (!rawSphere) {
@@ -476,5 +471,68 @@
476471
};
477472
};
478473

474+
// ---- Main entry (TSV + coordsystem pipeline) ----------------
475+
// Returns { label, count, electrodes, space, units, sphere, layoutStyle,
476+
// modality } matching the shape of MONTAGES[key]. The `modality` field
477+
// drives the viewer's rendering path ('sphere' vs 'flat').
478+
api.buildMontageFromBIDS = function ({ tsvText, coordsystemJson, label, modality }) {
479+
const parsed = api.parseElectrodesTSV(tsvText);
480+
const meta = coordsystemJson
481+
? api.parseCoordsystem(coordsystemJson)
482+
: { space: 'Other', units: null, landmarks: null };
483+
484+
// Let inferModalityFromMeta see the raw coordsystem.json keys — they
485+
// carry the only modality hint outside of the URL.
486+
const resolved = (
487+
(modality || '').toLowerCase() ||
488+
inferModalityFromMeta(meta, coordsystemJson) ||
489+
'eeg'
490+
);
491+
492+
return api.buildMontageFromSensors({
493+
sensors: parsed,
494+
meta,
495+
label,
496+
modality: resolved,
497+
});
498+
};
499+
500+
// ---- Registry pipeline (GET /api/{db}/montages/{hash}) -------
501+
// The API returns ``{database, data: <montage doc>}``; pass either the
502+
// wrapper or the inner doc here. Registry docs ship sensors already
503+
// parsed plus declared space/units, so we bypass TSV parsing entirely.
504+
api.buildMontageFromRegistryDoc = function (docOrResponse, { label } = {}) {
505+
const doc = (docOrResponse && docOrResponse.data) ? docOrResponse.data : docOrResponse;
506+
if (!doc || !Array.isArray(doc.sensors) || doc.sensors.length < 4) {
507+
throw new Error('registry doc missing sensors array (need at least 4)');
508+
}
509+
// Normalize sensor rows. The digest pipeline writes `{name, x, y, z}`
510+
// plus optional `type`/`material`; coerce to numbers defensively.
511+
const sensors = doc.sensors
512+
.map(s => ({
513+
name: String(s.name || '').trim(),
514+
x: +s.x, y: +s.y, z: +s.z,
515+
type: s.type || '',
516+
material: s.material || '',
517+
}))
518+
.filter(s => s.name && isFinite(s.x) && isFinite(s.y) && isFinite(s.z));
519+
if (sensors.length < 4) {
520+
throw new Error('registry doc has fewer than 4 electrodes with finite coordinates');
521+
}
522+
const meta = {
523+
space: doc.space_declared || 'Other',
524+
units: (doc.units_declared || '').toLowerCase() || null,
525+
landmarks: null,
526+
};
527+
const hashTag = doc.hash ? ` · ${String(doc.hash).slice(0, 8)}` : '';
528+
const fallbackLabel = `Registry${hashTag} · ${sensors.length}ch`;
529+
return api.buildMontageFromSensors({
530+
sensors,
531+
meta,
532+
label: label || fallbackLabel,
533+
modality: doc.modality,
534+
});
535+
};
536+
479537
window.BIDSLoader = api;
480538
})();

index.html

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
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=6"></script>
12-
<script src="bids-loader.js?v=2"></script>
12+
<script src="bids-loader.js?v=3"></script>
1313
<script src="topo2d.js?v=6"></script>
1414
</head>
1515
<body>
@@ -731,10 +731,12 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
731731
732732
Three URL shapes, in priority order:
733733
734-
?montage=<registry_id>
735-
Fetches data.eegdash.org/api/eegdash/montages/<id>, resolves the
736-
TSV and coordsystem URLs from the manifest, then loads. Primary
737-
shape used by the iframe inside eegdash docs pages.
734+
?montage=<hash> (optional ?api=<base>&db=<name> overrides)
735+
GET <base>/api/<db>/montages/<hash> → registry doc with the
736+
sensor coordinates already parsed server-side. Skips the TSV
737+
round-trip. Primary shape used by the iframe inside eegdash
738+
docs pages. ``?api=`` / ``?db=`` exist for pointing at a dev
739+
registry without rebuilding the viewer.
738740
739741
?tsv=<url>&coords=<url>
740742
Direct fetch of two files. ?coords= is optional. Useful for
@@ -765,6 +767,7 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
765767
const qCoords = params.get('coords');
766768
const qDemo = params.get('demo');
767769
const qApi = params.get('api');
770+
const qDb = params.get('db');
768771

769772
if (!qMontage && !qTsv && !qDemo) return;
770773

@@ -773,7 +776,25 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
773776
return;
774777
}
775778

776-
const sources = await resolveAutoloadSources({ qMontage, qTsv, qCoords, qDemo, qApi })
779+
// Registry path: montage doc already carries sensor coordinates, so we
780+
// skip the TSV round-trip and hand the doc straight to the sensor
781+
// pipeline. This is the shape the eegdash docs iframe uses.
782+
if (qMontage) {
783+
try {
784+
const montage = await loadMontageFromRegistry({ hash: qMontage, apiBase: qApi, database: qDb });
785+
MONTAGES[LOADED_KEY] = montage;
786+
upsertLoadedMontageButton(montage);
787+
setMontage(LOADED_KEY);
788+
} catch (err) {
789+
console.error('[auto-load] registry fetch failed:', { hash: qMontage, err });
790+
showLoadError(`Could not load montage: ${err.message}`);
791+
}
792+
return;
793+
}
794+
795+
// Raw-file paths (?tsv= / ?demo=): fetch the TSV + optional coordsystem
796+
// and feed them through the existing drag-drop pipeline.
797+
const sources = await resolveAutoloadSources({ qTsv, qCoords, qDemo })
777798
.catch(err => { showLoadError(err.message); return null; });
778799
if (!sources) return;
779800

@@ -786,19 +807,29 @@ <h3 class="rail-title"><span>Quick actions</span></h3>
786807
}
787808
})();
788809

789-
/* Resolve query params to concrete URLs and a display label.
790-
Returns { tsvUrl, coordsUrl | null, label }. */
791-
async function resolveAutoloadSources({ qMontage, qTsv, qCoords, qDemo, qApi }) {
792-
if (qMontage) {
793-
// Registry lookup. Base URL can be overridden with ?api=… for testing.
794-
const apiBase = (qApi || 'https://data.eegdash.org').replace(/\/$/, '');
795-
const regUrl = `${apiBase}/api/eegdash/montages/${encodeURIComponent(qMontage)}`;
796-
const r = await fetchWithTimeout(regUrl);
797-
if (!r.ok) throw new Error(`registry ${regUrl}${r.status}`);
798-
const doc = await r.json();
799-
if (!doc.tsv_url) throw new Error('registry doc missing tsv_url');
800-
return { tsvUrl: doc.tsv_url, coordsUrl: doc.coords_url || null, label: doc.label || doc.id || qMontage };
810+
/* Fetch a registry montage doc and build an in-viewer montage. Rejects
811+
with a user-readable Error on 404/timeout/malformed response. */
812+
async function loadMontageFromRegistry({ hash, apiBase, database }) {
813+
const base = (apiBase || 'https://data.eegdash.org').replace(/\/+$/, '');
814+
const db = (database || 'eegdash').replace(/[^a-z0-9_]/gi, '');
815+
const regUrl = `${base}/api/${db}/montages/${encodeURIComponent(hash)}`;
816+
const r = await fetchWithTimeout(regUrl);
817+
if (r.status === 404) {
818+
throw new Error(`no registry entry for montage ${hash}`);
801819
}
820+
if (!r.ok) {
821+
throw new Error(`registry ${regUrl}${r.status}`);
822+
}
823+
const body = await r.json().catch(() => null);
824+
if (!body) throw new Error('registry returned non-JSON response');
825+
return BIDSLoader.buildMontageFromRegistryDoc(body, {
826+
label: `Registry · ${String(hash).slice(0, 8)}`,
827+
});
828+
}
829+
830+
/* Resolve direct-URL query params (?tsv/?demo) to concrete URLs.
831+
Returns { tsvUrl, coordsUrl | null, label }. */
832+
async function resolveAutoloadSources({ qTsv, qCoords, qDemo }) {
802833
if (qTsv) {
803834
// Trust the caller. Accept any scheme so tests can point at localhost.
804835
return { tsvUrl: qTsv, coordsUrl: qCoords || null, label: extractLabelFromUrl(qTsv) };

test-data/mock-api/README.md

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,55 @@
11
# Mock eegdash registry API
22

3-
Used only for local testing of the `?montage=<id>` URL shape before the
4-
real backend endpoint (`https://data.eegdash.org/api/eegdash/montages/<id>`)
5-
is deployed.
3+
Used only for local testing of the `?montage=<hash>` URL shape. The real
4+
backend lives at `https://data.eegdash.org/api/eegdash/montages/<hash>`
5+
and returns the same schema.
66

77
## Serving locally
88

99
The standard `python3 -m http.server` in the parent `electrode-explorer/`
1010
folder serves these files at
11-
`http://localhost:9876/test-data/mock-api/api/eegdash/montages/<id>`.
11+
`http://localhost:9876/test-data/mock-api/api/eegdash/montages/<hash>`.
1212

1313
Point the app at this mock with `?api=<base>`, for example:
1414

1515
```
16-
http://localhost:9876/index.html?montage=biosemi-256-eeglab-v1&api=http%3A%2F%2Flocalhost%3A9876%2Ftest-data%2Fmock-api
16+
http://localhost:9876/index.html?montage=a1b2c3d4e5f60718&api=http%3A%2F%2Flocalhost%3A9876%2Ftest-data%2Fmock-api
1717
```
1818

19+
Available mock hashes: `a1b2c3d4e5f60718` (17-channel 10-20 subset),
20+
`biosemi-256-eeglab-v1` (256-channel BioSemi cap).
21+
1922
## File naming
2023

21-
Mock response files have **no extension** (`biosemi-256-eeglab-v1`, not
22-
`.json`) because the real API path is
23-
`/api/eegdash/montages/biosemi-256-eeglab-v1`. Python's `http.server`
24+
Mock response files have **no extension** — the real API path is
25+
`/api/eegdash/montages/<hash>` without a suffix. Python's `http.server`
2426
serves them as `application/octet-stream`, which the browser still parses
2527
via `response.json()` without complaint.
2628

2729
## Schema
2830

29-
Each file is a JSON document with the fields below. See `PLAN.md` Step 3
30-
for the canonical schema.
31+
Each file matches the `MontageResponse` shape returned by the real
32+
backend (`mongodb-eegdash-server/api/main.py::get_montage`):
3133

3234
```json
3335
{
34-
"id": "biosemi-256-eeglab-v1",
35-
"hash": "<sha1-of-sorted-names-and-rounded-coords>",
36-
"n_channels": 256,
37-
"space_declared": "CTF | CapTrak | EEGLAB | MNI | …",
38-
"units_declared": "m | cm | mm",
39-
"label": "BioSemi 256 (EEGLAB space)",
40-
"representative_dataset": "ds002578",
41-
"representative_subject": "sub-002",
42-
"tsv_url": "https://…/electrodes.tsv",
43-
"coords_url": "https://…/coordsystem.json",
44-
"first_seen_at": "2026-04-21T00:00:00Z"
36+
"database": "eegdash",
37+
"data": {
38+
"hash": "<16-char sha1 prefix>",
39+
"modality": "eeg | ieeg | meg | nirs",
40+
"n_sensors": 17,
41+
"space_declared": "CapTrak | EEGLAB | MNI | …",
42+
"units_declared": "m | cm | mm",
43+
"sensors": [
44+
{"name": "Fp1", "x": -29.44, "y": 83.92, "z": -6.99, "type": "EEG"},
45+
46+
],
47+
"first_seen": "2026-04-21T00:00:00Z",
48+
"representative_dataset": "ds…",
49+
"representative_subject": "sub-…"
50+
}
4551
}
4652
```
4753

48-
`tsv_url` is required; `coords_url` is optional.
54+
The viewer's `BIDSLoader.buildMontageFromRegistryDoc` consumes this
55+
directly — no TSV round-trip needed.

0 commit comments

Comments
 (0)