Skip to content

Add cortex.webgl.show_multi: multi-brain viewer with yoke toggle#622

Draft
mvdoc wants to merge 2 commits into
gallantlab:mainfrom
mvdoc:claude/xenodochial-ardinghelli-4f32e2
Draft

Add cortex.webgl.show_multi: multi-brain viewer with yoke toggle#622
mvdoc wants to merge 2 commits into
gallantlab:mainfrom
mvdoc:claude/xenodochial-ardinghelli-4f32e2

Conversation

@mvdoc
Copy link
Copy Markdown
Contributor

@mvdoc mvdoc commented May 7, 2026

Summary

Adds a new entry point cortex.webgl.show_multi(views, layout=(rows, cols), yoke=False, ...) that hosts N independent mriview.Viewer instances side-by-side in a jsplot.GridFigure, with a JS-side controller that synchronizes camera + surface mix across panels via a toggle button. The existing show() is untouched.

This is a draft — see Known limitations below.

What's new

  • cortex.webgl.show_multi(views, layout=(r, c), yoke=False, ...) — accepts a list of Volume / Vertex / Dataview items, one per panel; supports per-viewer different data and different subjects.
  • New template cortex/webgl/multi.html.
  • New JS module cortex/webgl/resources/js/multiview.jsYokeController plus the toggle button helper.

Per-viewer isolation

