arrayview 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {arrayview-0.9.0 → arrayview-0.10.0}/PKG-INFO +1 -1
  2. {arrayview-0.9.0 → arrayview-0.10.0}/pyproject.toml +1 -1
  3. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_server.py +84 -0
  4. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_viewer.html +522 -86
  5. {arrayview-0.9.0 → arrayview-0.10.0}/uv.lock +1 -1
  6. {arrayview-0.9.0 → arrayview-0.10.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  7. {arrayview-0.9.0 → arrayview-0.10.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  8. {arrayview-0.9.0 → arrayview-0.10.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  9. {arrayview-0.9.0 → arrayview-0.10.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  10. {arrayview-0.9.0 → arrayview-0.10.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  11. {arrayview-0.9.0 → arrayview-0.10.0}/.github/workflows/docs.yml +0 -0
  12. {arrayview-0.9.0 → arrayview-0.10.0}/.github/workflows/python-publish.yml +0 -0
  13. {arrayview-0.9.0 → arrayview-0.10.0}/.gitignore +0 -0
  14. {arrayview-0.9.0 → arrayview-0.10.0}/.python-version +0 -0
  15. {arrayview-0.9.0 → arrayview-0.10.0}/.tmp-vsix/extension/extension.js +0 -0
  16. {arrayview-0.9.0 → arrayview-0.10.0}/.tmp-vsix/extension/package.json +0 -0
  17. {arrayview-0.9.0 → arrayview-0.10.0}/AGENTS.md +0 -0
  18. {arrayview-0.9.0 → arrayview-0.10.0}/LICENSE +0 -0
  19. {arrayview-0.9.0 → arrayview-0.10.0}/README.md +0 -0
  20. {arrayview-0.9.0 → arrayview-0.10.0}/docs/comparing.md +0 -0
  21. {arrayview-0.9.0 → arrayview-0.10.0}/docs/configuration.md +0 -0
  22. {arrayview-0.9.0 → arrayview-0.10.0}/docs/display.md +0 -0
  23. {arrayview-0.9.0 → arrayview-0.10.0}/docs/index.md +0 -0
  24. {arrayview-0.9.0 → arrayview-0.10.0}/docs/loading.md +0 -0
  25. {arrayview-0.9.0 → arrayview-0.10.0}/docs/logo.png +0 -0
  26. {arrayview-0.9.0 → arrayview-0.10.0}/docs/measurement.md +0 -0
  27. {arrayview-0.9.0 → arrayview-0.10.0}/docs/remote.md +0 -0
  28. {arrayview-0.9.0 → arrayview-0.10.0}/docs/stylesheets/extra.css +0 -0
  29. {arrayview-0.9.0 → arrayview-0.10.0}/docs/viewing.md +0 -0
  30. {arrayview-0.9.0 → arrayview-0.10.0}/matlab/arrayview.m +0 -0
  31. {arrayview-0.9.0 → arrayview-0.10.0}/mkdocs.yml +0 -0
  32. {arrayview-0.9.0 → arrayview-0.10.0}/plans/webview/LOG.md +0 -0
  33. {arrayview-0.9.0 → arrayview-0.10.0}/scripts/demo.py +0 -0
  34. {arrayview-0.9.0 → arrayview-0.10.0}/scripts/release.sh +0 -0
  35. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/ARCHITECTURE.md +0 -0
  36. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/__init__.py +0 -0
  37. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/__main__.py +0 -0
  38. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_app.py +0 -0
  39. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_config.py +0 -0
  40. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_icon.png +0 -0
  41. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_io.py +0 -0
  42. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_launcher.py +0 -0
  43. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_platform.py +0 -0
  44. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_render.py +0 -0
  45. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_segmentation.py +0 -0
  46. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_session.py +0 -0
  47. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_shell.html +0 -0
  48. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_stdio_server.py +0 -0
  49. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_torch.py +0 -0
  50. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/_vscode.py +0 -0
  51. {arrayview-0.9.0 → arrayview-0.10.0}/src/arrayview/arrayview-opener.vsix +0 -0
  52. {arrayview-0.9.0 → arrayview-0.10.0}/tests/conftest.py +0 -0
  53. {arrayview-0.9.0 → arrayview-0.10.0}/tests/make_vectorfield_test_arrays.py +0 -0
  54. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_api.py +0 -0
  55. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_browser.py +0 -0
  56. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_cli.py +0 -0
  57. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_config.py +0 -0
  58. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_interactions.py +0 -0
  59. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_large_arrays.py +0 -0
  60. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_mode_consistency.py +0 -0
  61. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_mode_matrix.py +0 -0
  62. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_rgb_pixel_art.py +0 -0
  63. {arrayview-0.9.0 → arrayview-0.10.0}/tests/test_torch.py +0 -0
  64. {arrayview-0.9.0 → arrayview-0.10.0}/tests/ui_audit.py +0 -0
  65. {arrayview-0.9.0 → arrayview-0.10.0}/tests/visual_smoke.py +0 -0
  66. {arrayview-0.9.0 → arrayview-0.10.0}/vscode-extension/LICENSE +0 -0
  67. {arrayview-0.9.0 → arrayview-0.10.0}/vscode-extension/extension.js +0 -0
  68. {arrayview-0.9.0 → arrayview-0.10.0}/vscode-extension/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -1913,6 +1913,90 @@ def get_volume_histogram(
1913
1913
  return result
1914
1914
 
1915
1915
 
1916
+ # ── Volume Data (3-D MIP) ────────────────────────────────────────
1917
+
1918
+
1919
+ @app.get("/volume_data/{sid}")
1920
+ def get_volume_data(
1921
+ sid: str,
1922
+ dims: str = "",
1923
+ indices: str = "",
1924
+ complex_mode: int = 0,
1925
+ ):
1926
+ """Return the full 3-D sub-volume as raw float32 bytes for the WebGL MIP renderer.
1927
+
1928
+ *dims* is a comma-separated triple of the three spatial dimensions (e.g. ``"0,1,2"``).
1929
+ *indices* gives the current index for every dimension (comma-separated).
1930
+ Dimensions not in *dims* are sliced at the value given in *indices*.
1931
+
1932
+ If any axis exceeds 256, stride-based down-sampling is applied so that
1933
+ the returned volume fits within 256^3.
1934
+
1935
+ Response headers carry ``X-Shape`` (comma-separated), ``X-Vmin``, ``X-Vmax``.
1936
+ """
1937
+ session = SESSIONS.get(sid)
1938
+ if not session:
1939
+ return Response(status_code=404)
1940
+
1941
+ # Parse dims and indices
1942
+ if not dims:
1943
+ return Response(status_code=400, content="dims required")
1944
+ dim_list = [int(d) for d in dims.split(",")]
1945
+ if len(dim_list) != 3:
1946
+ return Response(status_code=400, content="dims must be exactly 3 integers")
1947
+
1948
+ idx_list = [int(x) for x in indices.split(",")] if indices else [s // 2 for s in session.shape]
1949
+
1950
+ # Build slicer: keep the 3 spatial dims free, fix everything else
1951
+ slicer = []
1952
+ for i in range(len(session.shape)):
1953
+ if i in dim_list:
1954
+ slicer.append(slice(None))
1955
+ else:
1956
+ idx = idx_list[i] if i < len(idx_list) else session.shape[i] // 2
1957
+ slicer.append(min(max(idx, 0), session.shape[i] - 1))
1958
+ vol = np.array(session.data[tuple(slicer)])
1959
+
1960
+ # Reorder axes so dim_list order maps to (0, 1, 2) of the output
1961
+ # vol currently has axes at the positions corresponding to dim_list within
1962
+ # the free-axis subset. We need to figure out which axes of `vol` correspond
1963
+ # to which dim in dim_list.
1964
+ free_axes_sorted = sorted(dim_list)
1965
+ # vol axes are in the order of free_axes_sorted (numpy preserves order)
1966
+ perm = [free_axes_sorted.index(d) for d in dim_list]
1967
+ vol = np.transpose(vol, perm)
1968
+
1969
+ # Apply complex mode
1970
+ vol = apply_complex_mode(vol, complex_mode)
1971
+
1972
+ # Downsample any axis > 256
1973
+ max_dim = 256
1974
+ strides = []
1975
+ for s in vol.shape:
1976
+ strides.append(max(1, (s + max_dim - 1) // max_dim))
1977
+ if any(st > 1 for st in strides):
1978
+ vol = vol[::strides[0], ::strides[1], ::strides[2]]
1979
+
1980
+ vol = np.ascontiguousarray(vol, dtype=np.float32)
1981
+
1982
+ finite = vol[np.isfinite(vol)]
1983
+ if finite.size > 0:
1984
+ vmin = float(np.percentile(finite, 1))
1985
+ vmax = float(np.percentile(finite, 99))
1986
+ else:
1987
+ vmin, vmax = 0.0, 1.0
1988
+
1989
+ return Response(
1990
+ content=vol.tobytes(),
1991
+ media_type="application/octet-stream",
1992
+ headers={
1993
+ "X-Shape": ",".join(str(s) for s in vol.shape),
1994
+ "X-Vmin": str(vmin),
1995
+ "X-Vmax": str(vmax),
1996
+ },
1997
+ )
1998
+
1999
+
1916
2000
  @app.get("/lebesgue/{sid}")
1917
2001
  def get_lebesgue_slice(
1918
2002
  sid: str,
@@ -83,6 +83,7 @@
83
83
  border-color: rgba(216, 222, 233, 0.07);
84
84
  }
85
85
  #viewer-row {
86
+ position: relative;
86
87
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
87
88
  width: 100%; padding: 8px; box-sizing: border-box;
88
89
  }
@@ -462,6 +463,8 @@
462
463
  border: 1px solid rgba(210,90,200,0.25); }
463
464
  .mode-badge-diff { background: rgba(255,130,90,0.15); color: #ff8a5c;
464
465
  border: 1px solid rgba(255,130,90,0.25); }
466
+ .mode-badge-mip { background: rgba(0,200,180,0.15); color: #4fd1c5;
467
+ border-color: rgba(0,200,180,0.3); }
465
468
  .mode-badge-proj { background: rgba(140,100,240,0.15); color: #a78bfa;
466
469
  border: 1px solid rgba(140,100,240,0.25); }
467
470
  :root.light .mode-badge-fft { background: rgba(40,160,100,0.1); color: #288c64; border-color: rgba(40,160,100,0.25); }
@@ -470,6 +473,7 @@
470
473
  :root.light .mode-badge-alpha { background: rgba(180,50,50,0.1); color: #c04040; border-color: rgba(180,50,50,0.25); }
471
474
  :root.light .mode-badge-rgb { background: rgba(170,50,160,0.1); color: #a030a0; border-color: rgba(170,50,160,0.25); }
472
475
  :root.light .mode-badge-diff { background: rgba(200,100,50,0.1); color: #b05520; border-color: rgba(200,100,50,0.25); }
476
+ :root.light .mode-badge-mip { background: rgba(0,180,160,0.1); color: #108080; border-color: rgba(0,180,160,0.25); }
473
477
  :root.light .mode-badge-proj { background: rgba(120,80,200,0.1); color: #7040c0; border-color: rgba(120,80,200,0.25); }
474
478
  #mode-eggs { position: fixed; display: flex; flex-direction: row; align-items: center; gap: 4px; cursor: default; z-index: 2; pointer-events: none; transition: opacity 0.3s ease; }
475
479
  body.fullscreen-mode .mode-badge { background-color: rgba(30, 30, 30, 0.75); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
@@ -618,6 +622,10 @@
618
622
  background: var(--surface); border: 1px solid var(--border);
619
623
  border-radius: var(--radius); pointer-events: none;
620
624
  }
625
+ /* ── MIP 3-D volume renderer ─────────────────────────────── */
626
+ #mip-canvas { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: grab; z-index: 5; }
627
+ #mip-canvas:active { cursor: grabbing; }
628
+
621
629
  #multi-view-wrap { display: none; width: 100%; height: 100%; }
622
630
  #multi-view-wrap.active { display: flex; flex-direction: column; align-items: stretch; justify-content: center; gap: 12px; }
623
631
  #mv-panes { display: flex; align-items: flex-start; justify-content: center; flex: 0 0 auto; gap: 16px; }
@@ -967,7 +975,9 @@
967
975
  #colormap-strip, #colormap-strip-right {
968
976
  position:fixed; display:flex; flex-direction:row; align-items:center; gap:8px;
969
977
  opacity:0; pointer-events:none; transition:opacity 0.25s ease;
970
- z-index:8;
978
+ z-index:8; overflow:hidden; justify-content:center;
979
+ mask-image:linear-gradient(to right, transparent, black 8%, black 92%, transparent);
980
+ -webkit-mask-image:linear-gradient(to right, transparent, black 8%, black 92%, transparent);
971
981
  }
972
982
  #colormap-strip.visible, #colormap-strip-right.visible { opacity:1; }
973
983
  .cmap-thumb { display:flex; flex-direction:column; align-items:center; gap:2px; opacity:0.5; transition:opacity 0.15s ease; }
@@ -1366,6 +1376,7 @@
1366
1376
  </div>
1367
1377
  </div>
1368
1378
  </div>
1379
+ <canvas id="mip-canvas"></canvas>
1369
1380
  <div id="multi-view-wrap"></div>
1370
1381
  <div id="qmri-view-wrap"></div>
1371
1382
  <div id="loading-overlay"></div>
@@ -1473,7 +1484,7 @@
1473
1484
  <div class="help-row"><span class="key">Shift+O</span><span class="desc">cycle overlay visibility: all → off → individual masks (when overlays active)</span></div>
1474
1485
  <div class="help-row"><span class="key">X</span><span class="desc">cycle center pane: off → A−B → |A−B| → |A−B|/|A| → overlay → wipe → flicker → checker</span></div>
1475
1486
  <div class="help-row"><span class="key">&lt; / &gt;</span><span class="desc">movie: fps (during playback)</span></div>
1476
- <div class="help-row"><span class="key">[ / ]</span><span class="desc">flicker: rate · checker: tile size · overlay/wipe/reg blend · arrow density · movie: fps</span></div>
1487
+ <div class="help-row"><span class="key">[ / ]</span><span class="desc">flicker: rate · checker: tile size · overlay/wipe/reg blend · arrow density</span></div>
1477
1488
  <div class="help-row"><span class="key">{ / }</span><span class="desc">arrow length shorter / longer (vector field mode)</span></div>
1478
1489
  <div class="help-row"><span class="key">U</span><span class="desc">toggle vector arrows</span></div>
1479
1490
  <div class="help-row"><span class="key">n</span><span class="desc">cycle compare target session</span></div>
@@ -1482,7 +1493,7 @@
1482
1493
  <div class="help-row"><span class="key">shift+drag</span><span class="desc">move dimbar / colorbar in immersive mode (resets on exit)</span></div>
1483
1494
  <div class="help-row"><span class="key">Z</span><span class="desc">focus center pane (when compare center is active — X to activate)</span></div>
1484
1495
  <div class="help-row"><span class="key">L</span><span class="desc">toggle log scale</span></div>
1485
- <div class="help-row"><span class="key">p</span><span class="desc">cycle projection: off → MAX → MIN → MEAN → STD → SOS → SUM</span></div>
1496
+ <div class="help-row"><span class="key">p</span><span class="desc">cycle projection: off → MAX → MIN → MEAN → STD → SOS → SUM; in multiview: toggle 3-D MIP volume renderer</span></div>
1486
1497
  <div class="help-row"><span class="key">m</span><span class="desc">cycle complex mode (mag / phase / real / imag)</span></div>
1487
1498
  <div class="help-row"><span class="key">f</span><span class="desc">toggle centred FFT (prompts for axes)</span></div>
1488
1499
  <div class="help-row"><span class="key">M</span><span class="desc">toggle alpha transparency</span></div>
@@ -1837,6 +1848,7 @@
1837
1848
  const PROJECTION_COLORS = ['#4cc9f0', '#ff6b6b', '#80ed99', '#c77dff', '#ffa62b'];
1838
1849
  let alphaLevel = 0; // 0=off, 1=on (transparent below vmin)
1839
1850
  let multiViewActive = false;
1851
+ let mipActive = false;
1840
1852
  let mvViews = [];
1841
1853
  let mvDims = [];
1842
1854
  let mvDraggingView = null;
@@ -2259,6 +2271,7 @@
2259
2271
  function _cbApplyAndRender() {
2260
2272
  proxyFetch(`/clearcache/${sid}`);
2261
2273
  updateView();
2274
+ _mipOnWindowChange();
2262
2275
  saveState();
2263
2276
  }
2264
2277
  // Legacy slimCbCanvas wheel/mousedown/dblclick handlers REMOVED (Task 3).
@@ -3340,6 +3353,7 @@
3340
3353
  ModeRegistry.scaleAll();
3341
3354
  positionEggs();
3342
3355
  _validateUIState();
3356
+ if (mipActive) _mipResizeCanvas();
3343
3357
  });
3344
3358
 
3345
3359
  // ── Mode crossfade utility ──────────────────────────────
@@ -3443,7 +3457,6 @@
3443
3457
  // ── Fullscreen overlay colorbar mode ──────────────────────
3444
3458
  if (_fullscreenActive) {
3445
3459
  // Fullscreen: use morph draw, then position anchored to bottom
3446
- if (_cmapInIsland) return;
3447
3460
  const cbRefW = compareActive ? window.innerWidth : (vpEl ? vpEl.offsetWidth : Math.round(canvasRect.width));
3448
3461
  // Cap width: use pane viewport width if available, otherwise % of reference
3449
3462
  let maxCbW = 350;
@@ -3494,8 +3507,6 @@
3494
3507
  }
3495
3508
 
3496
3509
  // ── Normal horizontal colorbar ──────────────────────────
3497
- // Skip everything if colormap preview is showing in the island
3498
- if (_cmapInIsland) return;
3499
3510
  if (wrap) {
3500
3511
  wrap.classList.remove('compact-vertical', 'compact-overlay');
3501
3512
  wrap.style.transform = '';
@@ -3580,7 +3591,7 @@
3580
3591
  // When collapsed: show window values (manualVmin/Vmax or currentVmin/Vmax)
3581
3592
  const slimVmin = document.getElementById('slim-cb-vmin');
3582
3593
  const slimVmax = document.getElementById('slim-cb-vmax');
3583
- if (slimVmin && slimVmax && !_cmapInIsland) {
3594
+ if (slimVmin && slimVmax) {
3584
3595
  if (compareActive) {
3585
3596
  const sharedVmin = Math.min(...compareFrames.filter(Boolean).map(f => manualVmin ?? f.vmin));
3586
3597
  const sharedVmax = Math.max(...compareFrames.filter(Boolean).map(f => manualVmax ?? f.vmax));
@@ -4058,7 +4069,7 @@
4058
4069
  if (vminFrac > 0.005 || vmaxFrac < 0.995) {
4059
4070
  [effVmin, effVmax].forEach(f => {
4060
4071
  if (f <= 0.003 || f >= 0.997) return;
4061
- const x = Math.round(f * cssW);
4072
+ const x = Math.max(2, Math.min(cssW - 2, Math.round(f * cssW)));
4062
4073
 
4063
4074
  const lineH = cssH - labelH;
4064
4075
  const lineTop = labelH;
@@ -4651,6 +4662,7 @@
4651
4662
  _cbWindowChangeRafId = null;
4652
4663
  updateView();
4653
4664
  triggerPreload();
4665
+ _mipOnWindowChange();
4654
4666
  saveState();
4655
4667
  });
4656
4668
  }
@@ -5919,6 +5931,9 @@
5919
5931
  if (projectionMode > 0) {
5920
5932
  html += `<span class="mode-badge mode-badge-proj">${PROJECTION_LABELS[projectionMode - 1]}</span>`;
5921
5933
  }
5934
+ if (mipActive) {
5935
+ html += `<span class="mode-badge mode-badge-mip">MIP</span>`;
5936
+ }
5922
5937
  el.innerHTML = html;
5923
5938
  // Position eggs below the active canvas, just above the slim colorbar
5924
5939
  if (html) positionEggs();
@@ -8317,6 +8332,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
8317
8332
  return;
8318
8333
  }
8319
8334
  if (e.key === 'Escape') {
8335
+ if (mipActive) { exitMipMode(); return; }
8320
8336
  helpOverlay.classList.remove('visible');
8321
8337
  document.getElementById('info-overlay').classList.remove('visible');
8322
8338
  return;
@@ -8508,13 +8524,6 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
8508
8524
  return;
8509
8525
  }
8510
8526
  } else if (e.key === '[' || e.key === ']') {
8511
- if (isPlaying) {
8512
- const delta = e.key === ']' ? 5 : -5;
8513
- playFps = Math.max(1, Math.min(120, playFps + delta));
8514
- setStatus(`▶ playing (Space to stop · < / > fps: ${playFps})`);
8515
- showToast(`playback: ${playFps} fps`);
8516
- return;
8517
- }
8518
8527
  const step = e.key === ']' ? 0.05 : -0.05;
8519
8528
  if (rectRoiMode && _roiShape === 'floodfill') {
8520
8529
  // Adjust flood fill tolerance
@@ -8879,6 +8888,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
8879
8888
  colormap_idx = (colormap_idx === -1 ? 0 : (colormap_idx + 1) % COLORMAPS.length);
8880
8889
  proxyFetch(`/clearcache/${sid}`); updateView(); triggerPreload();
8881
8890
  refreshAxesColor();
8891
+ _mipOnColormapChange();
8882
8892
  // When histogram is open, skip the previewer — just update the colorbar display
8883
8893
  const _histOpen = primaryCb._expanded || (_diffLeftCb && _diffLeftCb.expanded);
8884
8894
  if (_histOpen) {
@@ -8909,6 +8919,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
8909
8919
  colormap_idx = -1;
8910
8920
  proxyFetch(`/clearcache/${sid}`); updateView(); triggerPreload();
8911
8921
  refreshAxesColor();
8922
+ _mipOnColormapChange();
8912
8923
  showStatus(`colormap: ${customColormap}`);
8913
8924
  saveState();
8914
8925
  });
@@ -9230,7 +9241,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9230
9241
  } else if (e.key === 'h' || e.key === 'ArrowLeft') {
9231
9242
  e.preventDefault();
9232
9243
  // While colormap strip is visible, cycle colormap backward
9233
- if ((_cmapInIsland || (_cmapStrip && _cmapStrip.classList.contains('visible'))) && !qmriActive && !compareQmriActive && !rgbMode) {
9244
+ if ((_cmapStrip && _cmapStrip.classList.contains('visible')) && !qmriActive && !compareQmriActive && !rgbMode) {
9234
9245
  if (_cmapStripMode === 'diff-center') {
9235
9246
  const pool = (diffMode === 1) ? DIFF_COLORMAPS : ABS_DIFF_COLORMAPS;
9236
9247
  const idx = pool.indexOf(_diffCenterColormap);
@@ -9277,7 +9288,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9277
9288
  } else if (e.key === 'l' || e.key === 'ArrowRight') {
9278
9289
  e.preventDefault();
9279
9290
  // While colormap strip is visible, cycle colormap forward
9280
- if ((_cmapInIsland || (_cmapStrip && _cmapStrip.classList.contains('visible'))) && !qmriActive && !compareQmriActive && !rgbMode) {
9291
+ if ((_cmapStrip && _cmapStrip.classList.contains('visible')) && !qmriActive && !compareQmriActive && !rgbMode) {
9281
9292
  if (_cmapStripMode === 'diff-center') {
9282
9293
  const pool = (diffMode === 1) ? DIFF_COLORMAPS : ABS_DIFF_COLORMAPS;
9283
9294
  const idx = pool.indexOf(_diffCenterColormap);
@@ -9343,6 +9354,12 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9343
9354
  renderEggs();
9344
9355
  saveState();
9345
9356
  } else if (e.key === 'p') {
9357
+ // In multiview: toggle 3-D MIP volume renderer
9358
+ if (multiViewActive || mipActive) {
9359
+ if (mipActive) { exitMipMode(); }
9360
+ else { enterMipMode(); }
9361
+ return;
9362
+ }
9346
9363
  // Statistical projections: off → MAX → MIN → MEAN → STD → SOS
9347
9364
  if (shape.length < 3) { showStatus('projection: need ≥ 3D array'); return; }
9348
9365
  if (hasVectorfield) { showStatus('projection: not available in vector field mode'); return; }
@@ -10325,7 +10342,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10325
10342
  getWindow: () => ({ vmin: manualVmin ?? currentVmin, vmax: manualVmax ?? currentVmax }),
10326
10343
  setWindow: (lo, hi) => { manualVmin = lo; manualVmax = hi; },
10327
10344
  fetchHistogram: () => _fetchVolumeHistogram(_volHistSpatialOpts()),
10328
- onWindowChange: () => { for (const v of mvViews) mvRender(v); },
10345
+ onWindowChange: () => { for (const v of mvViews) mvRender(v); _mipOnWindowChange(); },
10329
10346
  onBinHover: (binIdx, frac) => {
10330
10347
  if (!lebesgueMode) return;
10331
10348
  if (binIdx < 0) { _clearLebesgueOverlay(); return; }
@@ -10503,6 +10520,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10503
10520
  }
10504
10521
 
10505
10522
  function exitMultiView() {
10523
+ if (mipActive) exitMipMode();
10506
10524
  multiViewActive = false;
10507
10525
  // Clean up multiview vector field state
10508
10526
  _mvVfieldCache.clear();
@@ -10539,6 +10557,483 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10539
10557
  showStatus('3-plane view: off');
10540
10558
  }
10541
10559
 
10560
+ // ═══════════════════════════════════════════════════════════
10561
+ // ── 3-D Maximum Intensity Projection (MIP) renderer ──────
10562
+ // ═══════════════════════════════════════════════════════════
10563
+
10564
+ // WebGL2 state
10565
+ let _mipGL = null;
10566
+ let _mipProgram = null;
10567
+ let _mipVolTex = null;
10568
+ let _mipLutTex = null;
10569
+ let _mipVAO = null;
10570
+ let _mipAzimuth = 0;
10571
+ let _mipElevation = 0;
10572
+ let _mipZoom = 2.5;
10573
+ let _mipDragging = false;
10574
+ let _mipDragX = 0;
10575
+ let _mipDragY = 0;
10576
+ let _mipVolShape = [1, 1, 1];
10577
+ let _mipVolCacheKey = null; // "sid:dims:indices:version" to detect changes
10578
+ let _mipAnimFrame = null;
10579
+
10580
+ const _MIP_VERT_SRC = `#version 300 es
10581
+ in vec2 a_pos;
10582
+ out vec2 v_uv;
10583
+ void main() {
10584
+ v_uv = a_pos * 0.5 + 0.5;
10585
+ gl_Position = vec4(a_pos, 0.0, 1.0);
10586
+ }`;
10587
+
10588
+ const _MIP_FRAG_SRC = `#version 300 es
10589
+ precision highp float;
10590
+ precision highp sampler3D;
10591
+ in vec2 v_uv;
10592
+ out vec4 fragColor;
10593
+
10594
+ uniform sampler3D u_volume;
10595
+ uniform sampler2D u_lut;
10596
+ uniform vec3 u_volSize; // normalized size ratios
10597
+ uniform float u_vmin;
10598
+ uniform float u_vmax;
10599
+ uniform mat3 u_rotation;
10600
+ uniform float u_camDist;
10601
+ uniform int u_steps;
10602
+
10603
+ void main() {
10604
+ // Ray origin and direction from orbit camera with perspective
10605
+ vec3 center = vec3(0.0);
10606
+ vec3 camPos = u_rotation * vec3(0.0, 0.0, u_camDist);
10607
+
10608
+ // Build ray direction with slight perspective
10609
+ vec3 right = u_rotation * vec3(1.0, 0.0, 0.0);
10610
+ vec3 up = u_rotation * vec3(0.0, 1.0, 0.0);
10611
+ vec3 fwd = u_rotation * vec3(0.0, 0.0, -1.0);
10612
+
10613
+ float fov = 0.8; // radians
10614
+ vec2 ndc = (v_uv - 0.5) * 2.0;
10615
+ vec3 rayDir = normalize(fwd + ndc.x * right * fov + ndc.y * up * fov);
10616
+ vec3 rayOri = camPos;
10617
+
10618
+ // AABB intersection with box [-0.5*volSize, 0.5*volSize]
10619
+ vec3 halfBox = u_volSize * 0.5;
10620
+ vec3 invDir = 1.0 / rayDir;
10621
+ vec3 t0 = (-halfBox - rayOri) * invDir;
10622
+ vec3 t1 = ( halfBox - rayOri) * invDir;
10623
+ vec3 tmin = min(t0, t1);
10624
+ vec3 tmax = max(t0, t1);
10625
+ float tNear = max(max(tmin.x, tmin.y), tmin.z);
10626
+ float tFar = min(min(tmax.x, tmax.y), tmax.z);
10627
+
10628
+ if (tNear > tFar || tFar < 0.0) {
10629
+ fragColor = vec4(0.0, 0.0, 0.0, 1.0);
10630
+ return;
10631
+ }
10632
+ tNear = max(tNear, 0.0);
10633
+
10634
+ // Ray-march: find maximum intensity
10635
+ float maxVal = -1e30;
10636
+ float stepSize = (tFar - tNear) / float(u_steps);
10637
+ for (int i = 0; i < 512; i++) {
10638
+ if (i >= u_steps) break;
10639
+ float t = tNear + (float(i) + 0.5) * stepSize;
10640
+ vec3 pos = rayOri + t * rayDir;
10641
+ // Map to texture coords [0,1]
10642
+ vec3 tc = pos / u_volSize + 0.5;
10643
+ float val = texture(u_volume, tc).r;
10644
+ maxVal = max(maxVal, val);
10645
+ }
10646
+
10647
+ // Windowing
10648
+ float intensity = clamp((maxVal - u_vmin) / (u_vmax - u_vmin + 1e-10), 0.0, 1.0);
10649
+
10650
+ // Colormap lookup
10651
+ vec3 color = texture(u_lut, vec2(intensity, 0.5)).rgb;
10652
+ fragColor = vec4(color, 1.0);
10653
+ }`;
10654
+
10655
+ function _mipBuildRotationMatrix(az, el) {
10656
+ const ca = Math.cos(az), sa = Math.sin(az);
10657
+ const ce = Math.cos(el), se = Math.sin(el);
10658
+ // Rotation: first azimuth around Y, then elevation around X
10659
+ return [
10660
+ ca, sa * se, sa * ce,
10661
+ 0, ce, -se,
10662
+ -sa, ca * se, ca * ce,
10663
+ ];
10664
+ }
10665
+
10666
+ function _mipInitGL() {
10667
+ const canvas = document.getElementById('mip-canvas');
10668
+ if (_mipGL) return _mipGL;
10669
+ const gl = canvas.getContext('webgl2', { antialias: false, alpha: false });
10670
+ if (!gl) { showStatus('WebGL2 not available'); return null; }
10671
+ gl.getExtension('OES_texture_float_linear'); // required for LINEAR filtering on R32F textures
10672
+ _mipGL = gl;
10673
+
10674
+ // Compile shaders
10675
+ function compile(type, src) {
10676
+ const s = gl.createShader(type);
10677
+ gl.shaderSource(s, src);
10678
+ gl.compileShader(s);
10679
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
10680
+ console.error('MIP shader error:', gl.getShaderInfoLog(s));
10681
+ return null;
10682
+ }
10683
+ return s;
10684
+ }
10685
+ const vs = compile(gl.VERTEX_SHADER, _MIP_VERT_SRC);
10686
+ const fs = compile(gl.FRAGMENT_SHADER, _MIP_FRAG_SRC);
10687
+ const prog = gl.createProgram();
10688
+ gl.attachShader(prog, vs);
10689
+ gl.attachShader(prog, fs);
10690
+ gl.linkProgram(prog);
10691
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
10692
+ console.error('MIP program link error:', gl.getProgramInfoLog(prog));
10693
+ return null;
10694
+ }
10695
+ _mipProgram = prog;
10696
+
10697
+ // Full-screen quad VAO
10698
+ _mipVAO = gl.createVertexArray();
10699
+ gl.bindVertexArray(_mipVAO);
10700
+ const buf = gl.createBuffer();
10701
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
10702
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
10703
+ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1
10704
+ ]), gl.STATIC_DRAW);
10705
+ const loc = gl.getAttribLocation(prog, 'a_pos');
10706
+ gl.enableVertexAttribArray(loc);
10707
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
10708
+ gl.bindVertexArray(null);
10709
+
10710
+ // Create LUT texture
10711
+ _mipLutTex = gl.createTexture();
10712
+ gl.activeTexture(gl.TEXTURE1);
10713
+ gl.bindTexture(gl.TEXTURE_2D, _mipLutTex);
10714
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
10715
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
10716
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
10717
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
10718
+
10719
+ return gl;
10720
+ }
10721
+
10722
+ function _mipBuildLUT() {
10723
+ const gl = _mipGL;
10724
+ if (!gl || !_mipLutTex) return;
10725
+ const stops = colormap_idx === -1 ? customGradientStops : COLORMAP_GRADIENT_STOPS[COLORMAPS[colormap_idx]];
10726
+ if (!stops || !stops.length) return;
10727
+
10728
+ // Build 256-wide RGBA LUT from gradient stops
10729
+ // Stops are [r, g, b] triplets evenly spaced across the colormap
10730
+ const lutSize = 256;
10731
+ const data = new Uint8Array(lutSize * 4);
10732
+ const n = stops.length;
10733
+
10734
+ for (let i = 0; i < lutSize; i++) {
10735
+ const t = i / (lutSize - 1) * (n - 1);
10736
+ const lo = Math.floor(t);
10737
+ const hi = Math.min(lo + 1, n - 1);
10738
+ const f = t - lo;
10739
+ data[i * 4 + 0] = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
10740
+ data[i * 4 + 1] = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
10741
+ data[i * 4 + 2] = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
10742
+ data[i * 4 + 3] = 255;
10743
+ }
10744
+
10745
+ gl.activeTexture(gl.TEXTURE1);
10746
+ gl.bindTexture(gl.TEXTURE_2D, _mipLutTex);
10747
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, lutSize, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
10748
+ }
10749
+
10750
+ function _mipUploadVolume(float32Data, shape) {
10751
+ const gl = _mipGL;
10752
+ if (!gl) return;
10753
+ if (_mipVolTex) gl.deleteTexture(_mipVolTex);
10754
+ _mipVolTex = gl.createTexture();
10755
+ _mipVolShape = shape;
10756
+ gl.activeTexture(gl.TEXTURE0);
10757
+ gl.bindTexture(gl.TEXTURE_3D, _mipVolTex);
10758
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
10759
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
10760
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
10761
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
10762
+ gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
10763
+ gl.texImage3D(gl.TEXTURE_3D, 0, gl.R32F,
10764
+ shape[2], shape[1], shape[0], // width=Z, height=Y, depth=X → texture(tc) where tc.xyz maps to [col, row, slice]
10765
+ 0, gl.RED, gl.FLOAT, float32Data);
10766
+ }
10767
+
10768
+ function _mipRender() {
10769
+ const gl = _mipGL;
10770
+ if (!gl || !_mipProgram || !_mipVolTex) return;
10771
+
10772
+ const canvas = document.getElementById('mip-canvas');
10773
+ const dpr = window.devicePixelRatio || 1;
10774
+ const w = canvas.clientWidth;
10775
+ const h = canvas.clientHeight;
10776
+ if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
10777
+ canvas.width = w * dpr;
10778
+ canvas.height = h * dpr;
10779
+ }
10780
+ gl.viewport(0, 0, canvas.width, canvas.height);
10781
+
10782
+ gl.clearColor(0, 0, 0, 1);
10783
+ gl.clear(gl.COLOR_BUFFER_BIT);
10784
+
10785
+ gl.useProgram(_mipProgram);
10786
+
10787
+ // Volume size: normalize so longest side = 1
10788
+ // Texture layout: width=shape[2], height=shape[1], depth=shape[0]
10789
+ // World-space axes: x=width(shape[2]), y=height(shape[1]), z=depth(shape[0])
10790
+ const s = _mipVolShape;
10791
+ const maxS = Math.max(s[0], s[1], s[2]);
10792
+ const volSize = [s[2] / maxS, s[1] / maxS, s[0] / maxS];
10793
+
10794
+ // Uniforms
10795
+ const rotMat = _mipBuildRotationMatrix(_mipAzimuth, _mipElevation);
10796
+
10797
+ gl.uniform3f(gl.getUniformLocation(_mipProgram, 'u_volSize'), volSize[0], volSize[1], volSize[2]);
10798
+ gl.uniform1f(gl.getUniformLocation(_mipProgram, 'u_vmin'), manualVmin ?? currentVmin);
10799
+ gl.uniform1f(gl.getUniformLocation(_mipProgram, 'u_vmax'), manualVmax ?? currentVmax);
10800
+ gl.uniformMatrix3fv(gl.getUniformLocation(_mipProgram, 'u_rotation'), false, rotMat);
10801
+ gl.uniform1f(gl.getUniformLocation(_mipProgram, 'u_camDist'), _mipZoom);
10802
+ gl.uniform1i(gl.getUniformLocation(_mipProgram, 'u_steps'), 256);
10803
+
10804
+ gl.activeTexture(gl.TEXTURE0);
10805
+ gl.bindTexture(gl.TEXTURE_3D, _mipVolTex);
10806
+ gl.uniform1i(gl.getUniformLocation(_mipProgram, 'u_volume'), 0);
10807
+
10808
+ gl.activeTexture(gl.TEXTURE1);
10809
+ gl.bindTexture(gl.TEXTURE_2D, _mipLutTex);
10810
+ gl.uniform1i(gl.getUniformLocation(_mipProgram, 'u_lut'), 1);
10811
+
10812
+ gl.bindVertexArray(_mipVAO);
10813
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
10814
+ gl.bindVertexArray(null);
10815
+ }
10816
+
10817
+ function _mipResizeCanvas() {
10818
+ const canvas = document.getElementById('mip-canvas');
10819
+ const row = document.getElementById('viewer-row');
10820
+ const w = Math.max(100, window.innerWidth - 80);
10821
+ const h = Math.max(100, window.innerHeight - uiReserveV() - 80);
10822
+ // Set viewer-row to contain the canvas (it collapses when mv-wrap is hidden)
10823
+ row.style.minHeight = h + 'px';
10824
+ canvas.style.width = w + 'px';
10825
+ canvas.style.height = h + 'px';
10826
+ _mipRender();
10827
+ }
10828
+
10829
+ function _mipCacheKey() {
10830
+ const dims = mvDims.join(',');
10831
+ const extraIndices = indices.map((v, i) => mvDims.includes(i) ? '' : v).join(',');
10832
+ return `${sid}:${dims}:${extraIndices}:${_watchDataVersion}`;
10833
+ }
10834
+
10835
+ async function enterMipMode() {
10836
+ if (mipActive) return;
10837
+ if (!multiViewActive) { showStatus('MIP: enter multiview first (v)'); return; }
10838
+ if (shape.length < 3) { showStatus('MIP: need at least 3D array'); return; }
10839
+
10840
+ mipActive = true;
10841
+ _eggsVisible = true;
10842
+
10843
+ // Hide multiview panes, show MIP canvas
10844
+ const wrap = document.getElementById('multi-view-wrap');
10845
+ wrap.style.display = 'none';
10846
+ const canvas = document.getElementById('mip-canvas');
10847
+ canvas.style.display = 'block';
10848
+
10849
+ // Show shared colorbar for MIP mode (mv-cb is inside hidden wrap)
10850
+ _reconcileCbVisibility();
10851
+ drawSlimColorbar(cbMarkerFrac);
10852
+
10853
+ showStatus('Loading volume...');
10854
+ renderEggs();
10855
+
10856
+ // Init WebGL
10857
+ const gl = _mipInitGL();
10858
+ if (!gl) { exitMipMode(); return; }
10859
+
10860
+ // Fetch volume data
10861
+ const cacheKey = _mipCacheKey();
10862
+ if (_mipVolCacheKey !== cacheKey || !_mipVolTex) {
10863
+ try {
10864
+ const dims = mvDims.join(',');
10865
+ const idxStr = indices.join(',');
10866
+ const resp = await proxyFetch(`/volume_data/${sid}?dims=${dims}&indices=${idxStr}&complex_mode=${complexMode}`);
10867
+ if (!resp.ok) { showStatus('MIP: failed to load volume'); exitMipMode(); return; }
10868
+ const shapeStr = resp.headers.get('X-Shape');
10869
+ const vmin = parseFloat(resp.headers.get('X-Vmin'));
10870
+ const vmax = parseFloat(resp.headers.get('X-Vmax'));
10871
+ const volShape = shapeStr.split(',').map(Number);
10872
+ const buf = await resp.arrayBuffer();
10873
+ const f32 = new Float32Array(buf);
10874
+
10875
+ _mipUploadVolume(f32, volShape);
10876
+ _mipVolCacheKey = cacheKey;
10877
+
10878
+ // Set auto vmin/vmax if not manually overridden
10879
+ if (manualVmin === null) currentVmin = vmin;
10880
+ if (manualVmax === null) currentVmax = vmax;
10881
+ } catch (err) {
10882
+ console.error('MIP volume fetch error:', err);
10883
+ showStatus('MIP: volume load error');
10884
+ exitMipMode();
10885
+ return;
10886
+ }
10887
+ }
10888
+
10889
+ _mipBuildLUT();
10890
+ _mipResizeCanvas();
10891
+
10892
+ // Attach mouse handlers
10893
+ _mipAttachEvents();
10894
+
10895
+ showStatus('MIP — drag to rotate, scroll to zoom, p/Esc to exit', 3000);
10896
+ }
10897
+
10898
+ function exitMipMode() {
10899
+ if (!mipActive) return;
10900
+ mipActive = false;
10901
+
10902
+ const canvas = document.getElementById('mip-canvas');
10903
+ canvas.style.display = 'none';
10904
+ _mipDetachEvents();
10905
+
10906
+ if (_mipAnimFrame) { cancelAnimationFrame(_mipAnimFrame); _mipAnimFrame = null; }
10907
+
10908
+ // Show multiview again
10909
+ const wrap = document.getElementById('multi-view-wrap');
10910
+ wrap.style.display = '';
10911
+ document.getElementById('viewer-row').style.minHeight = '';
10912
+
10913
+ // Hide shared cb, restore mv colorbar
10914
+ _reconcileCbVisibility();
10915
+
10916
+ _eggsVisible = true;
10917
+ renderEggs();
10918
+
10919
+ // Re-render multiview panes
10920
+ for (const v of mvViews) mvRender(v);
10921
+ if (window._mvColorBar) window._mvColorBar.draw();
10922
+
10923
+ showStatus('MIP: off');
10924
+ }
10925
+
10926
+ // Mouse interaction
10927
+ let _mipEventsBound = false;
10928
+ function _mipAttachEvents() {
10929
+ if (_mipEventsBound) return;
10930
+ const canvas = document.getElementById('mip-canvas');
10931
+ canvas.addEventListener('mousedown', _mipOnMouseDown);
10932
+ canvas.addEventListener('mousemove', _mipOnMouseMove);
10933
+ canvas.addEventListener('mouseup', _mipOnMouseUp);
10934
+ canvas.addEventListener('mouseleave', _mipOnMouseUp);
10935
+ canvas.addEventListener('wheel', _mipOnWheel, { passive: false });
10936
+ canvas.addEventListener('contextmenu', _mipPreventDefault);
10937
+ // Touch support
10938
+ canvas.addEventListener('touchstart', _mipOnTouchStart, { passive: false });
10939
+ canvas.addEventListener('touchmove', _mipOnTouchMove, { passive: false });
10940
+ canvas.addEventListener('touchend', _mipOnTouchEnd);
10941
+ _mipEventsBound = true;
10942
+ }
10943
+ function _mipDetachEvents() {
10944
+ if (!_mipEventsBound) return;
10945
+ const canvas = document.getElementById('mip-canvas');
10946
+ canvas.removeEventListener('mousedown', _mipOnMouseDown);
10947
+ canvas.removeEventListener('mousemove', _mipOnMouseMove);
10948
+ canvas.removeEventListener('mouseup', _mipOnMouseUp);
10949
+ canvas.removeEventListener('mouseleave', _mipOnMouseUp);
10950
+ canvas.removeEventListener('wheel', _mipOnWheel);
10951
+ canvas.removeEventListener('contextmenu', _mipPreventDefault);
10952
+ canvas.removeEventListener('touchstart', _mipOnTouchStart);
10953
+ canvas.removeEventListener('touchmove', _mipOnTouchMove);
10954
+ canvas.removeEventListener('touchend', _mipOnTouchEnd);
10955
+ _mipEventsBound = false;
10956
+ }
10957
+ function _mipPreventDefault(e) { e.preventDefault(); e.stopPropagation(); }
10958
+ function _mipOnMouseDown(e) {
10959
+ e.preventDefault(); e.stopPropagation();
10960
+ _mipDragging = true;
10961
+ _mipDragX = e.clientX;
10962
+ _mipDragY = e.clientY;
10963
+ }
10964
+ function _mipOnMouseMove(e) {
10965
+ if (!_mipDragging) return;
10966
+ e.preventDefault(); e.stopPropagation();
10967
+ const dx = e.clientX - _mipDragX;
10968
+ const dy = e.clientY - _mipDragY;
10969
+ _mipDragX = e.clientX;
10970
+ _mipDragY = e.clientY;
10971
+ _mipAzimuth += dx * 0.008;
10972
+ _mipElevation = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, _mipElevation + dy * 0.008));
10973
+ _mipRender();
10974
+ }
10975
+ function _mipOnMouseUp(e) {
10976
+ _mipDragging = false;
10977
+ }
10978
+ function _mipOnWheel(e) {
10979
+ e.preventDefault(); e.stopPropagation();
10980
+ _mipZoom = Math.max(0.5, Math.min(10.0, _mipZoom + e.deltaY * 0.005));
10981
+ _mipRender();
10982
+ }
10983
+ // Touch events
10984
+ let _mipTouchPrev = null;
10985
+ let _mipTouchDist = null;
10986
+ function _mipOnTouchStart(e) {
10987
+ e.preventDefault(); e.stopPropagation();
10988
+ if (e.touches.length === 1) {
10989
+ _mipTouchPrev = { x: e.touches[0].clientX, y: e.touches[0].clientY };
10990
+ } else if (e.touches.length === 2) {
10991
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
10992
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
10993
+ _mipTouchDist = Math.sqrt(dx * dx + dy * dy);
10994
+ }
10995
+ }
10996
+ function _mipOnTouchMove(e) {
10997
+ e.preventDefault(); e.stopPropagation();
10998
+ if (e.touches.length === 1 && _mipTouchPrev) {
10999
+ const dx = e.touches[0].clientX - _mipTouchPrev.x;
11000
+ const dy = e.touches[0].clientY - _mipTouchPrev.y;
11001
+ _mipTouchPrev = { x: e.touches[0].clientX, y: e.touches[0].clientY };
11002
+ _mipAzimuth += dx * 0.008;
11003
+ _mipElevation = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, _mipElevation + dy * 0.008));
11004
+ _mipRender();
11005
+ } else if (e.touches.length === 2 && _mipTouchDist !== null) {
11006
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
11007
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
11008
+ const dist = Math.sqrt(dx * dx + dy * dy);
11009
+ const delta = _mipTouchDist - dist;
11010
+ _mipZoom = Math.max(0.5, Math.min(10.0, _mipZoom + delta * 0.01));
11011
+ _mipTouchDist = dist;
11012
+ _mipRender();
11013
+ }
11014
+ }
11015
+ function _mipOnTouchEnd(e) {
11016
+ _mipTouchPrev = null;
11017
+ _mipTouchDist = null;
11018
+ }
11019
+
11020
+ // Called when colormap changes to update the LUT texture
11021
+ function _mipOnColormapChange() {
11022
+ if (!mipActive || !_mipGL) return;
11023
+ _mipBuildLUT();
11024
+ _mipRender();
11025
+ }
11026
+
11027
+ // Called when vmin/vmax changes to re-render
11028
+ function _mipOnWindowChange() {
11029
+ if (!mipActive || !_mipGL) return;
11030
+ _mipRender();
11031
+ }
11032
+
11033
+ // ═══════════════════════════════════════════════════════════
11034
+ // ── End MIP renderer ─────────────────────────────────────
11035
+ // ═══════════════════════════════════════════════════════════
11036
+
10542
11037
  // --------------- qMRI mode ---------------
10543
11038
  function enterQmri() {
10544
11039
  // Auto-detect: if exactly one dim has size 3–6, use it; otherwise fall back to activeDim
@@ -13631,7 +14126,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13631
14126
 
13632
14127
  // 1. Shared colorbar
13633
14128
  _sharedCbVisible = colorbarVisible() && !isCenter
13634
- && !multiViewActive && !compareMvActive
14129
+ && (!multiViewActive || mipActive) && !compareMvActive
13635
14130
  && !qmriActive && !compareQmriActive;
13636
14131
  const cbWrap = document.getElementById('slim-cb-wrap');
13637
14132
  if (cbWrap) cbWrap.style.display = _sharedCbVisible ? '' : 'none';
@@ -13667,25 +14162,6 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13667
14162
  const _cmapStripRight = document.getElementById('colormap-strip-right');
13668
14163
  let _cmapStripTimer = null;
13669
14164
  let _cmapStripMode = 'normal'; // 'normal' | 'diff-center' | 'diff-side'
13670
- let _cmapInIsland = false; // true when preview is rendered inside the island
13671
- function _cmapIslandRestore() {
13672
- if (!_cmapInIsland) return;
13673
- _cmapInIsland = false;
13674
- const inlinePreview = document.getElementById('cmap-island-preview');
13675
- if (inlinePreview) inlinePreview.remove();
13676
- const cv = document.getElementById('slim-cb');
13677
- const triZone = document.getElementById('slim-cb-tri-zone');
13678
- const vminEl = document.getElementById('slim-cb-vmin');
13679
- const vmaxEl = document.getElementById('slim-cb-vmax');
13680
- if (cv) cv.style.display = '';
13681
- if (triZone) triZone.style.display = '';
13682
- // Only show flanking values if histogram is collapsed
13683
- if (vminEl) vminEl.style.display = primaryCb._expanded ? 'none' : '';
13684
- if (vmaxEl) vmaxEl.style.display = primaryCb._expanded ? 'none' : '';
13685
- // Redraw to restore correct state
13686
- _syncPrimaryCb();
13687
- primaryCb.draw();
13688
- }
13689
14165
  // anchorEl: optional DOM element to position the strip on (instead of default colorbar)
13690
14166
  // extraFadeEls: optional array of extra DOM elements to fade out alongside the anchor
13691
14167
  /* ── JS: Colormap Strip and Wipe/Flicker Compare Tools ───── */
@@ -13694,21 +14170,10 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13694
14170
  if (!_cmapStrip || !COLORMAP_GRADIENT_STOPS) return;
13695
14171
  const pool = overridePool || COLORMAPS;
13696
14172
  const ci = overrideName ? pool.indexOf(overrideName) : (colormap_idx === -1 ? 0 : colormap_idx);
13697
- // Compute how many thumbs fit in available width (~76px per thumb incl gap)
14173
+ // Compute how many thumbs fit never wider than the colorbar
13698
14174
  const THUMB_W = 76;
13699
- let availW = 999;
13700
- const useIslandEarly = !anchorEl && !multiViewActive;
13701
- if (useIslandEarly) {
13702
- const col = document.getElementById('slim-cb-col');
13703
- if (col) availW = col.offsetWidth;
13704
- } else if (_fullscreenActive && compareActive) {
13705
- const firstInner = anchorEl ? anchorEl.closest('.compare-canvas-inner') : document.querySelector('.compare-canvas-inner');
13706
- const vpW = firstInner && firstInner.dataset.vpW ? parseInt(firstInner.dataset.vpW) : 0;
13707
- if (vpW > 0) availW = vpW;
13708
- } else if (_fullscreenActive) {
13709
- const vpEl = document.getElementById('canvas-viewport');
13710
- if (vpEl) availW = vpEl.offsetWidth;
13711
- }
14175
+ const cbWrap = anchorEl || document.getElementById(multiViewActive ? 'mv-cb-wrap' : 'slim-cb-wrap');
14176
+ const availW = cbWrap ? cbWrap.getBoundingClientRect().width : 999;
13712
14177
  const rawThumbs = Math.max(1, Math.floor(availW / THUMB_W));
13713
14178
  const maxThumbs = rawThumbs % 2 === 0 ? rawThumbs - 1 : rawThumbs;
13714
14179
  const SIDE = Math.floor((maxThumbs - 1) / 2);
@@ -13743,37 +14208,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13743
14208
  _cmapStrip.appendChild(_makeThumb(ci + offs, offs === 0));
13744
14209
  }
13745
14210
 
13746
- // --- Normal mode: render inside the island instead of floating ---
13747
- const useIsland = !anchorEl && !multiViewActive;
13748
- if (useIsland) {
13749
- _cmapIslandRestore(); // clean up any previous inline preview
13750
- const col = document.getElementById('slim-cb-col');
13751
- const cv = document.getElementById('slim-cb');
13752
- const triZone = document.getElementById('slim-cb-tri-zone');
13753
- const vminEl = document.getElementById('slim-cb-vmin');
13754
- const vmaxEl = document.getElementById('slim-cb-vmax');
13755
- // Hide normal colorbar contents
13756
- if (cv) cv.style.display = 'none';
13757
- if (triZone) triZone.style.display = 'none';
13758
- if (vminEl) vminEl.style.display = 'none';
13759
- if (vmaxEl) vmaxEl.style.display = 'none';
13760
- // Create inline preview container and move strip content into it
13761
- const preview = document.createElement('div');
13762
- preview.id = 'cmap-island-preview';
13763
- preview.style.cssText = 'display:flex;flex-direction:row;align-items:center;justify-content:center;gap:8px;width:100%;flex:1;overflow:hidden;mask-image:linear-gradient(to right, transparent, black 15%, black 85%, transparent);-webkit-mask-image:linear-gradient(to right, transparent, black 15%, black 85%, transparent);';
13764
- while (_cmapStrip.firstChild) {
13765
- preview.appendChild(_cmapStrip.firstChild);
13766
- }
13767
- col.appendChild(preview);
13768
- _cmapInIsland = true;
13769
- clearTimeout(_cmapStripTimer);
13770
- _cmapStripTimer = setTimeout(() => {
13771
- _cmapIslandRestore();
13772
- }, 1500);
13773
- return;
13774
- }
13775
-
13776
- // --- Anchored / multiview modes: floating strip (original behavior) ---
14211
+ // --- Floating strip positioned on the colorbar (or custom anchor) ---
13777
14212
  const hasRightAnchor = extraFadeEls && extraFadeEls.length > 0 && extraFadeEls[0] && _cmapStripRight;
13778
14213
  if (hasRightAnchor) {
13779
14214
  for (let offs = -SIDE; offs <= SIDE; offs++) {
@@ -13781,21 +14216,22 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13781
14216
  }
13782
14217
  }
13783
14218
  // Position strip centered on the colorbar (or custom anchor)
13784
- const cbWrap = anchorEl || document.getElementById(multiViewActive ? 'mv-cb-wrap' : 'slim-cb-wrap');
13785
14219
  if (cbWrap) {
13786
14220
  const cbRect = cbWrap.getBoundingClientRect();
13787
- _cmapStrip.style.top = (cbRect.top + cbRect.height / 2 - 16) + 'px';
14221
+ _cmapStrip.style.top = (cbRect.top + (cbRect.height - 32) / 2) + 'px';
13788
14222
  _cmapStrip.style.left = (cbRect.left + cbRect.width / 2) + 'px';
13789
14223
  _cmapStrip.style.transform = 'translateX(-50%)';
14224
+ _cmapStrip.style.maxWidth = cbRect.width + 'px';
13790
14225
  cbWrap.style.opacity = '0';
13791
14226
  }
13792
14227
  // Position right strip on the right pane's colorbar
13793
14228
  if (hasRightAnchor) {
13794
14229
  const rightAnchor = extraFadeEls[0];
13795
14230
  const rRect = rightAnchor.getBoundingClientRect();
13796
- _cmapStripRight.style.top = (rRect.top + rRect.height / 2 - 16) + 'px';
14231
+ _cmapStripRight.style.top = (rRect.top + (rRect.height - 32) / 2) + 'px';
13797
14232
  _cmapStripRight.style.left = (rRect.left + rRect.width / 2) + 'px';
13798
14233
  _cmapStripRight.style.transform = 'translateX(-50%)';
14234
+ _cmapStripRight.style.maxWidth = rRect.width + 'px';
13799
14235
  rightAnchor.style.opacity = '0';
13800
14236
  for (let i = 1; i < extraFadeEls.length; i++) {
13801
14237
  if (extraFadeEls[i]) extraFadeEls[i].style.opacity = '0';
@@ -44,7 +44,7 @@ wheels = [
44
44
 
45
45
  [[package]]
46
46
  name = "arrayview"
47
- version = "0.8.0"
47
+ version = "0.9.0"
48
48
  source = { editable = "." }
49
49
  dependencies = [
50
50
  { name = "fastapi" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes