arrayview 0.46.2__tar.gz → 0.47.1__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 (116) hide show
  1. {arrayview-0.46.2 → arrayview-0.47.1}/PKG-INFO +1 -1
  2. {arrayview-0.46.2 → arrayview-0.47.1}/pyproject.toml +1 -1
  3. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_io.py +41 -0
  4. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_launcher.py +6 -0
  5. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_loading.py +15 -0
  6. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_session.py +6 -0
  7. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_viewer.html +287 -74
  8. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_vscode_extension.py +1 -1
  9. arrayview-0.47.1/src/arrayview/arrayview-opener.vsix +0 -0
  10. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_api.py +20 -0
  11. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_nifti_meta.py +23 -0
  12. arrayview-0.46.2/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.46.2 → arrayview-0.47.1}/.gitignore +0 -0
  14. {arrayview-0.46.2 → arrayview-0.47.1}/LICENSE +0 -0
  15. {arrayview-0.46.2 → arrayview-0.47.1}/README.md +0 -0
  16. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/ARCHITECTURE.md +0 -0
  17. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/__init__.py +0 -0
  18. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/__main__.py +0 -0
  19. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_analysis.py +0 -0
  20. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_app.py +0 -0
  21. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_bootstrap_app.py +0 -0
  22. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_config.py +0 -0
  23. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_diagnostics.py +0 -0
  24. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_dicom.py +0 -0
  25. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_diff.py +0 -0
  26. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_extra_ports.py +0 -0
  27. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_icon.png +0 -0
  28. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_imaging.py +0 -0
  29. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_instance_registry.py +0 -0
  30. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_launch_plan.py +0 -0
  31. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_launch_trace.py +0 -0
  32. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_lifecycle.py +0 -0
  33. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_overlays.py +0 -0
  34. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_platform.py +0 -0
  35. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_render.py +0 -0
  36. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_analysis.py +0 -0
  37. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_drop.py +0 -0
  38. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_export.py +0 -0
  39. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_persistence.py +0 -0
  40. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_preferences.py +0 -0
  41. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_preload.py +0 -0
  42. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_query.py +0 -0
  43. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_rendering.py +0 -0
  44. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_segmentation.py +0 -0
  45. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_state.py +0 -0
  46. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_vectorfield.py +0 -0
  47. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_routes_websocket.py +0 -0
  48. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_server.py +0 -0
  50. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_source_safety.py +0 -0
  53. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_synthetic_mri.py +0 -0
  54. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_tutorial.py +0 -0
  56. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_vectorfield.py +0 -0
  57. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_vscode.py +0 -0
  58. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_vscode_browser.py +0 -0
  59. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_vscode_signal.py +0 -0
  60. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/_watch.py +0 -0
  61. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/gsap.min.js +0 -0
  62. {arrayview-0.46.2 → arrayview-0.47.1}/src/arrayview/todo.md +0 -0
  63. {arrayview-0.46.2 → arrayview-0.47.1}/tests/capture_patient_loading.py +0 -0
  64. {arrayview-0.46.2 → arrayview-0.47.1}/tests/capture_v_animation.py +0 -0
  65. {arrayview-0.46.2 → arrayview-0.47.1}/tests/conftest.py +0 -0
  66. {arrayview-0.46.2 → arrayview-0.47.1}/tests/lifecycle_matrix.py +0 -0
  67. {arrayview-0.46.2 → arrayview-0.47.1}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_1d_arrays.py +0 -0
  69. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_bootstrap_app.py +0 -0
  71. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_browser.py +0 -0
  72. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_cli.py +0 -0
  73. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_colorbar_hover_highlight.py +0 -0
  74. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_config.py +0 -0
  75. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_dicom.py +0 -0
  76. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_display_opener_results.py +0 -0
  77. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_doctor_commands.py +0 -0
  78. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_drop_import.py +0 -0
  79. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_instance_registry.py +0 -0
  80. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_invocation_contract_matrix.py +0 -0
  81. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_io.py +0 -0
  82. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_large_arrays.py +0 -0
  83. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_launch_blackbox.py +0 -0
  84. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_launch_diagnostics.py +0 -0
  85. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_launch_execution_ownership.py +0 -0
  86. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_launch_plan.py +0 -0
  87. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_launch_trace.py +0 -0
  88. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_lifecycle_contract.py +0 -0
  89. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_loading_server.py +0 -0
  90. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_mode_consistency.py +0 -0
  91. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_mode_matrix.py +0 -0
  92. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_mode_roundtrip.py +0 -0
  93. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_nifti_series.py +0 -0
  94. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_preferences_ui.py +0 -0
  95. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_public_invocation_regressions.py +0 -0
  96. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_render_failure_reporting.py +0 -0
  97. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_render_progress.py +0 -0
  98. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_rgb_pixel_art.py +0 -0
  99. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_roi_hud.py +0 -0
  100. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_safe_server_stop.py +0 -0
  101. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_server_identity_status.py +0 -0
  102. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_session_spec.py +0 -0
  103. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_source_safety.py +0 -0
  104. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_spawn_ownership.py +0 -0
  105. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_torch.py +0 -0
  106. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_tunnel_warm_path.py +0 -0
  107. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_tutorial.py +0 -0
  108. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_tutorial_browser.py +0 -0
  109. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_value_formatting.py +0 -0
  110. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_view_handle_lifecycle.py +0 -0
  111. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_view_launch_execution.py +0 -0
  112. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_vscode_ack_protocol.py +0 -0
  113. {arrayview-0.46.2 → arrayview-0.47.1}/tests/test_window_level_preview.py +0 -0
  114. {arrayview-0.46.2 → arrayview-0.47.1}/tests/ui_audit.py +0 -0
  115. {arrayview-0.46.2 → arrayview-0.47.1}/tests/v_anim_frames/.gitkeep +0 -0
  116. {arrayview-0.46.2 → arrayview-0.47.1}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.46.2
3
+ Version: 0.47.1
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.46.2"
7
+ version = "0.47.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -1863,3 +1863,44 @@ def _peek_file_shape(fpath: str, ext: str):
1863
1863
  except Exception:
1864
1864
  pass
1865
1865
  return None
1866
+
1867
+
1868
+ def nifti_grid_meta(filepath):
1869
+ """Return a NIfTI file's native-order spatial metadata (header only), or None."""
1870
+ if not (filepath.endswith(".nii") or filepath.endswith(".nii.gz")):
1871
+ return None
1872
+ return _nifti_header_with_meta(filepath, native=True)[1]
1873
+
1874
+
1875
+ def align_overlay_to_base(ov_path, ov_data, base_meta):
1876
+ """Resample a NIfTI overlay onto the base volume's voxel grid.
1877
+
1878
+ Only used when the overlay's spatial shape differs from the base, which
1879
+ otherwise leaves the overlay undisplayable. Both files' affines place the
1880
+ voxels in world space; label data uses nearest-neighbour so mask values
1881
+ stay exact. Returns ``ov_data`` unchanged when alignment does not apply.
1882
+ """
1883
+ if not base_meta or not (ov_path.endswith(".nii") or ov_path.endswith(".nii.gz")):
1884
+ return ov_data
1885
+ base_shape = base_meta.get("spatial_shape")
1886
+ base_affine = base_meta.get("data_affine")
1887
+ if base_shape is None or base_affine is None:
1888
+ return ov_data
1889
+ ov_shape = tuple(int(v) for v in ov_data.shape)
1890
+ if len(ov_shape) != 3 or ov_shape == tuple(base_shape):
1891
+ return ov_data
1892
+ from scipy.ndimage import affine_transform
1893
+
1894
+ ov_affine = np.asarray(_nib().load(ov_path).affine, dtype=np.float64)
1895
+ base_to_ov = np.linalg.inv(ov_affine) @ np.asarray(base_affine, dtype=np.float64)
1896
+ src = np.asarray(ov_data)
1897
+ is_label = np.issubdtype(src.dtype, np.integer) or src.dtype == bool
1898
+ return affine_transform(
1899
+ src.astype(np.float32) if not is_label else src,
1900
+ base_to_ov[:3, :3],
1901
+ offset=base_to_ov[:3, 3],
1902
+ output_shape=tuple(base_shape),
1903
+ order=0 if is_label else 1,
1904
+ mode="constant",
1905
+ cval=0,
1906
+ )
@@ -1314,6 +1314,7 @@ def _load_session_from_filepath(
1314
1314
  watch: bool = False,
1315
1315
  initial_view: dict | None = None,
1316
1316
  code_source: dict | None = None,
1317
+ align_to: str | None = None,
1317
1318
  ) -> dict:
1318
1319
  requested_sid = uuid.uuid4().hex
1319
1320
  payload = {
@@ -1329,6 +1330,8 @@ def _load_session_from_filepath(
1329
1330
  }
1330
1331
  if source_staging_dir:
1331
1332
  payload["source_staging_dir"] = source_staging_dir
1333
+ if align_to:
1334
+ payload["align_to"] = align_to
1332
1335
  if watch:
1333
1336
  payload["watch"] = True
1334
1337
  if initial_view:
@@ -1686,6 +1689,7 @@ def _register_cli_session_with_existing_server_impl(
1686
1689
  ov_name,
1687
1690
  expected_server_id=expected_server_id,
1688
1691
  source_staging_dir=(source_staging_dirs or {}).get(ov_path),
1692
+ align_to=os.path.abspath(base_file),
1689
1693
  )
1690
1694
  if "error" in ov_result:
1691
1695
  raise RuntimeError(
@@ -4803,6 +4807,7 @@ def _serve_daemon(
4803
4807
  array_choice_placeholder,
4804
4808
  load_data,
4805
4809
  load_data_with_meta,
4810
+ align_overlay_to_base,
4806
4811
  load_dir_collection,
4807
4812
  list_array_keys,
4808
4813
  )
@@ -4928,6 +4933,7 @@ def _serve_daemon(
4928
4933
  ):
4929
4934
  try:
4930
4935
  ov_data = load_data(ov_path)
4936
+ ov_data = align_overlay_to_base(ov_path, ov_data, spatial_meta)
4931
4937
  ov_session = _session_mod.Session(
4932
4938
  ov_data, filepath=ov_path, name=ov_name
4933
4939
  )
@@ -96,10 +96,12 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
96
96
  result = []
97
97
  for session in SESSIONS.values():
98
98
  dtype_str = str(getattr(session.data, "dtype", "unknown"))
99
+ code = getattr(session, "code_source", None) or {}
99
100
  result.append(
100
101
  {
101
102
  "sid": session.sid,
102
103
  "name": session.name,
104
+ "var": code.get("name") or None,
103
105
  "shape": [int(x) for x in session.shape],
104
106
  "filepath": session.filepath,
105
107
  "dtype": dtype_str,
@@ -275,6 +277,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
275
277
  and os.path.abspath(existing.filepath) == abs_path
276
278
  and current_signature is not None
277
279
  and getattr(existing, "file_signature", None) == current_signature
280
+ and getattr(existing, "aligned_to", None) == body.get("align_to")
278
281
  ):
279
282
  if acquire_session_leases([existing.sid]):
280
283
  return {"sid": existing.sid, "name": existing.name, "notified": False}
@@ -456,6 +459,17 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
456
459
  key=_key,
457
460
  select=body.get("select"),
458
461
  )
462
+ if body.get("align_to"):
463
+ from ._io import align_overlay_to_base, nifti_grid_meta
464
+
465
+ base_meta = await asyncio.to_thread(
466
+ nifti_grid_meta, str(body["align_to"])
467
+ )
468
+ aligned = await asyncio.to_thread(
469
+ align_overlay_to_base, filepath, data, base_meta
470
+ )
471
+ if aligned is not data:
472
+ data, spatial_meta = aligned, base_meta
459
473
  except Exception as e:
460
474
  if staging_dir:
461
475
  cleanup_staging_directory(staging_dir)
@@ -477,6 +491,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
477
491
  str(value) for value in body.get("related_sids", [])
478
492
  ]
479
493
  apply_launch_view(session, body)
494
+ session.aligned_to = body.get("align_to")
480
495
  if staging_dir:
481
496
  session._source_staging_dirs = [str(staging_dir)]
482
497
  if not dir_patterns:
@@ -765,6 +765,12 @@ def upgrade_memmap_in_background(session) -> bool:
765
765
  from ._io import _nifti_display_array
766
766
 
767
767
  loaded = _nifti_display_array(data)
768
+ # An uncompressed proxy whose dtype needs no conversion comes
769
+ # back as a view of the file map, so nothing was read yet and
770
+ # every new orientation would still fault from disk. Copy in
771
+ # file order to make it one sequential read.
772
+ if not loaded.flags.owndata:
773
+ loaded = np.array(loaded, copy=True, order="K")
768
774
  except Exception:
769
775
  return # keep serving from the lazy source
770
776
  # A /reload or a released session must not be clobbered by a read that
@@ -236,22 +236,24 @@
236
236
  border-radius: 10px;
237
237
  overflow: hidden;
238
238
  }
239
- /* Orientation arrow — hovering a dim label in the DimBar shows a
240
- small arrow near that dimension's zero point, pointing toward
241
- increasing values. See _showOrientEdgeHighlight(). */
242
- .orient-arrow {
243
- position: absolute; width: 32px; height: 32px;
244
- pointer-events: none; z-index: 5;
239
+ /* Orientation cross — hovering the DimBar (or pressing r / t) shows a
240
+ small axis cross in the origin corner of every image pane, one arrow
241
+ per in-plane direction with its letter. See _showOrientEdgeHighlight().
242
+ Yellow with a black outline so it reads on bright and dark image
243
+ content alike. */
244
+ .orient-cross {
245
+ position: fixed; left: 0; top: 0; width: 64px; height: 64px;
246
+ transform-origin: 0 0; pointer-events: none; z-index: 60;
245
247
  opacity: 0; transition: opacity 0.15s ease;
246
248
  }
247
- .orient-arrow.showing { opacity: 0.9; }
248
- /* Dark halo so the arrow reads against bright/washed-out image content
249
- regardless of the active colormap (same technique used elsewhere
250
- for on-canvas markers). A single polygon (not a separate shaft +
251
- head) so the outline wraps one shape with no seam down the middle. */
252
- .orient-arrow polygon {
253
- paint-order: stroke;
254
- stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round;
249
+ .orient-cross.showing { opacity: 1; }
250
+ .orient-cross .oc-halo { stroke: rgba(0, 0, 0, 0.7); stroke-width: 4px; fill: none; stroke-linecap: round; }
251
+ .orient-cross .oc-ln { stroke: var(--active-dim); stroke-width: 1.5px; fill: none; stroke-linecap: round; }
252
+ .orient-cross .oc-hd { fill: var(--active-dim); stroke: rgba(0, 0, 0, 0.7); stroke-width: 2.5px; stroke-linejoin: round; paint-order: stroke; }
253
+ .orient-cross text {
254
+ font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace;
255
+ font-size: 12px; font-weight: 600; fill: var(--active-dim);
256
+ stroke: rgba(0, 0, 0, 0.7); stroke-width: 3px; stroke-linejoin: round; paint-order: stroke;
255
257
  }
256
258
  #slim-cb-wrap {
257
259
  text-align: center;
@@ -2087,6 +2089,18 @@
2087
2089
  .drop-action:hover:not(:disabled) { color: var(--active-dim); background: var(--surface-2); }
2088
2090
  .drop-action:disabled { color: var(--muted); cursor: default; opacity: 0.5; }
2089
2091
  .drop-action-reason { display: block; margin-top: 2px; font-size: 9px; color: var(--muted); }
2092
+ #compare-with-menu {
2093
+ display: none; position: fixed; z-index: 70; left: 50%; top: 50%;
2094
+ transform: translate(-50%, -50%); width: min(340px, calc(100vw - 24px));
2095
+ max-height: calc(100vh - 48px); overflow-y: auto;
2096
+ padding: 8px; background: var(--surface); border: 1px solid var(--border);
2097
+ border-radius: var(--radius-lg); color: var(--text); font: 11px var(--mono);
2098
+ backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
2099
+ }
2100
+ #compare-with-menu.visible { display: block; }
2101
+ #compare-with-title { padding: 4px 6px 2px; }
2102
+ #compare-with-summary { color: var(--muted); padding: 0 6px 7px; font-size: 10px; }
2103
+ .compare-with-key { color: var(--muted); margin-right: 8px; }
2090
2104
  .mode-badge {
2091
2105
  display: inline-block; font-size: 0.62em; font-weight: 600;
2092
2106
  letter-spacing: 0.08em; padding: 2px 6px; border-radius: 4px;
@@ -4247,6 +4261,13 @@
4247
4261
  /* qMRI 3-plane multiview: one map per row (3 planes) + a vertical colorbar on the right */
4248
4262
  .qmri-mv-row { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 14px; }
4249
4263
  .qmri-mv-row .qv-canvas-clip { align-items: center; justify-content: center; }
4264
+ /* qMRI ortho: the 3x3 panes touch and read as one block, so only the
4265
+ outer corners of the block are rounded. */
4266
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip { border-radius: 0; }
4267
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-tl { border-top-left-radius: 10px; }
4268
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-tr { border-top-right-radius: 10px; }
4269
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-bl { border-bottom-left-radius: 10px; }
4270
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-br { border-bottom-right-radius: 10px; }
4250
4271
  .qmri-mv-row > .qv-row { flex: 0 1 auto; }
4251
4272
  .qmri-mv-cb { display: flex; align-items: center; flex-shrink: 0; }
4252
4273
  .qmri-mv-vcb-island { padding: 4px 8px; margin-top: 0; flex-direction: column !important; gap: 3px; }
@@ -5741,6 +5762,11 @@
5741
5762
  <div id="drop-action-summary"></div>
5742
5763
  <div id="drop-action-list"></div>
5743
5764
  </div>
5765
+ <div id="compare-with-menu" aria-hidden="true">
5766
+ <div id="compare-with-title">Compare with…</div>
5767
+ <div id="compare-with-summary"></div>
5768
+ <div id="compare-with-list"></div>
5769
+ </div>
5744
5770
  <div id="help-overlay">
5745
5771
  <div id="help-box">
5746
5772
  <div id="help-tabs"></div>
@@ -7507,6 +7533,7 @@
7507
7533
  let _cmvCrosshairAnim = null; // animation frame id for compare-mv crosshair fade
7508
7534
  let _qmvCrosshairAlpha = 0; // current crosshair opacity for qMRI multiview
7509
7535
  let _qmvCrosshairTarget = 0; // target opacity for qMRI multiview
7536
+ let _qmvCrosshairMap = null; // map row the lines are limited to (click-hold); null = every row
7510
7537
  let _qmvCrosshairAnim = null; // animation frame id for qMRI-mv crosshair fade
7511
7538
  let _qmvScrollHintTimer = null; // fade-out timer after scrolling a qMRI-mv pane
7512
7539
  let _qmvScrollHintDir = -1; // plane being scrolled (its own lines stay hidden)
@@ -20994,6 +21021,20 @@
20994
21021
  return `<span class="${cls}">${_dimIndexHtml(shownValue, maxValue)}</span>`;
20995
21022
  }
20996
21023
 
21024
+ // Axis letters follow the data, not the screen: the array axis that
21025
+ // started out as x keeps the letter x after t / r, so the DimBar and the
21026
+ // orientation cross both show which way each axis now runs. Remembered
21027
+ // per pair of displayed dims; picking different dims starts over.
21028
+ let _axisIdent = null;
21029
+ function _axisLetter(d) {
21030
+ const key = Math.min(dim_x, dim_y) + ',' + Math.max(dim_x, dim_y);
21031
+ if (!_axisIdent || _axisIdent.key !== key) {
21032
+ _axisIdent = { key, letters: { [dim_x]: 'x', [dim_y]: 'y' } };
21033
+ }
21034
+ if (d === dim_z) return 'z';
21035
+ return _axisIdent.letters[d] || (d === dim_x ? 'x' : 'y');
21036
+ }
21037
+
20997
21038
  function renderInfo() {
20998
21039
  _ensureQmriActiveDimVisible();
20999
21040
  _syncOverlayPaletteSlice();
@@ -21046,13 +21087,13 @@
21046
21087
  }
21047
21088
  if (i === dim_x) {
21048
21089
  const sign = flip_x ? '<span class="dim-flip-sign">-</span>' : '';
21049
- const inner = sign + '<span class="dim-axis-letter">x</span>' + `<span class="dim-slash">/</span><span class="dim-size" id="dim-x-size">${sizeOf(i)}</span>`;
21090
+ const inner = sign + `<span class="dim-axis-letter">${_axisLetter(i)}</span>` + `<span class="dim-slash">/</span><span class="dim-size" id="dim-x-size">${sizeOf(i)}</span>`;
21050
21091
  return (active || playing) ? `<span class="spatial-dim ${dimCls} dim-label" ${d}>${inner}</span>`
21051
21092
  : `<span class="spatial-dim dim-label" ${d}>${inner}</span>`;
21052
21093
  }
21053
21094
  if (i === dim_y) {
21054
21095
  const sign = flip_y ? '<span class="dim-flip-sign">-</span>' : '';
21055
- const inner = sign + '<span class="dim-axis-letter">y</span>' + `<span class="dim-slash">/</span><span class="dim-size" id="dim-y-size">${sizeOf(i)}</span>`;
21096
+ const inner = sign + `<span class="dim-axis-letter">${_axisLetter(i)}</span>` + `<span class="dim-slash">/</span><span class="dim-size" id="dim-y-size">${sizeOf(i)}</span>`;
21056
21097
  return (active || playing) ? `<span class="spatial-dim ${dimCls} dim-label" ${d}>${inner}</span>`
21057
21098
  : `<span class="spatial-dim dim-label" ${d}>${inner}</span>`;
21058
21099
  }
@@ -21272,60 +21313,122 @@
21272
21313
  if (typeof obliqueVecs !== 'undefined' && obliqueVecs) return false;
21273
21314
  return true;
21274
21315
  }
21275
- function _clearOrientEdgeHighlight() {
21276
- document.querySelectorAll('.orient-arrow.showing').forEach(el => {
21277
- el.classList.remove('showing');
21278
- });
21316
+ const _ORIENT_CROSS_BOX = 64;
21317
+ const _ORIENT_CROSS_MARGIN = 8;
21318
+ let _orientCrossEls = [];
21319
+ let _orientShown = false;
21320
+ let _orientHovering = false;
21321
+ let _orientRaf = 0;
21322
+ let _orientFlashTimer = 0;
21323
+ // Every image pane in the current mode: its canvas, the letters of its
21324
+ // horizontal / vertical dims, and which way each axis is flipped.
21325
+ function _orientPanes() {
21326
+ const out = [];
21327
+ const add = (cv, lx, ly, fx, fy) => { if (cv) out.push({ cv, lx, ly, fx: !!fx, fy: !!fy }); };
21328
+ const grid = (views, dims) => {
21329
+ const letter = (d) => ['x', 'y', 'z'][Math.max(0, dims.indexOf(d))];
21330
+ (views || []).forEach(v => add(v.canvas, letter(v.dimX), letter(v.dimY), _mvFlipX(v), _mvFlipY(v)));
21331
+ };
21332
+ const plain = (cvs) => cvs.forEach(cv => add(cv, _axisLetter(dim_x), _axisLetter(dim_y), flip_x, flip_y));
21333
+ if (stackMosaicActive) plain((stackMosaicViews || []).map(v => v.canvas));
21334
+ else if (compareQmriActive) plain((compareQmriViews || []).map(v => v.canvas));
21335
+ else if (compareMvActive) grid(compareMvViews, compareMvDims);
21336
+ else if (multiViewActive) grid(mvViews, mvDims);
21337
+ else if (compareActive) plain([...document.querySelectorAll('#compare-panes .compare-canvas')]);
21338
+ else if (qmriMvActive) grid(qmriMvViews, qmriMvDims);
21339
+ else if (qmriActive) plain((qmriViews || []).map(v => v.canvas));
21340
+ else if (mipActive || cutawayActive) return out;
21341
+ // Mosaic tiles are flipped one by one, so the whole canvas has no single origin corner.
21342
+ else if (!(dim_z >= 0 && (flip_x || flip_y))) plain([canvas]);
21343
+ return out;
21279
21344
  }
21280
- const _ORIENT_ARROW_MARGIN = 10;
21281
- function _orientArrowEl(container) {
21282
- if (!container) return null;
21283
- let el = container.querySelector(':scope > .orient-arrow');
21284
- if (!el) {
21285
- const NS = 'http://www.w3.org/2000/svg';
21286
- el = document.createElementNS(NS, 'svg');
21287
- el.setAttribute('class', 'orient-arrow');
21288
- el.setAttribute('viewBox', '0 0 32 32');
21289
- el.innerHTML = '<polygon points="3,14.5 21,14.5 21,10 31,16 21,22 21,17.5 3,17.5" fill="var(--active-dim)"/>';
21290
- container.appendChild(el);
21291
- }
21292
- return el;
21345
+ // The part of `cv` that is actually on screen: its box clipped by every scrolling/clipping ancestor.
21346
+ function _orientVisibleRect(cv) {
21347
+ const r = cv.getBoundingClientRect();
21348
+ let l = r.left, t = r.top, rt = r.right, bt = r.bottom;
21349
+ for (let el = cv.parentElement; el && el !== document.body; el = el.parentElement) {
21350
+ const cs = getComputedStyle(el);
21351
+ if (cs.overflowX === 'visible' && cs.overflowY === 'visible') continue;
21352
+ const pr = el.getBoundingClientRect();
21353
+ l = Math.max(l, pr.left); t = Math.max(t, pr.top);
21354
+ rt = Math.min(rt, pr.right); bt = Math.min(bt, pr.bottom);
21355
+ }
21356
+ return (rt - l > 8 && bt - t > 8) ? { left: l, top: t, right: rt, bottom: bt } : null;
21357
+ }
21358
+ function _orientCrossSvg(el, letterX, letterY, flipX, flipY) {
21359
+ const key = letterX + letterY + (flipX ? 1 : 0) + (flipY ? 1 : 0);
21360
+ if (el._ocKey === key) return;
21361
+ el._ocKey = key;
21362
+ const B = _ORIENT_CROSS_BOX, o = 8, len = 30, hl = 7, hw = 3.6;
21363
+ const sx = flipX ? -1 : 1, sy = flipY ? -1 : 1;
21364
+ const ox = flipX ? B - o : o, oy = flipY ? B - o : o;
21365
+ const xTip = ox + sx * len, yTip = oy + sy * len;
21366
+ const xHead = `${xTip},${oy} ${xTip - sx * hl},${oy - hw} ${xTip - sx * hl},${oy + hw}`;
21367
+ const yHead = `${ox},${yTip} ${ox - hw},${yTip - sy * hl} ${ox + hw},${yTip - sy * hl}`;
21368
+ const segs = [[ox, oy, xTip - sx * 4, oy], [ox, oy, ox, yTip - sy * 4]];
21369
+ el.innerHTML = segs.map(([x1, y1, x2, y2]) => `<line class="oc-halo" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`
21370
+ + `<line class="oc-ln" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join('')
21371
+ + `<polygon class="oc-hd" points="${xHead}"/><polygon class="oc-hd" points="${yHead}"/>`
21372
+ + `<circle class="oc-hd" cx="${ox}" cy="${oy}" r="2"/>`
21373
+ + `<text x="${xTip + sx * 9}" y="${oy + 4}" text-anchor="${flipX ? 'end' : 'start'}">${letterX}</text>`
21374
+ + `<text x="${ox}" y="${yTip + sy * 13}" text-anchor="middle">${letterY}</text>`;
21375
+ }
21376
+ // Puts one cross in the origin corner of each pane: the x arrow runs
21377
+ // along the top (bottom when flipped) edge toward increasing x, the y
21378
+ // arrow along the left (right when flipped) edge toward increasing y.
21379
+ function _placeOrientCrosses() {
21380
+ const panes = _orientPanes().map(p => ({ p, r: _orientVisibleRect(p.cv) })).filter(q => q.r);
21381
+ while (_orientCrossEls.length < panes.length) {
21382
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
21383
+ el.setAttribute('class', 'orient-cross');
21384
+ el.setAttribute('viewBox', `0 0 ${_ORIENT_CROSS_BOX} ${_ORIENT_CROSS_BOX}`);
21385
+ document.body.appendChild(el);
21386
+ _orientCrossEls.push(el);
21387
+ }
21388
+ _orientCrossEls.forEach((el, i) => {
21389
+ const q = panes[i];
21390
+ if (!q) { el.classList.remove('showing'); return; }
21391
+ const { p, r } = q;
21392
+ _orientCrossSvg(el, p.lx, p.ly, p.fx, p.fy);
21393
+ const w = r.right - r.left, h = r.bottom - r.top;
21394
+ // Small panes (grids, mosaics) get a proportionally smaller cross.
21395
+ const k = Math.max(0.55, Math.min(1, Math.min(w, h) / 230));
21396
+ const size = _ORIENT_CROSS_BOX * k, m = _ORIENT_CROSS_MARGIN * k;
21397
+ const x = p.fx ? r.right - m - size : r.left + m;
21398
+ const y = p.fy ? r.bottom - m - size : r.top + m;
21399
+ el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px) scale(${k})`;
21400
+ el.classList.add('showing');
21401
+ });
21293
21402
  }
21294
- // Places a small arrow near the (flipX, flipY) origin corner of `container`,
21295
- // pointing along `axis` ('x' or 'y') in the direction that dim increases.
21296
- // `flipOwn` is the flip of `axis` itself (picks the arrow's direction and
21297
- // which edge it hugs along that axis); `flipOther` is the flip of the
21298
- // perpendicular axis (picks where along that axis the arrow sits).
21299
- function _placeOrientArrow(container, axis, flipOwn, flipOther) {
21300
- const el = _orientArrowEl(container);
21301
- if (!el) return;
21302
- const m = _ORIENT_ARROW_MARGIN + 'px';
21303
- el.style.left = el.style.right = el.style.top = el.style.bottom = '';
21304
- if (axis === 'x') {
21305
- if (flipOwn) el.style.right = m; else el.style.left = m;
21306
- if (flipOther) el.style.bottom = m; else el.style.top = m;
21307
- el.style.transform = flipOwn ? 'rotate(180deg)' : 'rotate(0deg)';
21308
- } else {
21309
- if (flipOwn) el.style.bottom = m; else el.style.top = m;
21310
- if (flipOther) el.style.right = m; else el.style.left = m;
21311
- el.style.transform = flipOwn ? 'rotate(-90deg)' : 'rotate(90deg)';
21312
- }
21313
- el.classList.add('showing');
21403
+ // Panes can still be moving or re-rendering when this starts (after
21404
+ // r / t, or while zooming), so keep the crosses glued to them.
21405
+ function _orientLoop() {
21406
+ _orientRaf = 0;
21407
+ if (!_orientShown) return;
21408
+ _placeOrientCrosses();
21409
+ _orientRaf = requestAnimationFrame(_orientLoop);
21314
21410
  }
21315
- function _showOrientEdgeHighlight(dim) {
21316
- _clearOrientEdgeHighlight();
21317
- if (!_orientEdgeAllowed() || dim == null || dim < 0) return;
21318
- if (multiViewActive) {
21319
- if (!Array.isArray(mvViews)) return;
21320
- mvViews.forEach(v => {
21321
- if (v.dimX === dim) _placeOrientArrow(v.cvBox, 'x', _mvFlipX(v), _mvFlipY(v));
21322
- else if (v.dimY === dim) _placeOrientArrow(v.cvBox, 'y', _mvFlipY(v), _mvFlipX(v));
21323
- });
21324
- return;
21325
- }
21326
- const viewport = document.getElementById('canvas-viewport');
21327
- if (dim === dim_x) _placeOrientArrow(viewport, 'x', flip_x, flip_y);
21328
- else if (dim === dim_y) _placeOrientArrow(viewport, 'y', flip_y, flip_x);
21411
+ function _clearOrientEdgeHighlight() {
21412
+ _orientShown = false;
21413
+ if (_orientRaf) { cancelAnimationFrame(_orientRaf); _orientRaf = 0; }
21414
+ if (_orientFlashTimer) { clearTimeout(_orientFlashTimer); _orientFlashTimer = 0; }
21415
+ _orientCrossEls.forEach(el => el.classList.remove('showing'));
21416
+ }
21417
+ // Shown for every image pane while the pointer is over the DimBar.
21418
+ function _showOrientEdgeHighlight() {
21419
+ if (!_orientEdgeAllowed()) { _clearOrientEdgeHighlight(); return; }
21420
+ _orientShown = true;
21421
+ if (!_orientRaf) _orientLoop();
21422
+ }
21423
+ // Brief look after r / t so a rotate or transpose can be read off the cross.
21424
+ function _flashOrientCross(ms = 1400) {
21425
+ if (!_orientEdgeAllowed()) return;
21426
+ _showOrientEdgeHighlight();
21427
+ if (_orientFlashTimer) clearTimeout(_orientFlashTimer);
21428
+ _orientFlashTimer = setTimeout(() => {
21429
+ _orientFlashTimer = 0;
21430
+ if (!_orientHovering) _clearOrientEdgeHighlight();
21431
+ }, ms);
21329
21432
  }
21330
21433
 
21331
21434
  function setStatus(msg) {
@@ -23317,6 +23420,79 @@
23317
23420
  }
23318
23421
  }, true);
23319
23422
 
23423
+ // ── Compare with an array that is open in another tab ─────────
23424
+ let _compareWithMenuVisible = false;
23425
+ let _compareWithChoices = [];
23426
+
23427
+ function _closeCompareWithMenu() {
23428
+ if (!_compareWithMenuVisible) return;
23429
+ _compareWithMenuVisible = false;
23430
+ _compareWithChoices = [];
23431
+ _reconcileUI();
23432
+ try { document.getElementById('keyboard-sink').focus(); } catch (_) {}
23433
+ }
23434
+
23435
+ async function _pickCompareWith(choice) {
23436
+ if (!choice || !choice.enabled) return;
23437
+ _closeCompareWithMenu();
23438
+ const ok = await enterCompareModeBySid(choice.sid, choice.label);
23439
+ if (ok !== false) showStatus(`Compare: ${choice.label}`);
23440
+ }
23441
+
23442
+ async function openCompareWithMenu() {
23443
+ let sessions = [];
23444
+ try {
23445
+ const res = await proxyFetch('/sessions');
23446
+ if (res.ok) sessions = await res.json();
23447
+ } catch (_) {}
23448
+ const ownShape = JSON.stringify((shape || []).map(Number));
23449
+ _compareWithChoices = sessions
23450
+ .filter(s => s.sid && s.sid !== sid)
23451
+ .map(s => {
23452
+ const sameShape = JSON.stringify((s.shape || []).map(Number)) === ownShape;
23453
+ const label = s.var || s.name || s.sid.slice(0, 8);
23454
+ if (s.name) compareNameBySid[s.sid] = label;
23455
+ return {
23456
+ sid: s.sid,
23457
+ label,
23458
+ detail: `${(s.shape || []).join(' x ')}${s.var && s.name ? ` · ${s.name}` : ''}`,
23459
+ enabled: sameShape,
23460
+ };
23461
+ });
23462
+ if (!_compareWithChoices.length) {
23463
+ showStatus('No other open arrays to compare with');
23464
+ return;
23465
+ }
23466
+ const list = document.getElementById('compare-with-list');
23467
+ list.innerHTML = _compareWithChoices.map((c, i) => {
23468
+ const keyHint = i < 9 ? `<span class="compare-with-key">${i + 1}</span>` : '';
23469
+ const reason = c.enabled ? c.detail : `${c.detail} · needs shape ${(shape || []).join(' x ')}`;
23470
+ return `<button class="drop-action" data-compare-with="${i}" ${c.enabled ? '' : 'disabled'}>${keyHint}${_escapeHtml(c.label)}<span class="drop-action-reason">${_escapeHtml(reason)}</span></button>`;
23471
+ }).join('');
23472
+ list.querySelectorAll('.drop-action:not(:disabled)').forEach(button => {
23473
+ button.addEventListener('click', () => _pickCompareWith(_compareWithChoices[Number(button.dataset.compareWith)]));
23474
+ });
23475
+ document.getElementById('compare-with-summary').textContent =
23476
+ 'Arrays open in your other tabs · click or press a number · Esc to cancel';
23477
+ _compareWithMenuVisible = true;
23478
+ _reconcileUI();
23479
+ }
23480
+
23481
+ document.addEventListener('pointerdown', (event) => {
23482
+ if (_compareWithMenuVisible && !document.getElementById('compare-with-menu').contains(event.target)) {
23483
+ _closeCompareWithMenu();
23484
+ }
23485
+ });
23486
+ window.addEventListener('keydown', (event) => {
23487
+ if (!_compareWithMenuVisible) return;
23488
+ if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) return;
23489
+ event.preventDefault();
23490
+ event.stopImmediatePropagation();
23491
+ if (event.key === 'Escape') { _closeCompareWithMenu(); return; }
23492
+ const n = Number(event.key);
23493
+ if (Number.isInteger(n) && n >= 1 && n <= 9) _pickCompareWith(_compareWithChoices[n - 1]);
23494
+ }, true);
23495
+
23320
23496
  /* ── JS: Rendering Pipeline (updateView, play, screenshot) ── */
23321
23497
 
23322
23498
  function updateView() {
@@ -27757,6 +27933,7 @@
27757
27933
  _roiClearOnDimChange();
27758
27934
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27759
27935
  renderInfo(); saveState();
27936
+ _flashOrientCross();
27760
27937
  return;
27761
27938
  }
27762
27939
  const wasMosaic = dim_z >= 0;
@@ -27772,6 +27949,7 @@
27772
27949
  showStatus(wasMosaic ? 'transposed x/y (mosaic)' : 'transposed x/y');
27773
27950
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27774
27951
  renderInfo(); saveState();
27952
+ _flashOrientCross();
27775
27953
  },
27776
27954
  },
27777
27955
  'view.rotateOrFlip': {
@@ -27802,6 +27980,7 @@
27802
27980
  showStatus('rotated 90°'); saveState();
27803
27981
  }
27804
27982
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27983
+ _flashOrientCross();
27805
27984
  },
27806
27985
  },
27807
27986
  'theme.cycle': {
@@ -29084,6 +29263,11 @@
29084
29263
  commands['qmri.cycle'].run(ctx, e);
29085
29264
  },
29086
29265
  },
29266
+ 'compare.withOpenArray': {
29267
+ title: 'Compare with an array open in another tab',
29268
+ when: [],
29269
+ run: (ctx, e) => { openCompareWithMenu(); },
29270
+ },
29087
29271
  'tool.comparecenterDirect': {
29088
29272
  title: 'Cycle compare center mode',
29089
29273
  when: [],
@@ -29515,11 +29699,12 @@
29515
29699
  },