The single-viewer pipeline assumed exactly one Viewer/Surface/Renderer per page. To make N independent viewers coexist on one page:

  • Python side (view.py): build one Package per panel, namespace data layer names with panel{i}__ so /data/{name} stays globally unique, union subjects across panels into a single ctms blob served once + browser-cached.
  • DOM IDs: each viewer stamps mriview_html with a per-instance prefix (p0_, p1_, …). Internal lookups go through a scoped viewer.\$id(name) helper, and CSS rules switched to attribute-suffix selectors ([id\$='X']) so they match both prefixed and unprefixed IDs (single-viewer mode keeps prefix '').
  • Three.js objects: each Viewer owns its own Surface per subject (Three.js objects can have only one parent — a shared Surface gets reparented out of the first viewer's scene). SurfDelegate.update prefers viewer.subjects over the global subjects dict; CTM data is still shared via HTTP cache.
  • Colormaps: each Viewer owns its own colormap textures (Three.js textures bind to the renderer that first uploads them, sharing across renderers triggers "object does not belong to this context"). window.colormaps is re-aliased to the active viewer's dict at addData / setData entry points so legacy unscoped lookups (dataset.js etc.) still resolve.

Yoke

mriview.YokeController listens for \"change\" on each viewer's LandscapeControls (fired only on user input). When ON, it broadcasts {azimuth, altitude, radius, target, mix} to the others, applies setMix first (because setMix recomputes az/alt/target from the per-viewer _foldedazimuth/_flatazimuth interpolation) and then overrides camera state with the source's exact values. A _propagating guard breaks the feedback loop. The button is appended to the GridFigure chrome and flips yoke.enabled.

Bugs uncovered + fixed along the way

  • Surface.apply silently no-ops in multi-viewer mode: the SVG overlay's \"update\" handler calls this.loaded.resolve() more than once per Surface; in multi-viewer that leaves jQuery's resolve-Callbacks memory in a state where new .done(fn) adds become no-ops while state() still reports \"resolved\". Net effect: the per-frame apply() from drawView never swapped the mesh material to the data ShaderMaterial, so meshes stayed on the default MeshBasicMaterial and the brains rendered as flat solid colors. Fix: run synchronously when already resolved.
  • sliceplane.js: bare viewer global ref (line 14) broke multi-viewer construction; replaced with this.viewer.
  • mriview.js setData: the dataset-loaded \$.when(...) defer loop indexed subjects[subj], which is the empty global registry in multi mode; now uses the per-viewer viewer.subjects registry.

Tests

  • test_show_multi_smoke — 2 viewers, 2 canvases, no JS pageerrors.
  • test_show_multi_yoke_lockstep — yoke ON: dispatching change on viewer 0 syncs azimuth + mix on viewer 1.
  • test_show_multi_unyoked_independent — yoke OFF, panels stay independent.
  • All existing single-viewer datatype tests (Volume, Vertex, VolumeRGB, VertexRGB, Volume2D, Vertex2D) continue to pass — the ID-scoping + apply changes do not regress single-viewer mode.

Known limitations / draft scope

  • Picker is disabled in multi-viewer mode (per-Surface pick() stubbed to no-op in multi.html). The picker shares the Viewer's WebGLRenderer to draw vertex IDs into offscreen render targets; in multi-viewer the renderer's cached render-target / texture-unit state gets corrupted on the way back, so the next regular render samples the picker's encoding targets and shows a high-frequency speckled brain. Diagnosing this further is the most likely next pull.
  • Cross-subject click-to-vertex translation is out of scope.
  • Per-viewer dropdown / colormap / vmin-vmax sync, slice plane state, movie frame index, leap motion, oculus are not synchronized by the yoke.
  • Static export of multi-viewer pages via htmlembed.py is untouched (single-viewer export still works).

Test plan

  • Verify the three new tests pass on CI.
  • Re-run the existing single-viewer datatype tests on CI (regression net).
  • Manual check: 2-panel show_multi on S1, yoke OFF → panels rotate independently.
  • Manual check: yoke ON → camera + fold/flatten mix sync across panels.
  • Manual check: 2x2 show_multi with 4 distinct datasets on S1.
  • (After picker fix) re-enable picker and confirm click does not corrupt the canvas.

Introduces a new entry point that hosts N independent mriview.Viewer
instances in a jsplot.GridFigure and adds a JS YokeController to
synchronize camera + surface mix across panels via a toggle button.

API:
- cortex.webgl.show_multi(views, layout=(rows, cols), yoke=False, ...)
- New multi.html template, new multiview.js (YokeController + UI).
- show() left untouched.

Per-viewer isolation:
- view.py builds one Package per panel, prefixes data layer names with
  "panel{i}__" so the global /data/{name} route stays unique, and unions
  subjects into a single ctms dict served once per page.
- Each Viewer owns its own Surface per subject (Three.js objects can
  only have one parent, so a shared Surface would get reparented out of
  the first viewer's scene). SurfDelegate prefers viewer.subjects.
- Each Viewer owns its own colormaps registry; window.colormaps is
  re-aliased to the active viewer's dict at addData / setData.
- DOM IDs are stamped per-instance via an idPrefix and looked up via
  viewer.$id(name); CSS rules switch to attribute-suffix selectors
  ([id$='X']) so they match prefixed and unprefixed IDs.

Bug fixes uncovered along the way:
- mriview.Surface.apply: SVG overlay's "update" handler resolves
  this.loaded more than once per Surface; in multi-viewer that left
  jQuery's resolve-Callbacks memory in a state where new .done() adds
  silently became no-ops while state() still reported "resolved", so
  per-frame apply() couldn't swap meshes from MeshBasicMaterial to the
  data ShaderMaterial. Run synchronously when already resolved.
- sliceplane.js: replace bare "viewer" global ref with this.viewer.
- mriview.js setData: use per-viewer subjects registry when computing
  the dataset.loaded defers (was indexing the empty global subjects).

Picker is disabled in multi-viewer mode (per-Surface pick stubbed in
multi.html) — the picker shares the viewer's WebGLRenderer to draw
vertex IDs into offscreen render targets, but in multi-viewer the
renderer's cached render-target/texture-unit state gets corrupted on
the way back, so the next regular render samples the picker's encoding
targets and shows a high-frequency speckled brain. Out of scope for
MVP; see plan.

Tests:
- test_show_multi_smoke: 2 viewers, 2 canvases, no JS errors.
- test_show_multi_yoke_lockstep: yoke ON syncs azimuth + mix across
  panels.
- test_show_multi_unyoked_independent: yoke OFF, panels independent.
- All existing single-viewer datatype tests continue to pass.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a multi-viewer feature to the WebGL brain viewer, allowing multiple independent viewers to be displayed side-by-side with optional camera and surface-mix synchronization. The implementation includes a new show_multi Python function, a multi.html template, and significant JavaScript refactoring to support scoped DOM lookups and independent texture registries via ID prefixing. Review feedback points out isolation issues in setColorOptions due to global jQuery selectors, potential race conditions caused by reassigning the global window.colormaps object, and the use of the deprecated global event object in an event handler.

$('#colorlegend').removeClass('colorlegend-3d')
$cl.removeClass('colorlegend-2d')
$cl.removeClass('colorlegend-3d')
setColorOptions(get1dColormaps())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The setColorOptions function (called here and at line 413) uses global jQuery selectors like $('.colorlegend-select'). In a multi-viewer setup, this will cause all viewers to update the dropdown of the first viewer on the page, breaking isolation. setColorOptions should be refactored to use scoped selectors (e.g., $(viewer.object).find('.colorlegend-select')).

// (dataset.js, figure.js select2) hit our textures during this viewer's
// data-loading and UI handlers. Each viewer re-aliases at addData and
// any UI-driven setColormap entry-point (see _activateColormaps).
window.colormaps = this.colormaps;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reassigning the global window.colormaps to a per-viewer dictionary is a side effect that can lead to race conditions or unexpected behavior, especially if multiple viewers are active or if other components rely on a stable global registry. While the PR description mentions this is for legacy support, it makes the global state fragile. Consider refactoring dependent code to accept a colormap registry explicitly instead of relying on a mutable global.

Comment thread cortex/webgl/resources/js/mriview.js Outdated
$text.on('click', enterFunction);
$input.off();
$input.on('blur', leaveFunction);
$input.on('keyup', function (e) { if (event.keyCode === 13) leaveFunction() });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The event handler uses the deprecated global event object instead of the passed argument e. This can lead to issues in environments where window.event is not available or behaves differently.

Suggested change
$input.on('keyup', function (e) { if (event.keyCode === 13) leaveFunction() });
$input.on('keyup', function (e) { if (e.keyCode === 13) leaveFunction() });

@mvdoc mvdoc self-assigned this May 7, 2026
…ution write,

fix typo'd `event` global

- get1dColormaps / get2dColormaps now read from `viewer.colormaps`
  directly instead of the re-aliased `window.colormaps` shim — when we
  have a viewer in scope, prefer the explicit per-instance registry.
- setColorOptions scopes its option-add/remove to
  `$(viewer.object).find('.colorlegend-select')`. The previous
  unscoped query targeted the FIRST .colorlegend-select in the
  document, so a 1d/2d swap on viewer 1 reached into viewer 0's
  dropdown.
- Resolve the colorbar img src via `viewer.colormaps[cmapName]`
  instead of the bare-global lookup, same reasoning.
- Drop the `colormaps[pid] = tex` write in the Surface ctor's cmap
  catalog loop. Nothing reads colormaps by prefixed id; the write
  lands in whichever dict `window.colormaps` happens to point at,
  polluting one viewer's registry with the other's THREE.Texture
  instances. Per-viewer `viewerCmaps[name] = tex` already covers the
  legitimate use case.
- Document the `window.colormaps` re-aliasing more thoroughly so the
  legacy-bridge intent is explicit.
- Fix `event.keyCode` → `e.keyCode` (deprecated global event ref) in
  the colorbar input keyup handler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant