diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index f998ba73c..b4c357c6b 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -285,3 +285,136 @@ def test_addData_no_crash(): time.sleep(2) pageerrors = [e for e in handle._pw_thread.browser_errors if "[pageerror]" in e] assert len(pageerrors) == 0, f"JS errors after addData: {pageerrors}" + + +# --------------------------------------------------------------------------- +# Group 8: show_multi (multi-viewer page) +# --------------------------------------------------------------------------- + + +def _free_port(): + import socket + + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +class _MultiViewerHandle: + """Spin up a show_multi server + Playwright page; clean up on exit.""" + + def __init__(self, views, layout, yoke): + self.views = views + self.layout = layout + self.yoke = yoke + self.errors = [] + + def __enter__(self): + from playwright.sync_api import sync_playwright + + port = _free_port() + # autoclose=False so the server outlives the client disconnect when + # Playwright reloads or navigates away during the test. + self.server = cortex.webgl.show_multi( + self.views, + layout=self.layout, + yoke=self.yoke, + port=port, + open_browser=False, + autoclose=False, + display_url=False, + title="pytest multi viewer", + ) + # Give the surface CTM endpoints a moment to come up. + time.sleep(2) + self._pw = sync_playwright().start() + # swiftshader gives us a software WebGL context that survives multiple + # contexts on a single headless process. + self._browser = self._pw.chromium.launch( + headless=True, args=["--no-sandbox", "--use-gl=swiftshader"] + ) + self.page = self._browser.new_page(viewport={"width": 1200, "height": 700}) + self.page.on("pageerror", lambda e: self.errors.append(str(e))) + self.page.goto( + "http://localhost:%d/" % port, + wait_until="networkidle", + timeout=45000, + ) + # Wait for the JS-side viewers array to populate and CTMs to load. + self.page.wait_for_function( + "() => window.viewers && window.viewers.length === %d" % len(self.views), + timeout=30000, + ) + time.sleep(4) + return self + + def __exit__(self, *exc): + try: + self._browser.close() + finally: + self._pw.stop() + self.server.stop() + + +def test_show_multi_smoke(): + """show_multi(2 vols) renders two viewers, two canvases, no JS errors.""" + np.random.seed(0) + v1 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + v2 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with _MultiViewerHandle([v1, v2], layout=(1, 2), yoke=False) as h: + canvases = h.page.evaluate("document.querySelectorAll('canvas').length") + n_viewers = h.page.evaluate("window.viewers.length") + assert n_viewers == 2 + assert canvases >= 2 # at least one per viewer + # No uncaught JS errors during construction. + assert h.errors == [], f"JS pageerrors: {h.errors}" + + +def test_show_multi_yoke_lockstep(): + """With yoke ON, dispatching change on viewer 0 syncs viewer 1 azimuth + mix.""" + np.random.seed(0) + v1 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + v2 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with _MultiViewerHandle([v1, v2], layout=(1, 2), yoke=True) as h: + result = h.page.evaluate( + """() => { + window.viewers[1].controls.setAzimuth(45); + window.viewers[0].controls.setAzimuth(200); + window.viewers[0].controls.dispatchEvent({type:'change'}); + window.viewers[0].controls.setMix(0.5); + window.viewers[0].controls.dispatchEvent({type:'change'}); + return { + az0: window.viewers[0].controls.azimuth, + az1: window.viewers[1].controls.azimuth, + mix0: window.viewers[0].controls.mix, + mix1: window.viewers[1].controls.mix, + }; + }""" + ) + assert result["az0"] == result["az1"] + assert result["mix0"] == result["mix1"] + assert h.errors == [], f"JS pageerrors: {h.errors}" + + +def test_show_multi_unyoked_independent(): + """With yoke OFF, change on viewer 0 does not affect viewer 1.""" + np.random.seed(0) + v1 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + v2 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with _MultiViewerHandle([v1, v2], layout=(1, 2), yoke=False) as h: + result = h.page.evaluate( + """() => { + window.viewers[1].controls.setAzimuth(45); + window.viewers[0].controls.setAzimuth(200); + window.viewers[0].controls.dispatchEvent({type:'change'}); + return { + az0: window.viewers[0].controls.azimuth, + az1: window.viewers[1].controls.azimuth, + }; + }""" + ) + assert result["az0"] == 200 + assert result["az1"] == 45 + assert h.errors == [], f"JS pageerrors: {h.errors}" diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index b31971b5e..b1e9b9f0f 100644 --- a/cortex/webgl/__init__.py +++ b/cortex/webgl/__init__.py @@ -1,15 +1,18 @@ -"""Makes an interactive viewer for viewing data in a browser -""" +"""Makes an interactive viewer for viewing data in a browser""" + from typing import TYPE_CHECKING from ..utils import DocLoader if TYPE_CHECKING: from cortex.webgl.view import show as _show + from cortex.webgl.view import show_multi as _show_multi from cortex.webgl.view import make_static as _static else: _show = None + _show_multi = None _static = None show = DocLoader("show", ".view", "cortex.webgl", actual_func=_show) +show_multi = DocLoader("show_multi", ".view", "cortex.webgl", actual_func=_show_multi) make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static) diff --git a/cortex/webgl/multi.html b/cortex/webgl/multi.html new file mode 100644 index 000000000..e97387e73 --- /dev/null +++ b/cortex/webgl/multi.html @@ -0,0 +1,62 @@ +{% autoescape None %} +{% extends template.html %} +{% block jsinit %} + var viewers = [], figure, subjects, sock, viewopts, yokeController; +{% end %} +{% block onload %} + viewopts = {{viewopts}}; + // Raw per-subject CTM info keyed by subject name. Keep the unwrapped + // info around so each viewer can build its own Surface (Three.js + // objects can have only one parent — a shared Surface would get + // reparented out of the first viewer's scene when added to the second). + var subjectInfo = {{subjects}}; + subjects = {}; + + var panels = {{panels_json}}; + var nrows = {{nrows}}; + var ncols = {{ncols}}; + + figure = new jsplot.GridFigure(undefined, nrows, ncols); + + for (var i = 0; i < panels.length; i++) { + try { + console.log("[multi] constructing viewer", i); + var v = figure.add(mriview.Viewer, i, {idPrefix: 'p' + i + '_'}); + // Per-viewer Surface registry; one Surface instance per subject + // *for this viewer*. SurfDelegate.update prefers viewer.subjects + // over the global `subjects` dict. + v.subjects = {}; + for (var sname in subjectInfo) { + var sur = new mriview.Surface(subjectInfo[sname]); + // Disable per-Surface picking in multi-viewer mode. 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 from the picker's encoding targets and + // shows a high-frequency speckled brain. Out of scope for + // MVP (see plan). + sur.pick = function () { return null; }; + v.subjects[sname] = sur; + } + console.log("[multi] viewer constructed, calling addData", i); + v.addData(dataset.fromJSON(panels[i].data)); + console.log("[multi] addData done", i); + viewers.push(v); + } catch (e) { + console.error("[multi] viewer", i, "failed:", e.message, e.stack); + throw e; + } + } + // Expose for tests/debug. + window.viewers = viewers; + window.figure = figure; + + // Yoke controller + toggle button. The Python side passes the initial + // ON/OFF state via the `yoke` template var. + yokeController = new multiview.YokeController(viewers); + multiview.attachYokeToggle(figure.object, yokeController, {{yoke}}); + window.yokeController = yokeController; + + sock = new Websock(); +{% end %} diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index f365dc8bd..2433fdb77 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -46,7 +46,7 @@ table { -webkit-transition-delay:.1s; } -#main { +#main, [id$='_main'] { margin:0px; min-height:100%; height: auto !important; @@ -76,7 +76,10 @@ a:visited { color:white; } /************************************************* * WebGL Canvas *************************************************/ -#brain { +#brain, /************************************************* + * WebGL Canvas + *************************************************/ +[id$='_brain'] { transition: opacity .5s ease; -moz-transition: opacity .5s ease; -webkit-transition:opacity .5s ease; @@ -89,13 +92,16 @@ a:visited { color:white; } /************************************************* * Loading messages *************************************************/ -#dataload { +#dataload, /************************************************* + * Loading messages + *************************************************/ +[id$='_dataload'] { font-size:150%; } -#dataload img { +#dataload img, [id$='_dataload'] img { height:32px; } -#ctmload { +#ctmload, [id$='_ctmload'] { background:none; color:#AAA; width:400px; @@ -108,7 +114,7 @@ a:visited { color:white; } z-index:2; } -#dataopts { +#dataopts, [id$='_dataopts'] { position:absolute; z-index:8; float:left; @@ -131,18 +137,19 @@ a:visited { color:white; } -webkit-transition-timing-function: ease; -webkit-transition-delay:1s; } -#dataopts:hover, #dataopts:active { +#dataopts:hover, [id$='_dataopts']:hover, +#dataopts:active, [id$='_dataopts']:active { transition-delay:.1s; -moz-transition-delay:.1s; -webkit-transition-delay:.1s; } -#dataname { +#dataname, [id$='_dataname'] { color:white; text-shadow:0px 2px 8px black, 0px 1px 8px black; font-size:24pt; font-weight:bold; } -#helpmenu { +#helpmenu, [id$='_helpmenu'] { position:absolute; z-index:8; top: 50%; @@ -169,7 +176,9 @@ a:visited { color:white; } /* mouseover data styles */ -#picked_value { +#picked_value, /* mouseover data styles */ + +[id$='_picked_value'] { position: absolute; bottom: 5vmin; left:10px; @@ -180,7 +189,7 @@ a:visited { color:white; } vertical-align:bottom; } -#mouseover_value { +#mouseover_value, [id$='_mouseover_value'] { position: absolute; bottom: 1.5vmin; left:10px; @@ -194,7 +203,10 @@ a:visited { color:white; } /************************************************* * Colorlegend styles *************************************************/ -#colorlegend { +#colorlegend, /************************************************* + * Colorlegend styles + *************************************************/ +[id$='_colorlegend'] { width:30vw; position:absolute; bottom:30px; @@ -206,83 +218,93 @@ a:visited { color:white; } .select2-dropdown { width: 30vw!important; } -#colorlegend-colors { +#colorlegend-colors, [id$='_colorlegend-colors'] { height:100%; overflow:hidden; } -#colorlegend-colorbar { +#colorlegend-colorbar, [id$='_colorlegend-colorbar'] { height: 4vh; width:100%; display:block; text-align:right; } -#vlims-2d { +#vlims-2d, [id$='_vlims-2d'] { display: none; } /* 3D version */ -#colorlegend.colorlegend-3d #vlims-1d { +#colorlegend.colorlegend-3d #vlims-1d, /* 3D version */ +[id$='_colorlegend'].colorlegend-3d [id$='_vlims-1d'] { display: none; } -#colorlegend.colorlegend-3d #vlims-2d { +#colorlegend.colorlegend-3d #vlims-2d, [id$='_colorlegend'].colorlegend-3d [id$='_vlims-2d'] { display: none; } -#colorlegend.colorlegend-3d #colorlegend-colors { +#colorlegend.colorlegend-3d #colorlegend-colors, [id$='_colorlegend'].colorlegend-3d [id$='_colorlegend-colors'] { display: none; } /* 2D version */ -#colorlegend.colorlegend-2d { +#colorlegend.colorlegend-2d, /* 2D version */ +[id$='_colorlegend'].colorlegend-2d { height:20vmin; width:20vmin; margin-left: auto; margin-right: 2vmin; bottom: 5vmin; } -.colorlegend-2d #vlims-2d { +.colorlegend-2d #vlims-2d, .colorlegend-2d [id$='_vlims-2d'] { display: block; } -.colorlegend-2d #vlims-1d { +.colorlegend-2d #vlims-1d, .colorlegend-2d [id$='_vlims-1d'] { display: none; } -.colorlegend-2d #colorlegend-colorbar { +.colorlegend-2d #colorlegend-colorbar, .colorlegend-2d [id$='_colorlegend-colorbar'] { height: 20vmin; } -#xd-vmin-container, #xd-vmax-container, #yd-vmin-container, #yd-vmax-container { +#xd-vmin-container, [id$='_xd-vmin-container'], +#xd-vmax-container, [id$='_xd-vmax-container'], +#yd-vmin-container, [id$='_yd-vmin-container'], +#yd-vmax-container, [id$='_yd-vmax-container'] { font-size:2.5vmin; color: white; } -#xd-vmin-container, #xd-vmax-container { +#xd-vmin-container, [id$='_xd-vmin-container'], +#xd-vmax-container, [id$='_xd-vmax-container'] { position: absolute; top: 21.5vmin; } -#xd-vmin-container { +#xd-vmin-container, [id$='_xd-vmin-container'] { left: 0; text-align: left; display: block; } -#xd-vmax-container { +#xd-vmax-container, [id$='_xd-vmax-container'] { width: 14vmin; display: block; right: 0; text-align: right; } -#yd-vmin-container, #yd-vmax-container { +#yd-vmin-container, [id$='_yd-vmin-container'], +#yd-vmax-container, [id$='_yd-vmax-container'] { position: absolute; right: -12vmin; width: 10vmin; } -#yd-vmin-container { +#yd-vmin-container, [id$='_yd-vmin-container'] { text-align: right; left: -12vmin; bottom: 0; } -#yd-vmax-container { +#yd-vmax-container, [id$='_yd-vmax-container'] { text-align: right; top: 0vmin; left: -12vmin; } -#xd-vmin-input, #xd-vmax-input, #yd-vmin-input, #yd-vmax-input { +#xd-vmin-input, [id$='_xd-vmin-input'], +#xd-vmax-input, [id$='_xd-vmax-input'], +#yd-vmin-input, [id$='_yd-vmin-input'], +#yd-vmax-input, [id$='_yd-vmax-input'] { position: absolute; display: none; font-size:2.5vmin; @@ -295,21 +317,21 @@ a:visited { color:white; } padding:0.5vmin; } -#yd-vmax-input { +#yd-vmax-input, [id$='_yd-vmax-input'] { text-align: right; top: calc(-1px - 0.5vmin); left: calc(2px - 0.5vmin); } -#yd-vmin-input { +#yd-vmin-input, [id$='_yd-vmin-input'] { text-align: right; bottom:calc(-1px - 0.5vmin); left:calc(2px - 0.5vmin); } -#xd-vmin-input { +#xd-vmin-input, [id$='_xd-vmin-input'] { top: calc(-1px - 0.5vmin); left:calc(-1px - 0.5vmin); } -#xd-vmax-input { +#xd-vmax-input, [id$='_xd-vmax-input'] { text-align: right; top: calc(-1px - 0.5vmin); right: calc(-2px - 0.5vmin); @@ -317,7 +339,9 @@ a:visited { color:white; } /* 1D version*/ -#vmin-container, #vmax-container { +#vmin-container, /* 1D version*/ +[id$='_vmin-container'], +#vmax-container, [id$='_vmax-container'] { font-size:4vmin; color:white; z-index:110; @@ -327,13 +351,14 @@ a:visited { color:white; } line-height:0vmin; top:50%; } -#vmin-container { +#vmin-container, [id$='_vmin-container'] { left:-42vw; } -#vmax-container { +#vmax-container, [id$='_vmax-container'] { right:-42vw; } -#vmin-input, #vmax-input { +#vmin-input, [id$='_vmin-input'], +#vmax-input, [id$='_vmax-input'] { color:white; background-color:black; font-size:4vmin; @@ -350,19 +375,20 @@ a:visited { color:white; } top:calc(-1px - 3vh); } -#vmin-input { +#vmin-input, [id$='_vmin-input'] { right:calc(-1px - 1vw); text-align:right; } -#vmax-input { +#vmax-input, [id$='_vmax-input'] { left:calc(-1px - 1vw); } -#vmin, #vmax { +#vmin, [id$='_vmin'], +#vmax, [id$='_vmax'] { } -#vmin { +#vmin, [id$='_vmin'] { text-align:right; } -#vmax { +#vmax, [id$='_vmax'] { } /* selection styles*/ @@ -395,7 +421,7 @@ a:visited { color:white; } color:#CCC; } -#figure_ui { +#figure_ui, [id$='_figure_ui'] { position:absolute; right:0; width:265px !important; @@ -448,11 +474,11 @@ div.opt_category p.opt_label { max-width:220px; max-height:220px; } -#cmapsearchbox { +#cmapsearchbox, [id$='_cmapsearchbox'] { position:absolute; z-index:6; } -#cmapsearchresults { +#cmapsearchresults, [id$='_cmapsearchresults'] { z-index:5; margin-top:30px; position:absolute; @@ -474,18 +500,19 @@ input.vlim { float:left; padding:5px 10px; } -#vrange { +#vrange, [id$='_vrange'] { width:200px; } -#vrange2 { +#vrange2, [id$='_vrange2'] { height:200px; } -#topbar fieldset { +#topbar fieldset, [id$='_topbar'] fieldset { min-width:200px; } /* datasets */ -ul#datasets { +ul#datasets, /* datasets */ +ul[id$='_datasets'] { max-width: 400px; list-style: none; margin: 0; @@ -493,7 +520,7 @@ ul#datasets { overflow:auto; max-height:70vh; } -ul#datasets li { +ul#datasets li, ul[id$='_datasets'] li { background: white; position:relative; margin: 5px; @@ -502,7 +529,7 @@ ul#datasets li { list-style: none; padding-left: 42px; } -ul#datasets li .handle { +ul#datasets li .handle, ul[id$='_datasets'] li .handle { background: #CCC; position: absolute; left: 0; @@ -527,57 +554,58 @@ ul#datasets li .handle { margin-top:-135px !important; } -#bottombar { +#bottombar, [id$='_bottombar'] { height:135px; margin-top:-10px; border-radius: 10px 10px 0px 0px / 10px 10px 0px 0px; clear:both; text-align:left; } -#bottombar table { +#bottombar table, [id$='_bottombar'] table { padding:4px; width:100%; } -#bottombar table td:first-child { +#bottombar table td:first-child, [id$='_bottombar'] table td:first-child { width:60px; } -#bottombar:hover, #bottombar:active { +#bottombar:hover, [id$='_bottombar']:hover, +#bottombar:active, [id$='_bottombar']:active { margin-top:-115px; } -#moviecontrols { +#moviecontrols, [id$='_moviecontrols'] { width:100%; text-align:center; display:none; } -table#movieui { +table#movieui, table[id$='_movieui'] { width:80%; margin:auto; margin-top:-5px; text-align:left; vertical-align:middle; } -#moviecontrol { +#moviecontrol, [id$='_moviecontrol'] { padding:2px; } -#moviecontrol img { +#moviecontrol img, [id$='_moviecontrol'] img { padding:4px; margin:4px; appearance:button; -moz-appearance:button; -webkit-appearance:button; } -#movieframe { +#movieframe, [id$='_movieframe'] { width:60px; } -#movieprogress { +#movieprogress, [id$='_movieprogress'] { width:100%; } -#mixbtns td { +#mixbtns td, [id$='_mixbtns'] td { text-align:center; } -button#twodbutton { +button#twodbutton, button[id$='_twodbutton'] { /*visibility: hidden;*/ display: none; position: absolute; @@ -593,7 +621,8 @@ button#twodbutton { color: white; } -button#twodbutton:disabled, button#twodbutton[disabled] { +button#twodbutton:disabled, button[id$='_twodbutton']:disabled, +button#twodbutton[disabled], button[id$='_twodbutton'][disabled] { color: #888; opacity: 0.5; } \ No newline at end of file diff --git a/cortex/webgl/resources/js/figure.js b/cortex/webgl/resources/js/figure.js index a2e05b226..9265839e5 100644 --- a/cortex/webgl/resources/js/figure.js +++ b/cortex/webgl/resources/js/figure.js @@ -226,21 +226,25 @@ var jsplot = (function (module) { this.figure.addEventListener("resize", this.resize.bind(this)); } - // color legend + // color legend (scoped to this Axes' container so multiple viewers + // on one page don't bind to each other's elements) function formatState (state) { if (!state.id) { return state.text; } var $state = $('' + state.text + ''); return $state; }; + var self = this; $(document).ready(function() { - var selector = $(".colorlegend-select").select2({ + var $root = $(self.object); + var selector = $root.find(".colorlegend-select").select2({ templateResult: formatState }); - $("#colorlegend-colorbar").on('click', function() { + $root.find("[id$='colorlegend-colorbar']").on('click', function() { selector.show(); selector.select2('open'); }); - $('#brain').on('click', function () { selector.select2("close"); }) + // The brain canvas is the only inside an mriview Axes. + $root.find("canvas").on('click', function () { selector.select2("close"); }) }); } diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index 8c220a604..f09b9dd71 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -1,6 +1,12 @@ var mriview = (function(module) { var grid_shapes = [null, [1,1], [2, 1], [3, 1], [2, 2], [2, 2], [3, 2], [3, 2]]; - module.Viewer = function(figure) { + module.Viewer = function(figure, opts) { + opts = opts || {}; + // Per-instance prefix for stamped template element IDs. Empty in single-viewer + // mode; set to e.g. "p0_" by show_multi so multiple viewers can coexist on + // one page without ID collisions. + this._idPrefix = opts.idPrefix || ''; + jsplot.Axes.call(this, figure); //mix function to attach to surface when it's added @@ -13,10 +19,18 @@ var mriview = (function(module) { this.controls.allowTilt = evt.value; }.bind(this); - //Initialize all the html - $(this.object).html($("#mriview_html").html()) - - //Catalog the available colormaps + //Initialize all the html (with per-instance ID prefix when in multi-viewer mode) + module.stampTemplate(this.object, "#mriview_html", this._idPrefix); + + //Catalog the available colormaps. Each viewer owns its own + //THREE.Texture instances because Three.js textures are bound to the + //first WebGLRenderer that uploads them; sharing them across viewers' + //renderers triggers "object does not belong to this context" errors. + //Keys are stored UNPREFIXED so dataset.js / select2 lookups by the + //original cmap name (e.g. "RdBu_r") still work for both viewers. + this.colormaps = {}; + var prefix = this._idPrefix; + var viewerCmaps = this.colormaps; $(this.object).find(".cmap img").each(function() { var tex = new THREE.Texture(this); tex.minFilter = THREE.LinearFilter; @@ -24,11 +38,25 @@ var mriview = (function(module) { tex.premultiplyAlpha = false; tex.flipY = true; tex.needsUpdate = true; - colormaps[this.parentNode.id] = tex; + var pid = this.parentNode.id; + var name = (prefix && pid.indexOf(prefix) === 0) ? pid.slice(prefix.length) : pid; + viewerCmaps[name] = tex; }); - window.colormaps = colormaps - - this.canvas = $(this.object).find("#brain"); + // Bridge the per-viewer registry to the global `colormaps` symbol that + // legacy callers still read (dataset.js's fromJSON / setColormap and + // figure.js's select2 templateResult). New code in this file resolves + // colormaps via `viewer.colormaps` directly; this assignment exists + // ONLY so those legacy synchronous lookups land in the right viewer's + // dict during that viewer's addData/setData/UI handlers. Each viewer's + // entry point calls `_activateColormaps` to re-alias just before the + // legacy code runs, so the window between alias and lookup is the + // current synchronous task — no async path observes a stale alias. + // A cleaner design would thread the registry explicitly through + // dataset.js + figure.js; deferred until the multi-viewer feature + // lands so the diff stays bounded. + window.colormaps = this.colormaps; + + this.canvas = this.$id("brain"); jsplot.Axes3D.call(this, figure); this.surfs = []; @@ -38,7 +66,7 @@ var mriview = (function(module) { this.loaded = $.Deferred().done(function() { //this.schedule(); this.resize(); - $(this.object).find("#ctmload").hide(); + this.$id("ctmload").hide(); this.canvas.css("opacity", 1); this.object.appendChild(this.controls.twodbutton[0]); this.controls.twodbutton.click(function(){ @@ -72,6 +100,13 @@ var mriview = (function(module) { THREE.EventDispatcher.prototype.apply(module.Viewer.prototype); module.Viewer.prototype.constructor = module.Viewer; + // Scoped ID lookup: returns a jQuery wrapper around the per-viewer prefixed ID + // inside this viewer's DOM container. In single-viewer mode (prefix='') this + // is equivalent to $('#name'). + module.Viewer.prototype.$id = function(name) { + return $(this.object).find('#' + this._idPrefix + name); + }; + module.Viewer.prototype.drawView = function(scene, idx) { if (this.surfs[idx].prerender !== undefined) this.surfs[idx].prerender(this.renderer, scene, this.camera); @@ -198,7 +233,14 @@ var mriview = (function(module) { // }.bind(this)); // }; + // Make this viewer's colormaps the active global so global lookups in + // dataset.js / figure.js / setColormap UI handlers hit the right textures. + module.Viewer.prototype._activateColormaps = function() { + window.colormaps = this.colormaps; + }; + module.Viewer.prototype.addData = function(data) { + this._activateColormaps(); if (!(data instanceof Array)) data = [data]; @@ -230,20 +272,23 @@ var mriview = (function(module) { }; module.Viewer.prototype.setData = function(name) { + var viewer = this; + this._activateColormaps(); - // blur any selected input elements + // blur any selected input elements (use bare names; resolved via viewer.$id) let ids = [ - ['#vmin', '#vmin-input'], - ['#vmax', '#vmax-input'], - ['#xd-vmin', '#xd-vmin-input'], - ['#xd-vmax', '#xd-vmax-input'], - ['#yd-vmin', '#yd-vmin-input'], - ['#yd-vmax', '#yd-vmax-input'], + ['vmin', 'vmin-input'], + ['vmax', 'vmax-input'], + ['xd-vmin', 'xd-vmin-input'], + ['xd-vmax', 'xd-vmax-input'], + ['yd-vmin', 'yd-vmin-input'], + ['yd-vmax', 'yd-vmax-input'], ] - for (let displayIdInputId of ids) { - $(displayIdInputId[0]).css('display', 'block') - $(displayIdInputId[1]).css('display', 'none') - document.getElementById(displayIdInputId[1].slice(1)).blur() + for (let pair of ids) { + viewer.$id(pair[0]).css('display', 'block') + var inputJq = viewer.$id(pair[1]); + inputJq.css('display', 'none') + if (inputJq.length) inputJq[0].blur() } if (name instanceof Array) { @@ -307,52 +352,53 @@ var mriview = (function(module) { //Show or hide the colormap for raw / non-raw dataviews if (this.active.data[0].raw) { - $("#color_fieldset").fadeTo(0.15, 0); + viewer.$id("color_fieldset").fadeTo(0.15, 0); } else { - $("#color_fieldset").fadeTo(0.15, 1); + viewer.$id("color_fieldset").fadeTo(0.15, 1); } // // // // // // // // // // // // // // // // // // // // // // // // // start colorlegend code + // Pull from the per-viewer registry rather than `window.colormaps`, + // which is a re-aliased shim for legacy callers (dataset.js, + // figure.js) and could otherwise point at a different viewer's dict. function get1dColormaps () { - let colormaps1d = {} - for (let colormap of Object.keys(window.colormaps)) { - if (colormaps[colormap].image.height == 1) { - colormaps1d[colormap] = colormaps[colormap] + let registry = viewer.colormaps; + let out = {}; + for (let name of Object.keys(registry)) { + if (registry[name].image.height == 1) { + out[name] = registry[name]; } } - return colormaps1d + return out; } function get2dColormaps () { - let colormaps2d = {} - for (let colormap of Object.keys(window.colormaps)) { - if (colormaps[colormap].image.height > 1) { - colormaps2d[colormap] = colormaps[colormap] + let registry = viewer.colormaps; + let out = {}; + for (let name of Object.keys(registry)) { + if (registry[name].image.height > 1) { + out[name] = registry[name]; } } - return colormaps2d + return out; } + // Scope the select-element queries to this viewer's DOM root so a + // 1d/2d swap on viewer 0 doesn't reach into viewer 1's dropdown + // (and vice versa). `colormaps` here is the parameter, shadowing + // any outer reference. function setColorOptions (colormaps) { - - // clear current options - let options = $('.colorlegend-select option') - for (let key in Object.keys(options)) { - let option = options[key] - if (option) { - option.remove() - } - } - - // add new options - let selectElement = $('.colorlegend-select')[0] - for (let colormap of Object.keys(colormaps).sort()) { - let optionElement = document.createElement('option') - optionElement.setAttribute('value', colormap) - optionElement.innerText = colormap - selectElement.appendChild(optionElement) + let $select = $(viewer.object).find('.colorlegend-select'); + $select.find('option').remove(); + let selectElement = $select[0]; + if (!selectElement) return; + for (let name of Object.keys(colormaps).sort()) { + let optionElement = document.createElement('option'); + optionElement.setAttribute('value', name); + optionElement.innerText = name; + selectElement.appendChild(optionElement); } } @@ -364,18 +410,19 @@ var mriview = (function(module) { } function setColorOptionsByDim(dims) { + var $cl = viewer.$id('colorlegend'); if (dims === 1) { - $('#colorlegend').removeClass('colorlegend-2d') - $('#colorlegend').removeClass('colorlegend-3d') + $cl.removeClass('colorlegend-2d') + $cl.removeClass('colorlegend-3d') setColorOptions(get1dColormaps()) } else if (dims === 2) { - $('#colorlegend').removeClass('colorlegend-3d') - $('#colorlegend').addClass('colorlegend-2d') + $cl.removeClass('colorlegend-3d') + $cl.addClass('colorlegend-2d') setColorOptions(get2dColormaps()) } else if (dims === 3) { console.log('rgb detected') - $('#colorlegend').removeClass('colorlegend-2d') - $('#colorlegend').addClass('colorlegend-3d') + $cl.removeClass('colorlegend-2d') + $cl.addClass('colorlegend-3d') } else { console.log('unknown case: dims=' + dims) } @@ -408,48 +455,49 @@ var mriview = (function(module) { return cleaned } - var viewer = this + let imageData = viewer.$id(this.active.cmapName).find('img')[0].src - let imageData = $('#' + this.active.cmapName + ' img')[0].src - - $('#colorlegend-colorbar').attr('src', imageData); - $('.colorlegend-select').val(this.active.cmapName).trigger('change'); + viewer.$id('colorlegend-colorbar').attr('src', imageData); + var $cmapSelect = $(viewer.object).find('.colorlegend-select'); + $cmapSelect.val(this.active.cmapName).trigger('change'); // displaycolor limits if (dims === 1) { - $('#vmin').text(cleanNumber(viewer.active.vmin[0]['value'][0])); - $('#vmax').text(cleanNumber(viewer.active.vmax[0]['value'][0])); + viewer.$id('vmin').text(cleanNumber(viewer.active.vmin[0]['value'][0])); + viewer.$id('vmax').text(cleanNumber(viewer.active.vmax[0]['value'][0])); } else if (dims === 2) { - $('#xd-vmin').text(cleanNumber(viewer.active.vmin[0]['value'][0], 3, true)); - $('#xd-vmax').text(cleanNumber(viewer.active.vmax[0]['value'][0], 3, true)); - $('#yd-vmin').text(cleanNumber(viewer.active.vmin[0]['value'][1], 3, true)); - $('#yd-vmax').text(cleanNumber(viewer.active.vmax[0]['value'][1], 3, true)); + viewer.$id('xd-vmin').text(cleanNumber(viewer.active.vmin[0]['value'][0], 3, true)); + viewer.$id('xd-vmax').text(cleanNumber(viewer.active.vmax[0]['value'][0], 3, true)); + viewer.$id('yd-vmin').text(cleanNumber(viewer.active.vmin[0]['value'][1], 3, true)); + viewer.$id('yd-vmax').text(cleanNumber(viewer.active.vmax[0]['value'][1], 3, true)); } - $('.colorlegend-select').off('select2:open') - $('.colorlegend-select').on('select2:open', function (e) { + $cmapSelect.off('select2:open') + $cmapSelect.on('select2:open', function (e) { window.colorlegendOpen = true }); - $('.colorlegend-select').off('select2:opening') - $('.colorlegend-select').on('select2:opening', function (e) { + $cmapSelect.off('select2:opening') + $cmapSelect.on('select2:opening', function (e) { setColorOptionsByDim(dims) }); - $('.colorlegend-select').off('select2:close') - $('.colorlegend-select').on('select2:close', function (e) { + $cmapSelect.off('select2:close') + $cmapSelect.on('select2:close', function (e) { window.colorlegendOpen = false }); - $('.colorlegend-select').off('select2:select') - $('.colorlegend-select').on('select2:select', function (e) { + $cmapSelect.off('select2:select') + $cmapSelect.on('select2:select', function (e) { var cmapName = e.params.data.id; viewer.active.cmapName = cmapName; viewer.active.setColormap(cmapName); viewer.schedule(); - $('#colorlegend-colorbar').attr('src', colormaps[cmapName].image.currentSrc); + viewer.$id('colorlegend-colorbar').attr('src', viewer.colormaps[cmapName].image.currentSrc); }); - function submitVmin(newVal, dim, textId, decimals) { + // textTarget may be a jQuery object (from setWheel/setClick callers) or a + // selector string. $(textTarget) handles both. + function submitVmin(newVal, dim, textTarget, decimals) { if (!decimals) { decimals = 3 } @@ -460,12 +508,12 @@ var mriview = (function(module) { if ($.isNumeric(newVal)) { viewer.active.setvmin(newVal, dim); - $(textId).text(cleanNumber(newVal, decimals--)); + $(textTarget).text(cleanNumber(newVal, decimals--)); viewer.schedule(); } } - function submitVmax(newVal, dim, textId, decimals) { + function submitVmax(newVal, dim, textTarget, decimals) { if (!decimals) { decimals = 3 } @@ -476,7 +524,7 @@ var mriview = (function(module) { if ($.isNumeric(newVal)) { viewer.active.setvmax(newVal, dim); - $(textId).text(cleanNumber(newVal, decimals)); + $(textTarget).text(cleanNumber(newVal, decimals)); viewer.schedule(); } } @@ -486,10 +534,11 @@ var mriview = (function(module) { 'vmax': submitVmax, } - // new wheel functions + // new wheel functions (`id` here is a bare ID name; resolved via viewer.$id) function setWheelFunctions (side, id, dim) { - $(id).off('wheel') - $(id).on('wheel', function (e) { + var $el = viewer.$id(id); + $el.off('wheel') + $el.on('wheel', function (e) { let currentVal = parseFloat(viewer.active[side][0]['value'][dim]); let newVal; let step = 0.25; @@ -515,43 +564,46 @@ var mriview = (function(module) { newVal = currentVal - step; } } - submitFunctions[side](newVal, dim, id); + submitFunctions[side](newVal, dim, $el); }); } - setWheelFunctions('vmin', '#vmin', 0) - setWheelFunctions('vmax', '#vmax', 0) - setWheelFunctions('vmin', '#xd-vmin', 0) - setWheelFunctions('vmax', '#xd-vmax', 0) - setWheelFunctions('vmin', '#yd-vmin', 1) - setWheelFunctions('vmax', '#yd-vmax', 1) + setWheelFunctions('vmin', 'vmin', 0) + setWheelFunctions('vmax', 'vmax', 0) + setWheelFunctions('vmin', 'xd-vmin', 0) + setWheelFunctions('vmax', 'xd-vmax', 0) + setWheelFunctions('vmin', 'yd-vmin', 1) + setWheelFunctions('vmax', 'yd-vmax', 1) function setClickFunctions (side, textId, inputId, dim, decimals) { + var $text = viewer.$id(textId); + var $input = viewer.$id(inputId); + function enterFunction () { - $(textId).css('display', 'none'); - $(inputId).val(cleanNumber(viewer.active[side][0]['value'][dim], decimals, true)); - $(inputId).css('display', 'block'); - $(inputId).focus(); + $text.css('display', 'none'); + $input.val(cleanNumber(viewer.active[side][0]['value'][dim], decimals, true)); + $input.css('display', 'block'); + $input.focus(); } function leaveFunction () { - $(inputId).css('display', 'none'); - $(textId).css('display', 'block'); - submitFunctions[side]($(inputId).val(), dim, textId, decimals); + $input.css('display', 'none'); + $text.css('display', 'block'); + submitFunctions[side]($input.val(), dim, $text, decimals); } - $(textId).on('click', enterFunction); - $(inputId).off(); - $(inputId).on('blur', leaveFunction); - $(inputId).on('keyup', function (e) { if (event.keyCode === 13) leaveFunction() }); + $text.on('click', enterFunction); + $input.off(); + $input.on('blur', leaveFunction); + $input.on('keyup', function (e) { if (e.keyCode === 13) leaveFunction() }); } - setClickFunctions('vmin', '#vmin', '#vmin-input', 0) - setClickFunctions('vmax', '#vmax', '#vmax-input', 0) - setClickFunctions('vmin', '#xd-vmin', '#xd-vmin-input', 0, 3) - setClickFunctions('vmax', '#xd-vmax', '#xd-vmax-input', 0, 3) - setClickFunctions('vmin', '#yd-vmin', '#yd-vmin-input', 1, 3) - setClickFunctions('vmax', '#yd-vmax', '#yd-vmax-input', 1, 3) + setClickFunctions('vmin', 'vmin', 'vmin-input', 0) + setClickFunctions('vmax', 'vmax', 'vmax-input', 0) + setClickFunctions('vmin', 'xd-vmin', 'xd-vmin-input', 0, 3) + setClickFunctions('vmax', 'xd-vmax', 'xd-vmax-input', 0, 3) + setClickFunctions('vmin', 'yd-vmin', 'yd-vmin-input', 1, 3) + setClickFunctions('vmax', 'yd-vmax', 'yd-vmax-input', 1, 3) // end colorlegend code // // // // // // // // // // // // // // // // // // // // // // // // @@ -559,8 +611,10 @@ var mriview = (function(module) { var defers = []; + // Prefer the per-viewer Surface registry (multi-viewer mode). + var subjReg = this.subjects || subjects; for (var i = 0; i < this.active.data.length; i++) { - defers.push(subjects[this.active.data[i].subject].loaded) + defers.push(subjReg[this.active.data[i].subject].loaded) } $.when.apply(null, defers).done(function() { // $(this.object).find("#vrange").slider("option", {min: this.active.data[0].min, max:this.active.data[0].max}); @@ -574,21 +628,21 @@ var mriview = (function(module) { this.setupStim(); - $(this.object).find("#datasets li").each(function() { + this.$id("datasets").find("li").each(function() { if ($(this).text() == name) $(this).addClass("ui-selected"); else $(this).removeClass("ui-selected"); }) - $(this.object).find("#datasets").val(name); + this.$id("datasets").val(name); if (typeof(this.active.description) == "string") { var html = name+""+this.active.description+""; - $("#dataname").html(html); - $("#dataopts").show(); + this.$id("dataname").html(html); + this.$id("dataopts").show(); } else { - $("#dataname").text(name); - $("#dataopts").show(); + this.$id("dataname").text(name); + this.$id("dataopts").show(); } this.schedule(); this.loaded.resolve(); @@ -603,7 +657,7 @@ var mriview = (function(module) { module.Viewer.prototype.nextData = function(dir) { var i = 0, found = false; var datasets = []; - $(this.object).find("#datasets li").each(function() { + this.$id("datasets").find("li").each(function() { if (!found) { if (this.className.indexOf("ui-selected") > 0) found = true; @@ -621,14 +675,17 @@ var mriview = (function(module) { }; module.Viewer.prototype.rmData = function(name) { delete this.datasets[name]; - $(this.object).find("#datasets li").each(function() { + this.$id("datasets").find("li").each(function() { if ($(this).text() == name) $(this).remove(); }) }; module.Viewer.prototype.addSurf = function(surftype, opts) { //Sets the slicing surface used to visualize the data - var surf = new surftype(this.active, opts); + // Pass viewer reference so the Surface can scope DOM lookups to this viewer. + var passedOpts = Object.assign({}, opts || {}, {viewer: this}); + var surf = new surftype(this.active, passedOpts); + surf.viewer = this; surf.addEventListener("mix", this._mix); surf.addEventListener("allowTilt", this._allowTilt); @@ -643,11 +700,11 @@ var mriview = (function(module) { this.ui.addFolder("surface", true, surf.ui); } - $("#brain").on('mousemove', + this.canvas.on('mousemove', function (event) { // only implemented for 1d volume datasets or vertex datasets if (this.active.data.length != 1 || this.active.data[0].raw) { - $('#mouseover_value').css('display', 'none') + this.$id('mouseover_value').css('display', 'none') return } // We need to use a different logic if we have a VolumeData or a VertexData object @@ -662,7 +719,7 @@ var mriview = (function(module) { // Now we need to map back with the index map // First figure out the subject, then get the index map subject = this.active.data[0].subject - indexMap = subjects[subject].hemis[coords.hemi].indexMap + indexMap = (this.subjects || subjects)[subject].hemis[coords.hemi].indexMap // console.log("vertex before: " + vertex); vertex = indexMap[vertex] // console.log("vertex after: " + vertex); @@ -678,10 +735,10 @@ var mriview = (function(module) { } // console.log("Value on mouseover: " + value); if (value !== null) { - $('#mouseover_value').text(parseFloat(value).toPrecision(3)) - $('#mouseover_value').css('display', 'block') + this.$id('mouseover_value').text(parseFloat(value).toPrecision(3)) + this.$id('mouseover_value').css('display', 'block') } else { - $('#mouseover_value').css('display', 'none') + this.$id('mouseover_value').css('display', 'none') } }.bind(this) ) @@ -825,7 +882,7 @@ var mriview = (function(module) { // set the picked value display // only implemented for 1d volume datasets or vertex datasets if (this.active.data.length != 1 || this.active.data[0].raw) { - $('#picked_value').css('display', 'none') + this.$id('picked_value').css('display', 'none') return } @@ -838,7 +895,7 @@ var mriview = (function(module) { // Now we need to map back with the index map // First figure out the subject, then get the index map subject = this.active.data[0].subject - indexMap = subjects[subject].hemis[coords.hemi].indexMap + indexMap = (this.subjects || subjects)[subject].hemis[coords.hemi].indexMap vertex = indexMap[vertex] // Now access the data value = this.active.data[0].verts[0][hemiIdx].array[vertex] @@ -854,10 +911,10 @@ var mriview = (function(module) { } console.log("Value on click: " + value); if (value !== null) { - $('#picked_value').text(parseFloat(value).toPrecision(3)) - $('#picked_value').css('display', 'block') + this.$id('picked_value').text(parseFloat(value).toPrecision(3)) + this.$id('picked_value').css('display', 'block') } else { - $('#picked_value').css('display', 'none') + this.$id('picked_value').css('display', 'none') } } diff --git a/cortex/webgl/resources/js/mriview_surface.js b/cortex/webgl/resources/js/mriview_surface.js index 8bb0eac01..f8bd1463b 100644 --- a/cortex/webgl/resources/js/mriview_surface.js +++ b/cortex/webgl/resources/js/mriview_surface.js @@ -435,14 +435,24 @@ var mriview = (function(module) { // }; module.Surface.prototype.apply = function(dataview) { - this.loaded.done(function() { + // In multi-viewer mode the SVG overlay's "update" handler resolves + // `this.loaded` more than once per Surface, which leaves jQuery's + // resolve-Callbacks memory in a state where new `.done()` adds become + // no-ops while `state()` still reports "resolved". Run synchronously + // when already resolved so the per-frame `apply()` from drawView can + // actually swap the mesh material to our ShaderMaterial. + var run = function () { for (var i = 0; i < this.sheets.length; i++) { this.sheets[i].left.material = this.shaders[dataview.uuid]; this.sheets[i].right.material = this.shaders[dataview.uuid]; } - - this.picker.apply(dataview) - }.bind(this)); + this.picker.apply(dataview); + }.bind(this); + if (this.loaded.state() === "resolved") { + run(); + } else { + this.loaded.done(run); + } }; module.Surface.prototype.resetShaders = function() { @@ -515,7 +525,7 @@ var mriview = (function(module) { var factor = 1 - Math.abs(smix - (this.names.length-1)); let clipped = 0 <= factor ? (factor <= 1 ? factor : 1) : 0; this.dispatchEvent({type:'mix', flat:clipped, mix:mix, thickmix:this.uniforms.thickmix.value}); - viewer.schedule() + if (this.viewer) this.viewer.schedule() var gui = this.ui._gui for (var i in gui.__controllers) { @@ -555,7 +565,7 @@ var mriview = (function(module) { let newVal = this.uniforms.surfmix.value + inc; if (0.0 <= newVal && newVal <= 1.0) { this.setMix(newVal); - viewer.schedule(); + if (this.viewer) this.viewer.schedule(); } } module.Surface.prototype.setPivot = function(val) { @@ -599,11 +609,12 @@ var mriview = (function(module) { this.pivots.left.front.visible = val; }; module.Surface.prototype.toggleColorbar = function(val) { + var $cl = this.viewer ? this.viewer.$id('colorlegend') : $('#colorlegend'); if (val === true || val === undefined) { - $('#colorlegend').css('display', 'block') + $cl.css('display', 'block') return true } else { - $('#colorlegend').css('display', 'none') + $cl.css('display', 'none') return false } @@ -612,7 +623,7 @@ var mriview = (function(module) { }; module.Surface.prototype.toggleLeftVis = function() { this.setLeftVis(!this._leftvis); - viewer.schedule(); + if (this.viewer) this.viewer.schedule(); }; module.Surface.prototype.setRightVis = function(val) { if (val === undefined) @@ -623,13 +634,15 @@ var mriview = (function(module) { }; module.Surface.prototype.toggleRightVis = function() { this.setRightVis(!this._rightvis); - viewer.schedule(); + if (this.viewer) this.viewer.schedule(); }; module.Surface.prototype.toggleOpacity = function() { + if (!this.viewer) return; let newValue = 1 - Math.round(this.uniforms.dataAlpha.value) - surface = Object.keys(viewer.ui._desc.surface).filter((key) => key[0] != '_')[0] - viewer.ui.set('surface.' + surface + '.opacity', newValue) - viewer.schedule(); + var v = this.viewer; + var surface = Object.keys(v.ui._desc.surface).filter((key) => key[0] != '_')[0] + v.ui.set('surface.' + surface + '.opacity', newValue) + v.schedule(); }; module.Surface.prototype.setLayers = function(val) { if (val === undefined) @@ -644,7 +657,7 @@ var mriview = (function(module) { this._layers = 1; } this.resetShaders(); - viewer.schedule(); + if (this.viewer) this.viewer.schedule(); var gui = this.ui._gui for (var i in gui.__controllers) { gui.__controllers[i].updateDisplay(); @@ -706,7 +719,12 @@ var mriview = (function(module) { return {pos:flat, norms:norms}; }; - module.SurfDelegate = function(dataview) { + module.SurfDelegate = function(dataview, opts) { + opts = opts || {}; + // Owning viewer - used to look up per-viewer Surface instances so that + // multiple viewers don't reparent the same THREE.Group into different + // scenes (Three.js objects can have only one parent). + this.viewer = opts.viewer || null; this.object = new THREE.Group(); this.object.name = "SurfDelegate"; @@ -727,7 +745,10 @@ var mriview = (function(module) { this.ui.remove(this.surf.ui); } var subj = dataview.data[0].subject; - this.surf = subjects[subj]; + // Prefer the per-viewer Surface registry (multi-viewer mode); fall back + // to the global single-viewer `subjects` dict for back-compat. + var subjReg = (this.viewer && this.viewer.subjects) ? this.viewer.subjects : subjects; + this.surf = subjReg[subj]; this.surf.init(dataview); this.object.add(this.surf.object); diff --git a/cortex/webgl/resources/js/mriview_utils.js b/cortex/webgl/resources/js/mriview_utils.js index 40a55507f..fe9854982 100644 --- a/cortex/webgl/resources/js/mriview_utils.js +++ b/cortex/webgl/resources/js/mriview_utils.js @@ -3,6 +3,19 @@ Number.prototype.mod = function(n) { } var mriview = (function(module) { + // Stamp a + {% if leapmotion %} diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index d32ad7916..19f1eacfa 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -1,17 +1,13 @@ import binascii import copy -import functools import glob import json import mimetypes import os import random import shutil -import sys -import threading import time from typing import Union, Any, Callable, Optional, ParamSpec, cast -import warnings import webbrowser from configparser import NoOptionError @@ -21,26 +17,32 @@ import numpy as np from tornado import web -from .. import dataset, options, utils, volume +from .. import dataset, options, utils from ..database import db from . import serve from .data import Package from .FallbackLoader import FallbackLoader try: - cmapdir = options.config.get('webgl', 'colormaps') + cmapdir = options.config.get("webgl", "colormaps") if not os.path.exists(cmapdir): - raise Exception("Colormap directory (%s) does not exist"%cmapdir) + raise Exception("Colormap directory (%s) does not exist" % cmapdir) except NoOptionError: cmapdir = os.path.join(options.config.get("basic", "filestore"), "colormaps") if not os.path.exists(cmapdir): - raise Exception("Colormap directory was not defined in the config file and the default (%s) does not exist"%cmapdir) + raise Exception( + "Colormap directory was not defined in the config file and the default (%s) does not exist" + % cmapdir + ) domain_name = options.config.get("webgl", "domain_name") colormaps = glob.glob(os.path.join(cmapdir, "*.png")) -colormaps = [(os.path.splitext(os.path.split(cm)[1])[0], serve.make_base64(cm)) - for cm in sorted(colormaps)] +colormaps = [ + (os.path.splitext(os.path.split(cm)[1])[0], serve.make_base64(cm)) + for cm in sorted(colormaps) +] + def make_static( outpath, @@ -130,7 +132,7 @@ def make_static( Smoothness of curvature overlay. Default None, which uses the value specified in the config file. surface_specularity : float or None, optional - Specularity of surfaces visualized with the WebGL viewer. + Specularity of surfaces visualized with the WebGL viewer. Default None, which uses the value specified in the config file under `webgl_viewopts.specularity`. **kwargs @@ -285,24 +287,24 @@ def make_static( def show( data: Union[dataset.Dataset, dataset.Dataview], - autoclose: Optional[bool]=None, - open_browser: Optional[bool]=None, - port: Optional[int]=None, - pickerfun: Optional[Callable[[tuple[int, int, int], int, str], None]]=None, - recache: bool=False, - template: str="mixer.html", - overlays_available: Optional[tuple[str, ...]]=None, - overlays_visible: Optional[tuple[str, ...]]=("rois", "sulci"), - labels_visible: Optional[tuple[str, ...]]=("rois",), - types: Optional[tuple[str, ...]]=("inflated",), - overlay_file: Optional[str]=None, - curvature_brightness: Optional[float]=None, - curvature_contrast: Optional[float]=None, - curvature_smoothness: Optional[float]=None, - surface_specularity: Optional[float]=None, - title: str="Brain", - layout: Optional[str]=None, - display_url: bool=True, + autoclose: Optional[bool] = None, + open_browser: Optional[bool] = None, + port: Optional[int] = None, + pickerfun: Optional[Callable[[tuple[int, int, int], int, str], None]] = None, + recache: bool = False, + template: str = "mixer.html", + overlays_available: Optional[tuple[str, ...]] = None, + overlays_visible: Optional[tuple[str, ...]] = ("rois", "sulci"), + labels_visible: Optional[tuple[str, ...]] = ("rois",), + types: Optional[tuple[str, ...]] = ("inflated",), + overlay_file: Optional[str] = None, + curvature_brightness: Optional[float] = None, + curvature_contrast: Optional[float] = None, + curvature_smoothness: Optional[float] = None, + surface_specularity: Optional[float] = None, + title: str = "Brain", + layout: Optional[str] = None, + display_url: bool = True, **kwargs, ): """ @@ -338,7 +340,7 @@ def show( overlays_available : tuple, optional Overlays available in the viewer. If None, then all overlay layers of the svg file will be potentially available in the viewer (whether initially - visible or not). + visible or not). overlays_visible : tuple, optional The listed overlay layers will be set visible by default. Layers not listed here will be hidden by default (but can be enabled in the viewer GUI). @@ -366,7 +368,7 @@ def show( Smoothness of curvature overlay. Default None, which uses the value specified in the config file. surface_specularity : float or None, optional - Specularity of surfaces visualized with the WebGL viewer. + Specularity of surfaces visualized with the WebGL viewer. Default None, which uses the value specified in the config file under `webgl_viewopts.specularity`. title : str, optional @@ -387,52 +389,61 @@ def show( # populate default webshow args if autoclose is None: - autoclose = options.config.get('webshow', 'autoclose', fallback='true') == 'true' + autoclose = ( + options.config.get("webshow", "autoclose", fallback="true") == "true" + ) if open_browser is None: - open_browser = options.config.get('webshow', 'open_browser', fallback='true') == 'true' + open_browser = ( + options.config.get("webshow", "open_browser", fallback="true") == "true" + ) data = dataset.normalize(data) if not isinstance(data, dataset.Dataset): data = dataset.Dataset(data=data) - html = FallbackLoader([os.path.split(os.path.abspath(template))[0], serve.cwd]).load(template) + html = FallbackLoader( + [os.path.split(os.path.abspath(template))[0], serve.cwd] + ).load(template) db.auxfile = data - #Extract the list of stimuli, for special-casing + # Extract the list of stimuli, for special-casing stims: dict[str, str] = dict() for name, view in data: - if 'stim' in view.attrs and os.path.exists(view.attrs['stim']): - sname = os.path.split(view.attrs['stim'])[1] - stims[sname] = view.attrs['stim'] + if "stim" in view.attrs and os.path.exists(view.attrs["stim"]): + sname = os.path.split(view.attrs["stim"])[1] + stims[sname] = view.attrs["stim"] package = Package(data) metadata = json.dumps(package.metadata()) images = package.images subjects = list(package.subjects) - ctmargs = dict(method='mg2', level=9, recache=recache, - external_svg=overlay_file, overlays_available=overlays_available) - ctms = dict((subj, utils.get_ctmpack(subj, types, **ctmargs)) - for subj in subjects) + ctmargs = dict( + method="mg2", + level=9, + recache=recache, + external_svg=overlay_file, + overlays_available=overlays_available, + ) + ctms = dict((subj, utils.get_ctmpack(subj, types, **ctmargs)) for subj in subjects) package.reorder(ctms) - subjectjs = json.dumps(dict((subj, "ctm/%s/"%subj) for subj in subjects)) + subjectjs = json.dumps(dict((subj, "ctm/%s/" % subj) for subj in subjects)) db.auxfile = None - - linear = lambda x, y, m: (1.-m)*x + m*y + linear = lambda x, y, m: (1.0 - m) * x + m * y mixes = dict( linear=linear, - smoothstep=(lambda x, y, m: linear(x, y, 3*m**2 - 2*m**3)), - smootherstep=(lambda x, y, m: linear(x, y, 6*m**5 - 15*m**4 + 10*m**3)) + smoothstep=(lambda x, y, m: linear(x, y, 3 * m**2 - 2 * m**3)), + smootherstep=(lambda x, y, m: linear(x, y, 6 * m**5 - 15 * m**4 + 10 * m**3)), ) post_name: Queue[str] = Queue() # Put together all view options - my_viewopts: dict[str, Any] = dict(options.config.items('webgl_viewopts')) - my_viewopts['overlays_visible'] = overlays_visible - my_viewopts['labels_visible'] = labels_visible + my_viewopts: dict[str, Any] = dict(options.config.items("webgl_viewopts")) + my_viewopts["overlays_visible"] = overlays_visible + my_viewopts["labels_visible"] = labels_visible my_viewopts["brightness"] = ( options.config.get("curvature", "brightness") if curvature_brightness is None @@ -455,7 +466,7 @@ def show( ) for sec in options.config.sections(): - if 'paths' in sec or 'labels' in sec: + if "paths" in sec or "labels" in sec: my_viewopts[sec] = dict(options.config.items(sec)) if pickerfun is None: @@ -463,8 +474,8 @@ def show( class CTMHandler(web.RequestHandler): def get(self, path: str): - subj, path = path.split('/') - if path == '': + subj, path = path.split("/") + if path == "": self.set_header("Content-Type", "application/json") self.write(open(ctms[subj]).read()) else: @@ -473,14 +484,14 @@ def get(self, path: str): if mtype is None: mtype = "application/octet-stream" self.set_header("Content-Type", mtype) - self.write(open(os.path.join(fpath, path), 'rb').read()) + self.write(open(os.path.join(fpath, path), "rb").read()) class DataHandler(web.RequestHandler): def get(self, path: str): path = path.strip("/") frame: Union[int, str] try: - dataname, frame = path.split('/') + dataname, frame = path.split("/") except ValueError: dataname = path frame = 0 @@ -492,15 +503,21 @@ def get(self, path: str): else: self.set_header("Content-Type", "image/png") - if 'Range' in self.request.headers: + if "Range" in self.request.headers: self.set_status(206) - rangestr = self.request.headers['Range'].split('=')[1] - start, end = [ int(i) if len(i) > 0 else None for i in rangestr.split('-') ] - - clenheader = 'bytes %s-%s/%s' % (start, end or len(dataimg), len(dataimg) ) - self.set_header('Content-Range', clenheader) - self.set_header('Content-Length', end-start+1) - self.write(dataimg[start:end+1]) + rangestr = self.request.headers["Range"].split("=")[1] + start, end = [ + int(i) if len(i) > 0 else None for i in rangestr.split("-") + ] + + clenheader = "bytes %s-%s/%s" % ( + start, + end or len(dataimg), + len(dataimg), + ) + self.set_header("Content-Range", clenheader) + self.set_header("Content-Length", end - start + 1) + self.write(dataimg[start : end + 1]) else: self.write(dataimg) else: @@ -521,24 +538,26 @@ def get(self, path: str): class StaticHandler(web.StaticFileHandler): def initialize(self): - self.root = '' + self.root = "" class MixerHandler(web.RequestHandler): def get(self): self.set_header("Content-Type", "text/html") - generated = html.generate(data=metadata, - colormaps=colormaps, - default_cmap="RdBu_r", - python_interface=True, - leapmotion=True, - layout=layout, - subjects=subjectjs, - viewopts=json.dumps(my_viewopts), - title=title, - **kwargs) - #overlays_visible=json.dumps(overlays_visible), - #labels_visible=json.dumps(labels_visible), - #**viewopts) + generated = html.generate( + data=metadata, + colormaps=colormaps, + default_cmap="RdBu_r", + python_interface=True, + leapmotion=True, + layout=layout, + subjects=subjectjs, + viewopts=json.dumps(my_viewopts), + title=title, + **kwargs, + ) + # overlays_visible=json.dumps(overlays_visible), + # labels_visible=json.dumps(labels_visible), + # **viewopts) self.write(generated) def post(self): @@ -554,13 +573,13 @@ def post(self): data = png svgfile.write(data) - P = ParamSpec('P') + P = ParamSpec("P") class JSMixer(serve.JSProxy[P]): @property def view_props(self) -> list[str]: - """An enumerated list of settable properties for views. - There may be a way to get this from the javascript object, + """An enumerated list of settable properties for views. + There may be a way to get this from the javascript object, but I (ML) don't know how. There may be additional properties we want to set in views @@ -572,14 +591,18 @@ def view_props(self) -> list[str]: 'volume_vis', 'frame', 'slices'] """ camera = getattr(self.ui, "camera") - _camera_props = ['camera.%s' % k for k in camera._controls.attrs.keys()] + _camera_props = ["camera.%s" % k for k in camera._controls.attrs.keys()] surface = getattr(self.ui, "surface") _subject = list(surface._folders.attrs.keys())[0] _surface = getattr(surface, _subject) - _surface_props = ['surface.{subject}.%s'%k for k in _surface._controls.attrs.keys()] - _curvature_props = ['surface.{subject}.curvature.brightness', - 'surface.{subject}.curvature.contrast', - 'surface.{subject}.curvature.smoothness'] + _surface_props = [ + "surface.{subject}.%s" % k for k in _surface._controls.attrs.keys() + ] + _curvature_props = [ + "surface.{subject}.curvature.brightness", + "surface.{subject}.curvature.contrast", + "surface.{subject}.curvature.smoothness", + ] return _camera_props + _surface_props + _curvature_props def _set_view(self, **kwargs): @@ -593,19 +616,23 @@ def _set_view(self, **kwargs): assert isinstance(self.ui, serve.JSProxy) surface: serve.JSProxy[P] = getattr(self.ui, "surface") subject_list = cast(serve.JSProxy[P], surface._folders).attrs.keys() - # Better to only self.view_props once; it interacts with javascript, + # Better to only self.view_props once; it interacts with javascript, # don't want to do that too often, it leads to glitches. vw_props = copy.copy(self.view_props) for subject in subject_list: - if 'surface.{subject}.unfold' in kwargs: - unfold = kwargs.pop('surface.{subject}.unfold') - self.ui.set('surface.{subject}.unfold'.format(subject=subject), unfold) + if "surface.{subject}.unfold" in kwargs: + unfold = kwargs.pop("surface.{subject}.unfold") + self.ui.set( + "surface.{subject}.unfold".format(subject=subject), unfold + ) for k, v in kwargs.items(): - if not k in vw_props: - print('Unknown parameter %s!'%k) + if k not in vw_props: + print("Unknown parameter %s!" % k) continue else: - self.ui.set(k.format(subject=subject) if '{subject}' in k else k, v) + self.ui.set( + k.format(subject=subject) if "{subject}" in k else k, v + ) # Wait for webgl. Wait for it. .... WAAAAAIIIT. time.sleep(0.03) @@ -621,7 +648,7 @@ def _capture_view(self, frame_time=None): ---------- frame_time : scalar time (in seconds) to specify for this frame. - + Notes ----- If multiple subjects are present, only retrieves view for first subject. @@ -630,16 +657,18 @@ def _capture_view(self, frame_time=None): subject = list(self.ui.surface._folders.attrs.keys())[0] for p in self.view_props: try: - view[p] = self.ui.get(p.format(subject=subject) if '{subject}' in p else p)[0] + view[p] = self.ui.get( + p.format(subject=subject) if "{subject}" in p else p + )[0] # Wait for webgl. time.sleep(0.03) except Exception as err: # TO DO: Fix this hack with an error class in serve.py & catch it here - print(err) #msg = "Cannot read property 'undefined'" - #if err.message[:len(msg)] != msg: + print(err) # msg = "Cannot read property 'undefined'" + # if err.message[:len(msg)] != msg: # raise err if frame_time is not None: - view['time'] = frame_time + view["time"] = frame_time return view def save_view(self, subject, name, is_overwrite=False): @@ -683,12 +712,14 @@ def get_view(self, subject, name): def addData(self, **kwargs): Proxy = serve.JSProxy(self.send, "window.viewers.addData") - new_meta, new_ims = _convert_dataset(Dataset(**kwargs), path='/data/', fmt='%s_%d.png') + new_meta, new_ims = _convert_dataset( + Dataset(**kwargs), path="/data/", fmt="%s_%d.png" + ) metadata.update(new_meta) images.update(new_ims) return Proxy(metadata) - def getImage(self, filename: str, size: tuple[int, int]=(1920, 1080)): + def getImage(self, filename: str, size: tuple[int, int] = (1920, 1080)): """Saves currently displayed view to a .png image file Parameters @@ -702,8 +733,15 @@ def getImage(self, filename: str, size: tuple[int, int]=(1920, 1080)): Proxy = serve.JSProxy(self.send, "window.viewer.getImage") return Proxy(size[0], size[1], "mixer.html") - def makeMovie(self, animation, filename="brainmovie%07d.png", offset=0, - fps=30, size=(1920, 1080), interpolation="linear"): + def makeMovie( + self, + animation, + filename="brainmovie%07d.png", + offset=0, + fps=30, + size=(1920, 1080), + interpolation="linear", + ): """Renders movie frames for animation of mesh movement Makes an animation (for example, a transition between inflated and @@ -754,39 +792,47 @@ def makeMovie(self, animation, filename="brainmovie%07d.png", offset=0, # anim is a list of transitions between keyframes anim = [] setfunc = self.ui.set - for f in sorted(animation, key=lambda x:x['idx']): - if f['idx'] == 0: - setfunc(f['state'], f['value']) - state[f['state']] = dict(idx=f['idx'], val=f['value']) + for f in sorted(animation, key=lambda x: x["idx"]): + if f["idx"] == 0: + setfunc(f["state"], f["value"]) + state[f["state"]] = dict(idx=f["idx"], val=f["value"]) else: - if f['state'] not in state: - state[f['state']] = dict(idx=0, val=self.getState(f['state'])[0]) - start = dict(idx=state[f['state']]['idx'], - state=f['state'], - value=state[f['state']]['val']) - end = dict(idx=f['idx'], state=f['state'], value=f['value']) - state[f['state']]['idx'] = f['idx'] - state[f['state']]['val'] = f['value'] - if start['value'] != end['value']: + if f["state"] not in state: + state[f["state"]] = dict( + idx=0, val=self.getState(f["state"])[0] + ) + start = dict( + idx=state[f["state"]]["idx"], + state=f["state"], + value=state[f["state"]]["val"], + ) + end = dict(idx=f["idx"], state=f["state"], value=f["value"]) + state[f["state"]]["idx"] = f["idx"] + state[f["state"]]["val"] = f["value"] + if start["value"] != end["value"]: anim.append((start, end)) - for i, sec in enumerate(np.arange(0, anim[-1][1]['idx']+1./fps, 1./fps)): + for i, sec in enumerate( + np.arange(0, anim[-1][1]["idx"] + 1.0 / fps, 1.0 / fps) + ): for start, end in anim: - if start['idx'] < sec <= end['idx']: - idx = (sec - start['idx']) / float(end['idx'] - start['idx']) - if start['state'] == 'frame': - func = mixes['linear'] + if start["idx"] < sec <= end["idx"]: + idx = (sec - start["idx"]) / float(end["idx"] - start["idx"]) + if start["state"] == "frame": + func = mixes["linear"] else: func = mixes[interpolation] - val = func(np.array(start['value']), np.array(end['value']), idx) + val = func( + np.array(start["value"]), np.array(end["value"]), idx + ) if isinstance(val, np.ndarray): - setfunc(start['state'], val.ravel().tolist()) + setfunc(start["state"], val.ravel().tolist()) else: - setfunc(start['state'], val) - self.getImage(filename%(i+offset), size=size) + setfunc(start["state"], val) + self.getImage(filename % (i + offset), size=size) - def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): + def _get_anim_seq(self, keyframes, fps=30, interpolation="linear"): """Convert a list of keyframes to a list of EVERY frame in an animation. Utility function called by make_movie; separated out so that individual @@ -798,23 +844,23 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): fr = 0 a = np.array func = mixes[interpolation] - #skip_props = ['surface.{subject}.right', 'surface.{subject}.left', ] #'projection', + # skip_props = ['surface.{subject}.right', 'surface.{subject}.left', ] #'projection', # Get keyframes - keyframes = sorted(keyframes, key=lambda x:x['time']) + keyframes = sorted(keyframes, key=lambda x: x["time"]) # Normalize all time to frame rate - fs = 1./fps + fs = 1.0 / fps for k in range(len(keyframes)): - t = keyframes[k]['time'] - t = np.round(t/fs)*fs - keyframes[k]['time'] = t + t = keyframes[k]["time"] + t = np.round(t / fs) * fs + keyframes[k]["time"] = t allframes = [] for start, end in zip(keyframes[:-1], keyframes[1:]): - t0 = start['time'] - t1 = end['time'] - tdif = float(t1-t0) + t0 = start["time"] + t1 = end["time"] + tdif = float(t1 - t0) # Check whether to continue frame sequence to endpoint - use_endpoint = keyframes[-1]==end - nvalues = np.round(tdif/fs).astype(int) + use_endpoint = keyframes[-1] == end + nvalues = np.round(tdif / fs).astype(int) if use_endpoint: nvalues += 1 fr_time = np.linspace(0, 1, nvalues, endpoint=use_endpoint) @@ -822,9 +868,13 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): for t in fr_time: frame = {} for prop in start.keys(): - if prop=='time': + if prop == "time": continue - if (start[prop] is None) or (start[prop] == end[prop]) or isinstance(start[prop], (bool, str)): + if ( + (start[prop] is None) + or (start[prop] == end[prop]) + or isinstance(start[prop], (bool, str)) + ): frame[prop] = start[prop] continue val = func(a(start[prop]), a(end[prop]), t) @@ -835,9 +885,18 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): allframes.append(frame) return allframes - def make_movie_views(self, animation, filename="brainmovie%07d.png", - offset=0, fps=30, size=(1920, 1080), alpha=1, frame_sleep=0.05, - frame_start=0, interpolation="linear"): + def make_movie_views( + self, + animation, + filename="brainmovie%07d.png", + offset=0, + fps=30, + size=(1920, 1080), + alpha=1, + frame_sleep=0.05, + frame_start=0, + interpolation="linear", + ): """Renders movie frames for animation of mesh movement Makes an animation (for example, a transition between inflated and @@ -894,7 +953,7 @@ def make_movie_views(self, animation, filename="brainmovie%07d.png", for fr, frame in enumerate(allframes[frame_start:], frame_start): self._set_view(**frame) time.sleep(frame_sleep) - self.getImage(filename%(fr+offset+1), size=size) + self.getImage(filename % (fr + offset + 1), size=size) time.sleep(frame_sleep) class PickerHandler(web.RequestHandler): @@ -907,7 +966,9 @@ def get(self): parts = voxel_arg.split(",") if len(parts) != 3: self.set_status(400) - self.finish("Invalid 'voxel' query parameter: expected 3 comma-separated integers") + self.finish( + "Invalid 'voxel' query parameter: expected 3 comma-separated integers" + ) return try: voxel: tuple[int, int, int] = tuple(int(i) for i in parts) @@ -921,6 +982,7 @@ def get(self): class WebApp(serve.WebApp): disconnect_on_close = autoclose + def get_client(self): self.connect.wait() self.connect.clear() @@ -932,18 +994,22 @@ def get_local_client(self): if port is None: port = random.randint(1024, 65536) - server = WebApp([(r'/ctm/(.*)', CTMHandler), - (r'/data/(.*)', DataHandler), - (r'/stim/(.*)', StimHandler), - (r'/mixer.html', MixerHandler), - (r'/picker', PickerHandler), - (r'/', MixerHandler), - (r'/static/(.*)', StaticHandler)], - port) + server = WebApp( + [ + (r"/ctm/(.*)", CTMHandler), + (r"/data/(.*)", DataHandler), + (r"/stim/(.*)", StimHandler), + (r"/mixer.html", MixerHandler), + (r"/picker", PickerHandler), + (r"/", MixerHandler), + (r"/static/(.*)", StaticHandler), + ], + port, + ) server.start() - print("Started server on port %d"%server.port) - url = "http://%s%s:%d/mixer.html"%(serve.hostname, domain_name, server.port) + print("Started server on port %d" % server.port) + url = "http://%s%s:%d/mixer.html" % (serve.hostname, domain_name, server.port) if open_browser: webbrowser.open(url) client = server.get_client() @@ -952,8 +1018,298 @@ def get_local_client(self): elif display_url: try: from IPython.display import HTML, display - display(HTML('Open viewer: {0}'.format(url))) + + display( + HTML('Open viewer: {0}'.format(url)) + ) except: pass return server + + +def show_multi( + views, + layout=None, + yoke=False, + template="multi.html", + open_browser=None, + autoclose=None, + port=None, + title="Brain (multi)", + overlays_visible=("rois", "sulci"), + labels_visible=("rois",), + types=("inflated",), + overlay_file=None, + curvature_brightness=None, + curvature_contrast=None, + curvature_smoothness=None, + surface_specularity=None, + recache=False, + overlays_available=None, + display_url=True, + **kwargs, +): + """Serve N brain viewers side-by-side in a single HTML page. + + Each panel renders in its own ``mriview.Viewer`` (independent renderer, + camera, controls, and Surface), so they can show different data and/or + different subjects simultaneously. An optional yoke toggle synchronises + camera + surface-mix across panels at runtime. + + Parameters + ---------- + views : list + List of N items, one per panel. Each item is a Volume, Vertex, + Dataview, or Dataset (which is unwrapped to its first view). + layout : tuple of (rows, cols), optional + Grid shape. Defaults to a single row when omitted. + yoke : bool, optional + If True, the yoke toggle in the chrome starts ON. + All other kwargs are passed through to the template renderer; semantics + match :func:`show`. + """ + if autoclose is None: + autoclose = ( + options.config.get("webshow", "autoclose", fallback="true") == "true" + ) + if open_browser is None: + open_browser = ( + options.config.get("webshow", "open_browser", fallback="true") == "true" + ) + + if not isinstance(views, (list, tuple)) or len(views) < 1: + raise ValueError("show_multi requires a non-empty list of views") + n = len(views) + + if layout is None: + layout = (1, n) + nrows, ncols = layout + if nrows * ncols < n: + raise ValueError("layout %s cannot fit %d views" % (layout, n)) + + html = FallbackLoader( + [os.path.split(os.path.abspath(template))[0], serve.cwd] + ).load(template) + + # Build one Package per panel. Each panel's data layer is renamed + # ``panel{i}__{originalname}`` so the global /data/{name}/ route can serve + # all panels' data without collision (Step 3 makes this matter; even when + # all panels share the same volume, the prefix keeps them logically distinct + # so each viewer's `addData` references its own metadata blob). + panel_packages = [] + panel_metadata = [] + panel_subjects = [] + merged_images: dict[str, bytes] = {} + all_subjects: set[str] = set() + all_stims: dict[str, str] = {} + + for i, view in enumerate(views): + ds = dataset.normalize(view) + if not isinstance(ds, dataset.Dataset): + ds = dataset.Dataset(data=ds) + + # Collect any stimuli (special-cased like in show()). + for vname, dv in ds: + attrs = getattr(dv, "attrs", {}) or {} + stimpath = attrs.get("stim") + if stimpath and os.path.exists(stimpath): + sname = os.path.split(stimpath)[1] + all_stims[sname] = stimpath + + prefix = "panel%d__" % i + + pkg = Package(ds) + # Rewrite all internal layer names with the per-panel prefix. + # The inner `name` field must also be rewritten because dataset.fromJSON + # in the browser looks up `images[json.name]`, and `images` is keyed by + # the prefixed name. + renamed_brains = {} + for bname, bval in pkg.brains.items(): + v_renamed = dict(bval) + v_renamed["name"] = prefix + bname + renamed_brains[prefix + bname] = v_renamed + renamed_images = {prefix + n: v for n, v in pkg.images.items()} + # Update the cross-references stored inside view metadata so each view's + # `data` field points at the prefixed brain name(s). + renamed_views = [] + for v in pkg.views: + v2 = dict(v) + v2["data"] = ( + [prefix + n for n in v["data"]] if v.get("data") else v.get("data") + ) + renamed_views.append(v2) + pkg.brains = renamed_brains + pkg.images = renamed_images + # `image_names` reads pkg.images on each call, so the URL paths reflect + # the renamed keys automatically. + merged_images.update(renamed_images) + + # Stash so we can reorder() after CTMs are loaded. + panel_packages.append(pkg) + # Per-panel metadata blob: views + data, with image URLs. + panel_meta = dict( + views=renamed_views, + data=renamed_brains, + images=pkg.image_names(), + ) + panel_metadata.append(panel_meta) + # Pull subject from the first view's first brain (panels currently have + # one view; multi-view-per-panel can be added later). + first_view = renamed_views[0] if renamed_views else None + subj = ( + renamed_brains[first_view["data"][0]]["subject"] + if first_view and first_view.get("data") + else None + ) + panel_subjects.append(subj) + if subj is not None: + all_subjects.add(subj) + + # Get one CTM per unique subject (browser-cached across panels). + ctmargs = dict( + method="mg2", + level=9, + recache=recache, + external_svg=overlay_file, + overlays_available=overlays_available, + ) + ctms = {subj: utils.get_ctmpack(subj, types, **ctmargs) for subj in all_subjects} + for pkg in panel_packages: + pkg.reorder(ctms) + # `reorder` may have rewritten `pkg.images` for vertex datasets; sync. + merged_images.update(pkg.images) + + subjectjs = json.dumps({subj: "ctm/%s/" % subj for subj in all_subjects}) + + # Per-panel JSON blob the template feeds into each Viewer's addData. + panels_json = json.dumps( + [{"data": panel_metadata[i], "subject": panel_subjects[i]} for i in range(n)] + ) + + post_name: Queue[str] = Queue() + my_viewopts: dict[str, Any] = dict(options.config.items("webgl_viewopts")) + my_viewopts["overlays_visible"] = overlays_visible + my_viewopts["labels_visible"] = labels_visible + my_viewopts["brightness"] = ( + options.config.get("curvature", "brightness") + if curvature_brightness is None + else curvature_brightness + ) + my_viewopts["contrast"] = ( + options.config.get("curvature", "contrast") + if curvature_contrast is None + else curvature_contrast + ) + my_viewopts["smoothness"] = ( + options.config.get("curvature", "webgl_smooth") + if curvature_smoothness is None + else curvature_smoothness + ) + my_viewopts["specularity"] = ( + options.config.get("webgl_viewopts", "specularity") + if surface_specularity is None + else surface_specularity + ) + for sec in options.config.sections(): + if "paths" in sec or "labels" in sec: + my_viewopts[sec] = dict(options.config.items(sec)) + + class CTMHandler(web.RequestHandler): + def get(self, path: str): + subj, path = path.split("/") + if path == "": + self.set_header("Content-Type", "application/json") + self.write(open(ctms[subj]).read()) + else: + fpath = os.path.split(ctms[subj])[0] + mtype = ( + mimetypes.guess_type(os.path.join(fpath, path))[0] + or "application/octet-stream" + ) + self.set_header("Content-Type", mtype) + self.write(open(os.path.join(fpath, path), "rb").read()) + + class DataHandler(web.RequestHandler): + def get(self, path: str): + path = path.strip("/") + try: + dataname, frame = path.split("/") + except ValueError: + dataname, frame = path, 0 + if dataname not in merged_images: + self.set_status(404) + self.write_error(404) + return + dataimg = merged_images[dataname][int(frame)] + if dataimg[1:6] == "NUMPY": + self.set_header("Content-Type", "application/octet-stream") + else: + self.set_header("Content-Type", "image/png") + self.write(dataimg) + + class StimHandler(web.StaticFileHandler): + def initialize(self): + pass + + def get(self, path: str): + if path not in all_stims: + self.set_status(404) + self.write_error(404) + else: + self.root, fname = os.path.split(all_stims[path]) + super(StimHandler, self).get(fname) + + class MultiMixerHandler(web.RequestHandler): + def get(self): + self.set_header("Content-Type", "text/html") + generated = html.generate( + colormaps=colormaps, + default_cmap="RdBu_r", + python_interface=True, + leapmotion=False, + subjects=subjectjs, + panels_json=panels_json, + nrows=nrows, + ncols=ncols, + yoke=("true" if yoke else "false"), + viewopts=json.dumps(my_viewopts), + title=title, + **kwargs, + ) + self.write(generated) + + if port is None: + port = random.randint(1024, 65536) + + server = serve.WebApp( + [ + (r"/ctm/(.*)", CTMHandler), + (r"/data/(.*)", DataHandler), + (r"/stim/(.*)", StimHandler), + (r"/multi.html", MultiMixerHandler), + (r"/", MultiMixerHandler), + ], + port, + ) + server.disconnect_on_close = autoclose + server.start() + print("Started multi-viewer on port %d" % server.port) + url = "http://%s%s:%d/multi.html" % (serve.hostname, domain_name, server.port) + if open_browser: + webbrowser.open(url) + elif display_url: + try: + from IPython.display import HTML, display + + display( + HTML( + 'Open multi-viewer: {0}'.format( + url + ) + ) + ) + except Exception: + pass + + return server