29516
29700
  {
29517
29701
  id: 'compare', label: 'Compare',
29518
- desc: 'View two or more arrays side by side, blend them together, or compute a difference map. Useful for checking registration, comparing model outputs, or inspecting before-and-after changes. Open multiple arrays with the file picker first.',
29702
+ desc: 'View two or more arrays side by side, blend them together, or compute a difference map. Useful for checking registration, comparing model outputs, or inspecting before-and-after changes. Press / then c to compare with an array open in another tab.',
29519
29703
  sections: [
29520
29704
  {
29521
29705
  title: 'Layout',
29522
29706
  entries: [
29707
+ { key: '/ c', action: 'Compare with…', hint: 'pick an array that is open in another tab; it must have the same shape' },
29523
29708
  { key: 'g', action: 'Cycle layout', hint: 'horizontal → vertical → grid; with exactly 2 arrays + center mode active, toggles horizontal ↔ big-left' },
29524
29709
  { key: 'Drag title', action: 'Reorder panes by dragging their title bar' },
29525
29710
  ]
@@ -29558,6 +29743,7 @@
29558
29743
  {
29559
29744
  title: 'After /', columnBreak: true,
29560
29745
  entries: [
29746
+ { key: '/ c', action: 'Compare with…', hint: 'side by side with an array open in another tab' },
29561
29747
  { key: '/ r', action: 'ROI', hint: 'measure a region and see live stats' },
29562
29748
  { key: '/ S', action: 'Segmentation', hint: 'local mask editing and nnInteractive options' },
29563
29749
  { key: '/ o', action: 'Overlays', hint: 'show mask names and visibility' },
@@ -29983,6 +30169,16 @@
29983
30169
  { key: 'q', toolId: 'qmri', command: 'tool.qmriDirect' },
29984
30170
  { key: 's', toolId: 'save', command: 'saveMenu.open' },
29985
30171
  { key: 'C', toolId: 'crop', command: 'tool.cropDirect' },
30172
+ {
30173
+ key: 'c',
30174
+ id: 'comparewith',
30175
+ label: 'Compare with…',
30176
+ command: 'compare.withOpenArray',
30177
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="8" height="14" rx="1"/><rect x="13" y="5" width="8" height="14" rx="1"/></svg>',
30178
+ enabled: () => !isInlineEmbed,
30179
+ active: () => compareActive,
30180
+ unavailableReason: () => 'Not available in notebook output.',
30181
+ },
29986
30182
  { key: 'P', toolId: 'comparecenter', command: 'tool.comparecenterDirect' },
29987
30183
  { key: 'r', toolId: 'roi', command: 'tool.roiDirect' },
29988
30184
  { key: 'S', toolId: 'segmentation', command: 'tool.segmentationDirect' },
@@ -37771,6 +37967,7 @@
37771
37967
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return;
37772
37968
  sink.focus();
37773
37969
  _qmriMvDragView = view;
37970
+ _qmvCrosshairMap = view.qmriIdx;
37774
37971
  _qmvRecomputeCrosshairTarget();
37775
37972
  handleQmriMvPointer(view, e);
37776
37973
  const onMove = (me) => { if (_qmriMvDragView === view) handleQmriMvPointer(view, me); };
@@ -37923,7 +38120,7 @@
37923
38120
  _hoveredQmriMvView = null;
37924
38121
  if (_qmvCrosshairAnim) { cancelAnimationFrame(_qmvCrosshairAnim); _qmvCrosshairAnim = null; }
37925
38122
  if (_qmvScrollHintTimer) { clearTimeout(_qmvScrollHintTimer); _qmvScrollHintTimer = null; }
37926
- _qmvCrosshairAlpha = 0; _qmvCrosshairTarget = 0; _qmvScrollHintDir = -1;
38123
+ _qmvCrosshairAlpha = 0; _qmvCrosshairTarget = 0; _qmvScrollHintDir = -1; _qmvCrosshairMap = null;
37927
38124
  _reconcileOrthoCursorHidden();
37928
38125
  _reconcileMvOrientationVisibility();
37929
38126
  qmriMvViews.forEach(v => { if (v.ws) { v.ws.onclose = null; v.ws.close(); } });
@@ -38097,7 +38294,9 @@
38097
38294
  }
38098
38295
  /** Position lines on the pane's overlay, like compareMvDrawFrame. */
38099
38296
  function _qmvDrawCrosshair(v) {
38100
- const alpha = (_qmvScrollHintTimer && v.sliceDir === _qmvScrollHintDir) ? 0 : _qmvCrosshairAlpha;
38297
+ const alpha = (_qmvScrollHintTimer && v.sliceDir === _qmvScrollHintDir) ? 0
38298
+ : (_qmvCrosshairMap !== null && v.qmriIdx !== _qmvCrosshairMap) ? 0
38299
+ : _qmvCrosshairAlpha;
38101
38300
  const ov = _getMvCrosshairOverlay(v);
38102
38301
  if (!ov) return;
38103
38302
  const metrics = _getMvCrosshairMetrics(v);
@@ -38130,8 +38329,8 @@
38130
38329
  if (!ready.length) return;
38131
38330
  const nRows = Math.max(1, Math.round(qmriMvViews.length / 3));
38132
38331
  const nCols = 3;
38133
- const colGap = 24;
38134
- const rowGap = 14;
38332
+ const colGap = 0;
38333
+ const rowGap = 0;
38135
38334
  const sidePad = 120;
38136
38335
  const vcbW = 80; // vertical colorbar island horizontal footprint (bar + padding)
38137
38336
  const bottomPad = 16;
@@ -38147,6 +38346,13 @@
38147
38346
  const baseScale = absMaxT / maxSide;
38148
38347
  const panStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
38149
38348
  for (const v of ready) {
38349
+ const qi = qmriMvViews.indexOf(v), qr = Math.floor(qi / 3), qc = qi % 3;
38350
+ if (v.clip) {
38351
+ v.clip.classList.toggle('qmv-tl', qr === 0 && qc === 0);
38352
+ v.clip.classList.toggle('qmv-tr', qr === 0 && qc === 2);
38353
+ v.clip.classList.toggle('qmv-bl', qr === nRows - 1 && qc === 0);
38354
+ v.clip.classList.toggle('qmv-br', qr === nRows - 1 && qc === 2);
38355
+ }
38150
38356
  _scalePane(
38151
38357
  { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: panStub },
38152
38358
  v.clip || null,
@@ -39155,6 +39361,7 @@
39155
39361
  * scrolled plane's own lines stay put, so they are left hidden. */
39156
39362
  function _qmvScrollHint(view) {
39157
39363
  _qmvScrollHintDir = view ? view.sliceDir : -1;
39364
+ if (!_qmriMvDragView && view) _qmvCrosshairMap = view.qmriIdx;
39158
39365
  if (_qmvScrollHintTimer) clearTimeout(_qmvScrollHintTimer);
39159
39366
  _qmvScrollHintTimer = setTimeout(() => {
39160
39367
  _qmvScrollHintTimer = null;
@@ -39887,9 +40094,10 @@
39887
40094
  let _histDimHovering = false;
39888
40095
  infoEl.addEventListener('mouseover', (e) => {
39889
40096
  const span = e.target.closest('[data-dim]');
40097
+ _orientHovering = true;
40098
+ _showOrientEdgeHighlight();
39890
40099
  if (span) {
39891
40100
  hoveredDim = parseInt(span.dataset.dim);
39892
- _showOrientEdgeHighlight(hoveredDim);
39893
40101
  if (_histDimControlsVisible()) {
39894
40102
  _histDimHovering = true;
39895
40103
  _showHistTooltip(_tooltipTextForDim(hoveredDim), infoEl.getBoundingClientRect());
@@ -39905,7 +40113,6 @@
39905
40113
  return;
39906
40114
  }
39907
40115
  _histDimHovering = false;
39908
- _clearOrientEdgeHighlight();
39909
40116
  _hideHistTooltip();
39910
40117
  if (!_lebesgueManual && primaryCb && primaryCb._expanded && !_histAutoDismissTimer) {
39911
40118
  _restartHistAutoDismiss();
@@ -39915,6 +40122,7 @@
39915
40122
  infoEl.addEventListener('mouseleave', () => {
39916
40123
  hoveredDim = -1;
39917
40124
  _histDimHovering = false;
40125
+ _orientHovering = false;
39918
40126
  _clearOrientEdgeHighlight();
39919
40127
  _hideHistTooltip();
39920
40128
  if (!_lebesgueManual && primaryCb && primaryCb._expanded && !_histAutoDismissTimer) {
@@ -41644,6 +41852,11 @@
41644
41852
  dropMenu.classList.toggle('visible', _dropMenuVisible);
41645
41853
  dropMenu.setAttribute('aria-hidden', _dropMenuVisible ? 'false' : 'true');
41646
41854
  }
41855
+ const compareWithMenu = document.getElementById('compare-with-menu');
41856
+ if (compareWithMenu) {
41857
+ compareWithMenu.classList.toggle('visible', _compareWithMenuVisible);
41858
+ compareWithMenu.setAttribute('aria-hidden', _compareWithMenuVisible ? 'false' : 'true');
41859
+ }
41647
41860
  if (_zenActive) _setZenQmriChromeOpacity('0');
41648
41861
  renderToolMenu();
41649
41862
  if (typeof _reconcileOverlayPalette === 'function') _reconcileOverlayPalette();
@@ -58,7 +58,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
58
58
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
59
59
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
60
60
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
61
- _VSCODE_EXT_VERSION = "0.15.61" # current bundled extension version
61
+ _VSCODE_EXT_VERSION = "0.15.62" # current bundled extension version
62
62
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
63
63
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
64
64
  # script opening several arrays) states the mismatch once instead of per call.
@@ -7844,3 +7844,23 @@ class TestViewDisplayRouting:
7844
7844
  finally:
7845
7845
  for sid in set(session_mod.SESSIONS) - before_sids:
7846
7846
  session_mod.SESSIONS.pop(sid, None)
7847
+
7848
+
7849
+ def test_load_aligns_nifti_overlay_to_requested_base_grid(client, tmp_path):
7850
+ nib = pytest.importorskip("nibabel")
7851
+ import arrayview._session as session_mod
7852
+
7853
+ affine = np.diag([1.0, 1.0, 2.0, 1.0])
7854
+ base = tmp_path / "base.nii.gz"
7855
+ nib.save(nib.Nifti1Image(np.zeros((6, 6, 5), np.float32), affine), str(base))
7856
+ mask = tmp_path / "mask.nii.gz"
7857
+ nib.save(nib.Nifti1Image(np.ones((6, 6, 4), np.uint8), affine), str(mask))
7858
+
7859
+ plain = client.post("/load", json={"filepath": str(mask)}).json()["sid"]
7860
+ aligned = client.post(
7861
+ "/load", json={"filepath": str(mask), "align_to": str(base)}
7862
+ ).json()["sid"]
7863
+
7864
+ assert aligned != plain
7865
+ assert session_mod.SESSIONS[plain].shape == (6, 6, 4)
7866
+ assert session_mod.SESSIONS[aligned].shape == (6, 6, 5)
@@ -172,3 +172,26 @@ def test_explicit_ras_conversion_reorients_native_volume(client, tmp_path):
172
172
  reverted = client.post(f"/resample_ras/{sid}", json={"enabled": False})
173
173
  reverted.raise_for_status()
174
174
  assert reverted.json()["shape"] == [3, 4, 5]
175
+
176
+
177
+ def test_overlay_on_different_grid_is_resampled_onto_base(tmp_path):
178
+ from arrayview._io import align_overlay_to_base, load_data, load_data_with_meta
179
+
180
+ base_affine = np.diag([1.0, 1.0, 2.0, 1.0])
181
+ nib.save(nib.Nifti1Image(np.zeros((8, 8, 6), np.float32), base_affine), tmp_path / "base.nii.gz")
182
+ # Mask has one slice fewer and starts one base slice (2 mm) higher.
183
+ mask = np.zeros((8, 8, 5), np.uint8)
184
+ mask[2:4, 3:5, 1] = 1
185
+ mask_affine = base_affine.copy()
186
+ mask_affine[2, 3] = 2.0
187
+ nib.save(nib.Nifti1Image(mask, mask_affine), tmp_path / "mask.nii.gz")
188
+
189
+ _base, meta = load_data_with_meta(str(tmp_path / "base.nii.gz"))
190
+ path = str(tmp_path / "mask.nii.gz")
191
+ aligned = align_overlay_to_base(path, load_data(path), meta)
192
+
193
+ assert aligned.shape == (8, 8, 6)
194
+ assert aligned.dtype == np.uint8
195
+ expected = np.zeros((8, 8, 6), np.uint8)
196
+ expected[2:4, 3:5, 2] = 1
197
+ np.testing.assert_array_equal(aligned, expected)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes