arrayview 0.49.1__tar.gz → 0.50.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 (89) hide show
  1. {arrayview-0.49.1 → arrayview-0.50.0}/PKG-INFO +1 -1
  2. {arrayview-0.49.1 → arrayview-0.50.0}/pyproject.toml +1 -1
  3. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_websocket.py +64 -11
  4. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_viewer.html +281 -31
  5. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_browser.py +5 -3
  6. {arrayview-0.49.1 → arrayview-0.50.0}/.gitignore +0 -0
  7. {arrayview-0.49.1 → arrayview-0.50.0}/LICENSE +0 -0
  8. {arrayview-0.49.1 → arrayview-0.50.0}/README.md +0 -0
  9. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/ARCHITECTURE.md +0 -0
  10. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/__init__.py +0 -0
  11. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/__main__.py +0 -0
  12. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_analysis.py +0 -0
  13. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_app.py +0 -0
  14. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_bootstrap_app.py +0 -0
  15. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_config.py +0 -0
  16. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_diagnostics.py +0 -0
  17. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_dicom.py +0 -0
  18. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_diff.py +0 -0
  19. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_extra_ports.py +0 -0
  20. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_icon.png +0 -0
  21. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_imaging.py +0 -0
  22. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_instance_registry.py +0 -0
  23. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_io.py +0 -0
  24. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_launch_plan.py +0 -0
  25. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_launch_trace.py +0 -0
  26. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_launcher.py +0 -0
  27. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_lifecycle.py +0 -0
  28. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_overlays.py +0 -0
  29. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_platform.py +0 -0
  30. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_render.py +0 -0
  31. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_analysis.py +0 -0
  32. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_drop.py +0 -0
  33. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_export.py +0 -0
  34. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_loading.py +0 -0
  35. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_persistence.py +0 -0
  36. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_preferences.py +0 -0
  37. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_preload.py +0 -0
  38. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_query.py +0 -0
  39. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_rendering.py +0 -0
  40. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_segmentation.py +0 -0
  41. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_state.py +0 -0
  42. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_routes_vectorfield.py +0 -0
  43. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_segmentation.py +0 -0
  44. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_server.py +0 -0
  45. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_session.py +0 -0
  46. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_session_spec.py +0 -0
  47. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_shell.html +0 -0
  48. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_source_safety.py +0 -0
  49. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_synthetic_mri.py +0 -0
  50. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_torch.py +0 -0
  51. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_tutorial.py +0 -0
  52. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_vectorfield.py +0 -0
  53. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_view_memory.py +0 -0
  54. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_vscode.py +0 -0
  55. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_vscode_browser.py +0 -0
  56. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_vscode_extension.py +0 -0
  57. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_vscode_signal.py +0 -0
  58. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/_watch.py +0 -0
  59. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/arrayview-opener.vsix +0 -0
  60. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/gsap.min.js +0 -0
  61. {arrayview-0.49.1 → arrayview-0.50.0}/src/arrayview/todo.md +0 -0
  62. {arrayview-0.49.1 → arrayview-0.50.0}/tests/capture_patient_loading.py +0 -0
  63. {arrayview-0.49.1 → arrayview-0.50.0}/tests/capture_v_animation.py +0 -0
  64. {arrayview-0.49.1 → arrayview-0.50.0}/tests/conftest.py +0 -0
  65. {arrayview-0.49.1 → arrayview-0.50.0}/tests/make_vectorfield_test_arrays.py +0 -0
  66. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_1d_arrays.py +0 -0
  67. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_api.py +0 -0
  68. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_cli.py +0 -0
  70. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_config.py +0 -0
  71. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_dicom.py +0 -0
  72. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_drop_import.py +0 -0
  73. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_instance_registry.py +0 -0
  74. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_io.py +0 -0
  75. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_large_arrays.py +0 -0
  76. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_launch_blackbox.py +0 -0
  77. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_mode_roundtrip.py +0 -0
  78. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_nifti_meta.py +0 -0
  79. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_nifti_series.py +0 -0
  80. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_rgb_pixel_art.py +0 -0
  81. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_safe_server_stop.py +0 -0
  82. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_source_safety.py +0 -0
  83. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_torch.py +0 -0
  84. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_tutorial.py +0 -0
  85. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_view_memory.py +0 -0
  86. {arrayview-0.49.1 → arrayview-0.50.0}/tests/test_vsix_bundle.py +0 -0
  87. {arrayview-0.49.1 → arrayview-0.50.0}/tests/ui_audit.py +0 -0
  88. {arrayview-0.49.1 → arrayview-0.50.0}/tests/v_anim_frames/.gitkeep +0 -0
  89. {arrayview-0.49.1 → arrayview-0.50.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.49.1
3
+ Version: 0.50.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.49.1"
7
+ version = "0.50.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"
@@ -103,12 +103,22 @@ def _cancel_pending_viewer_release(sid: str) -> None:
103
103
  task.cancel()
104
104
 
105
105
 
106
- def _trace_viewer_event(event: str, sid: str) -> None:
107
- if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
108
- return
109
- from arrayview._launch_trace import emit_launch_event, trace_tag
106
+ def _trace_viewer_event(event: str, sid: str, **attrs: object) -> None:
107
+ # Same opt-in as the page-route events (env or ~/.arrayview marker), so a
108
+ # server started by an Explorer click logs its sockets too: a launch that
109
+ # loads its page but never shows a frame needs both halves to diagnose.
110
+ try:
111
+ from arrayview._launch_trace import (
112
+ configure_route_launch_trace,
113
+ emit_launch_event,
114
+ trace_tag,
115
+ )
110
116
 
111
- emit_launch_event(event, sid_tag=trace_tag(sid))
117
+ if not configure_route_launch_trace():
118
+ return
119
+ emit_launch_event(event, sid_tag=trace_tag(sid), **attrs)
120
+ except Exception:
121
+ return
112
122
 
113
123
  def _render_progress_reporter(
114
124
  loop, ws, label="Loading", quiet_for_s=0.6, min_interval_s=0.2
@@ -469,11 +479,21 @@ def register_websocket_routes(app) -> None:
469
479
  # stops that clock. The viewer keeps its splash up until the metadata
470
480
  # below arrives, so an early accept shows nothing half-rendered.
471
481
  await ws.accept()
482
+ accepted_at = time.perf_counter()
483
+ arrived_on = (ws.scope.get("server") or (None, None))[1]
484
+ _trace_viewer_event(
485
+ "viewer.socket_accepted", sid,
486
+ request_id=launch_request_id, port=arrived_on,
487
+ session_ready=sid in _session_mod.SESSIONS,
488
+ )
472
489
 
473
490
  session = _session_mod.SESSIONS.get(sid)
474
491
  if session is None:
475
492
  session = await _await_session_reporting_read_progress(ws, sid)
476
493
  if not session:
494
+ _trace_viewer_event(
495
+ "viewer.session_missing", sid, request_id=launch_request_id
496
+ )
477
497
  # The socket has already opened, and the viewer treats a drop after
478
498
  # a successful open as a recoverable reconnect — it would retry
479
499
  # forever. Say the load failed so it shows an error instead.
@@ -497,6 +517,11 @@ def register_websocket_routes(app) -> None:
497
517
  or launch_journal is None
498
518
  or launch_journal.get("token") != launch_token
499
519
  ):
520
+ _trace_viewer_event(
521
+ "viewer.launch_rejected", sid,
522
+ request_id=launch_request_id,
523
+ journal_found=launch_journal is not None,
524
+ )
500
525
  await ws.close(code=1008)
501
526
  return
502
527
 
@@ -525,10 +550,9 @@ def register_websocket_routes(app) -> None:
525
550
  _session_mod.VIEWER_CONNECTIONS_SEEN += 1
526
551
  _session_mod.LIVE_VIEWER_SOCKETS.append(ws)
527
552
  _ensure_path_nudge_task()
528
- # Which port this viewer arrived on, so a cold-start port can be
529
- # released the moment its own viewer goes away rather than waiting for
530
- # every viewer everywhere to close.
531
- arrived_on = (ws.scope.get("server") or (None, None))[1]
553
+ # arrived_on (above) is the port this viewer came in on, so a
554
+ # cold-start port can be released the moment its own viewer goes away
555
+ # rather than waiting for every viewer everywhere to close.
532
556
  try:
533
557
  from arrayview import _extra_ports
534
558
 
@@ -544,7 +568,11 @@ def register_websocket_routes(app) -> None:
544
568
  _session_mod.VIEWER_REQUEST_COUNTS[request_key] = (
545
569
  _session_mod.VIEWER_REQUEST_COUNTS.get(request_key, 0) + 1
546
570
  )
547
- _trace_viewer_event("viewer.connected", sid)
571
+ _trace_viewer_event(
572
+ "viewer.connected", sid,
573
+ request_id=launch_request_id,
574
+ wait_ms=round((time.perf_counter() - accepted_at) * 1000),
575
+ )
548
576
  loop = asyncio.get_running_loop()
549
577
  _pending: asyncio.Queue[dict | None] = asyncio.Queue()
550
578
 
@@ -572,11 +600,16 @@ def register_websocket_routes(app) -> None:
572
600
 
573
601
  recv_task = asyncio.create_task(_receiver())
574
602
  previous_indices = None
603
+ frames_sent = 0
575
604
  try:
576
605
  while True:
577
606
  msg = await _pending.get()
578
607
  if msg is None:
579
608
  break
609
+ if frames_sent == 0:
610
+ _trace_viewer_event(
611
+ "viewer.first_request", sid, request_id=launch_request_id
612
+ )
580
613
 
581
614
  seq = int(msg.get("seq", 0))
582
615
 
@@ -858,6 +891,14 @@ def register_websocket_routes(app) -> None:
858
891
  raw_payload = payload
859
892
  payload = await _render(loop, lambda: _pack_frame(raw_payload))
860
893
  await ws.send_bytes(payload)
894
+ frames_sent += 1
895
+ if frames_sent == 1:
896
+ _trace_viewer_event(
897
+ "viewer.first_frame_sent", sid,
898
+ request_id=launch_request_id,
899
+ ms=round((time.perf_counter() - accepted_at) * 1000),
900
+ payload_bytes=len(payload),
901
+ )
861
902
  if perf:
862
903
  total_ms = (time.perf_counter() - total_t0) * 1000.0
863
904
  await ws.send_json(
@@ -939,6 +980,13 @@ def register_websocket_routes(app) -> None:
939
980
 
940
981
  _vprint(f"[ArrayView] WS/{sid[:8]}: {_ws_exc}", flush=True)
941
982
  traceback.print_exc()
983
+ _trace_viewer_event(
984
+ "viewer.render_failed", sid,
985
+ request_id=launch_request_id,
986
+ error=type(_ws_exc).__name__,
987
+ message=str(_ws_exc)[:200],
988
+ frames_sent=frames_sent,
989
+ )
942
990
  # Without this the viewer never learns the frame it is waiting for
943
991
  # will not arrive: the socket just closes and the panel sits on the
944
992
  # loading spinner until the opener's timeout fails the request
@@ -1122,4 +1170,9 @@ def register_websocket_routes(app) -> None:
1122
1170
  _session_mod.VIEWER_REQUEST_COUNTS[request_key] = request_count
1123
1171
  else:
1124
1172
  _session_mod.VIEWER_REQUEST_COUNTS.pop(request_key, None)
1125
- _trace_viewer_event("viewer.disconnected", sid)
1173
+ _trace_viewer_event(
1174
+ "viewer.disconnected", sid,
1175
+ request_id=launch_request_id,
1176
+ frames_sent=frames_sent,
1177
+ open_s=round(time.perf_counter() - accepted_at, 1),
1178
+ )
@@ -3924,6 +3924,30 @@
3924
3924
  .compare-title.drag-over { outline: 1px dashed var(--accent, #888); border-radius: 2px; }
3925
3925
  .compare-title.compare-center-title { cursor: default; max-width: 100%; overflow: visible; text-overflow: clip; }
3926
3926
  .compare-title.compare-side-title { gap: 0; }
3927
+ /* Array names: the start and end every compared name shares fade back
3928
+ and give way first when the slot is narrow (_fitArrayName). */
3929
+ .av-name { min-width: 0; overflow: hidden; white-space: nowrap; }
3930
+ .av-name-shared { opacity: 0.5; }
3931
+ #compare-view-wrap:not(.compare-titles-inset) .compare-title .av-name { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
3932
+ /* A name beside its image: plain text in the empty margin, wrapping
3933
+ at separators (_placeRowName / _placeCompareTitle). */
3934
+ .av-name.av-name-side {
3935
+ display: block; white-space: normal; overflow-wrap: anywhere;
3936
+ font-size: 12px; line-height: 16px; letter-spacing: 0.02em; text-transform: none;
3937
+ color: var(--text);
3938
+ }
3939
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left,
3940
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right {
3941
+ top: 50%; transform: translateY(-50%); max-width: none; -webkit-text-stroke: 0; text-shadow: none;
3942
+ }
3943
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left { left: auto; right: 100%; margin-right: 14px; justify-content: flex-end; text-align: right; }
3944
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right { left: 100%; margin-left: 14px; text-align: left; }
3945
+ .compare-title.compare-title-out-left svg,
3946
+ .compare-title.compare-title-out-right svg { display: none; }
3947
+ .av-name-br { display: none; }
3948
+ /* Chrome breaks at <wbr> even under nowrap: only side names wrap */
3949
+ .av-name:not(.av-name-side) wbr { display: none; }
3950
+ .av-name.av-name-paths .av-name-br { display: block; }
3927
3951
  .compare-diff-stats { display: inline-flex; gap: 12px; font-variant-numeric: tabular-nums; cursor: default; }
3928
3952
  .compare-diff-stats b { font-weight: normal; color: color-mix(in srgb, var(--text) 75%, var(--muted)); text-transform: none; }
3929
3953
  .detached-index-hint {
@@ -4212,6 +4236,8 @@
4212
4236
  /* compare-qMRI: row label (array name) at left of each row */
4213
4237
  .cq-row-label { font-size: 12px; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; writing-mode: vertical-rl; transform: rotate(180deg); width: 24px; flex-shrink: 0; align-self: center; text-align: center; white-space: nowrap; }
4214
4238
  .qv-row.cq-row > .cq-row-label { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%) rotate(180deg); }
4239
+ /* compare-qMRI / compare-3plane: array name left of its row */
4240
+ .qv-row.cq-row > .cq-row-name { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%); text-align: right; cursor: default; user-select: none; }
4215
4241
  /* compare-qMRI: column header row for map names */
4216
4242
  .cq-header-row { display: flex; flex-direction: row; gap: 24px; justify-content: center; align-items: flex-end; padding-left: 0; }
4217
4243
  .cq-header-label { font-size: 16px; color: var(--fg); letter-spacing: 0.05em; text-align: center; }
@@ -4754,7 +4780,8 @@
4754
4780
  /* qMRI maps touching (Preferences): no gaps; with two rows the
4755
4781
  colour bars float inside the maps like the immersive islands. */
4756
4782
  body.qmri-gapless #qmri-view-wrap.active { gap: 0 !important; }
4757
- body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row) { gap: 0 !important; }
4783
+ body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row),
4784
+ body.qmri-gapless #qmri-view-wrap > .cq-header-row { gap: 0 !important; }
4758
4785
  body.qmri-gapless #qmri-view-wrap .qv-pane { gap: 0; }
4759
4786
  body.qmri-gapless #qmri-view-wrap .qv-canvas-clip { border-radius: 0 !important; }
4760
4787
  /* Touching panes read as one block: only its outer corners stay
@@ -9977,6 +10004,231 @@
9977
10004
  return sidVal.slice(0, 8);
9978
10005
  }
9979
10006
 
10007
+ // Compared names often share most of their text (one file name in
10008
+ // several run folders). Split each into the start and end that every
10009
+ // name shares and the middle that tells it apart. Splits only at
10010
+ // separators, and every name keeps a word of its own.
10011
+ const _NAME_SEP_RE = /^[_\-.\/\\\s()]+$/;
10012
+ function _arrayNameParts(names) {
10013
+ if (!names.length) return [];
10014
+ const toks = names.map(n => String(n).split(/([_\-.\/\\\s()]+)/).filter(Boolean));
10015
+ const hasWord = ts => ts.some(t => !_NAME_SEP_RE.test(t));
10016
+ let h = 0, t = 0;
10017
+ if (toks.length > 1) {
10018
+ const minLen = Math.min(...toks.map(ts => ts.length));
10019
+ while (h < minLen && toks.every(ts => ts[h] === toks[0][h])) h++;
10020
+ while (h > 0 && !toks.every(ts => hasWord(ts.slice(h)))) h--;
10021
+ const rest = toks.map(ts => ts.slice(h));
10022
+ const minRest = Math.min(...rest.map(ts => ts.length));
10023
+ const fromEnd = (ts, i) => ts[ts.length - 1 - i];
10024
+ while (t < minRest && rest.every(ts => fromEnd(ts, t) === fromEnd(rest[0], t))) t++;
10025
+ while (t > 0 && !rest.every(ts => hasWord(ts.slice(0, ts.length - t)))) t--;
10026
+ }
10027
+ // `near`: how much of each key's start it shares with its closest
10028
+ // sibling; in a tight slot that goes before the rest of the key.
10029
+ let keys = toks.map(ts => ts.slice(h, ts.length - t));
10030
+ // Shared folders and a shared file name or extension are left out
10031
+ // (the full path is on hover), but never part of a word group
10032
+ // (pat_a stays pat_a), nor when what remains says nothing on its
10033
+ // own (t1.npy (1) would become "1").
10034
+ const isPath = tok => /[\/\\]/.test(tok);
10035
+ let hd = h;
10036
+ while (hd > 0 && !isPath(toks[0][hd - 1])) hd--;
10037
+ const tailToks = toks[0].slice(toks[0].length - t);
10038
+ const cut = tailToks.findIndex(tok => isPath(tok) || tok.startsWith('.'));
10039
+ const td = cut < 0 ? 0 : t - cut;
10040
+ const shortKeys = toks.map(ts => ts.slice(hd, ts.length - td));
10041
+ const dropShared = shortKeys.every(kt => kt.some(tok => /[a-z]/i.test(tok)));
10042
+ if (dropShared) {
10043
+ keys = shortKeys.map(kt => {
10044
+ let a = 0, b = kt.length;
10045
+ const loose = tok => /^[_\-.\/\\\s]+$/.test(tok);
10046
+ while (a < b && loose(kt[a])) a++;
10047
+ while (b > a && loose(kt[b - 1])) b--;
10048
+ return kt.slice(a, b);
10049
+ });
10050
+ }
10051
+ return toks.map((ts, i) => {
10052
+ let near = 0;
10053
+ keys.forEach((other, j) => {
10054
+ if (j === i) return;
10055
+ let n = 0;
10056
+ while (n < keys[i].length && keys[i][n] === other[n]) n++;
10057
+ while (n > 0 && !hasWord(keys[i].slice(n))) n--;
10058
+ near = Math.max(near, n);
10059
+ });
10060
+ return {
10061
+ head: dropShared ? '' : ts.slice(0, h).join(''),
10062
+ key: keys[i].join(''),
10063
+ tail: dropShared ? '' : ts.slice(ts.length - t).join(''),
10064
+ near: keys[i].slice(0, near).join('').length,
10065
+ };
10066
+ });
10067
+ }
10068
+
10069
+ // Last `n` characters of `s`, moved up to the next word start when the
10070
+ // cut would leave a word fragment (\u2026on_ reads as noise).
10071
+ function _nameTailChars(s, n) {
10072
+ const out = s.slice(s.length - n);
10073
+ if (n >= s.length || /[_\-.\/\\\s()]/.test(s[s.length - n - 1])) return out;
10074
+ const m = out.match(/[_\-.\/\\\s()]+/);
10075
+ return m ? out.slice(m.index + m[0].length) : '';
10076
+ }
10077
+
10078
+ // Name squeezed to `cap` characters: the shared end gives way first,
10079
+ // then the shared start, then the start the name shares with its
10080
+ // closest sibling, and only then the middle of what is its own.
10081
+ // Returns [text, shared] runs.
10082
+ function _arrayNameRuns(p, cap) {
10083
+ const E = '\u2026';
10084
+ const { head, key, tail, near } = p;
10085
+ if (head.length + key.length + tail.length <= cap) return [[head, true], [key, false], [tail, true]];
10086
+ const tailRoom = cap - head.length - key.length - 1;
10087
+ if (tail && tailRoom >= 0) return [[head, true], [key, false], [tail.slice(0, tailRoom) + E, true]];
10088
+ const tailE = tail ? E : '';
10089
+ const headRoom = cap - key.length - tailE.length - 1;
10090
+ if (head && headRoom >= 0) return [[E + _nameTailChars(head, headRoom), true], [key, false], [tailE, true]];
10091
+ if (!head && key.length + tailE.length <= cap) return [[key, false], [tailE, true]];
10092
+ const keyRoom = cap - 1 - tailE.length;
10093
+ if (keyRoom > 0 && key.length - keyRoom <= near) {
10094
+ const cut = _nameTailChars(key, keyRoom);
10095
+ return [[E, true], [cut || key.slice(key.length - keyRoom), false], [tailE, true]];
10096
+ }
10097
+ const rest = key.slice(near).replace(/^[_\-.\/\\\s()]+/, '');
10098
+ const lead = (head || near) ? E : '';
10099
+ const room = cap - lead.length - tailE.length - 1;
10100
+ if (room < 2) return [[rest.slice(0, Math.max(1, cap - 1)), false], [E, true]];
10101
+ const a = Math.floor(room / 2), b = room - a;
10102
+ return [[lead, true], [rest.slice(0, a), false], [E, false], [rest.slice(rest.length - b), false], [tailE, true]];
10103
+ }
10104
+
10105
+ function _arrayNameHTML(runs) {
10106
+ // A name beside its image wraps after separators (not dots: they
10107
+ // sit inside numbers), and after folders when av-name-paths is on.
10108
+ const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;')
10109
+ .replace(/[_\-\s()]+/g, '$&<wbr>')
10110
+ .replace(/[\/\\]+/g, '$&<span class="av-name-br"></span>');
10111
+ return runs.filter(([t]) => t)
10112
+ .map(([t, shared]) => shared ? `<span class="av-name-shared">${esc(t)}</span>` : esc(t))
10113
+ .join('');
10114
+ }
10115
+
10116
+ // Names sit beside their image, in the empty margin, when it has room
10117
+ // for a column at least NAME_SIDE_MIN_W wide; otherwise inside it.
10118
+ // NAME_SIDE_GAP is also the CSS margin between name and image.
10119
+ const NAME_SIDE_MIN_W = 96;
10120
+ const NAME_SIDE_MAX_W = 280;
10121
+ const NAME_SIDE_GAP = 14;
10122
+ const NAME_SIDE_EDGE = 12;
10123
+ // Margin a row layout keeps free on each side for its row names,
10124
+ // counted from the window edge. Capped so a narrow window keeps its
10125
+ // images and the names get a narrower column instead.
10126
+ function _rowNameReserve() {
10127
+ const view = document.getElementById('qmri-view-wrap');
10128
+ const viewL = view ? Math.max(0, view.getBoundingClientRect().left) : 0;
10129
+ return Math.min(viewL + NAME_SIDE_MIN_W + NAME_SIDE_GAP + NAME_SIDE_EDGE, window.innerWidth * 0.17);
10130
+ }
10131
+
10132
+ function _fitArrayName(el) {
10133
+ const p = el && el._nameParts;
10134
+ if (!p) return;
10135
+ if (el.isConnected && el._namePlace) el._namePlace(el);
10136
+ const full = p.head.length + p.key.length + p.tail.length;
10137
+ const show = cap => { el.innerHTML = _arrayNameHTML(_arrayNameRuns(p, cap)); };
10138
+ el.classList.remove('av-name-paths');
10139
+ show(full);
10140
+ if (!el.isConnected) return;
10141
+ const fits = () => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight;
10142
+ // A name that needs several lines starts each folder on a new one,
10143
+ // unless that alone makes it too tall.
10144
+ if (el.classList.contains('av-name-side') && el.scrollHeight > 1.5 * parseFloat(getComputedStyle(el).lineHeight)) {
10145
+ el.classList.add('av-name-paths');
10146
+ if (!fits()) el.classList.remove('av-name-paths');
10147
+ }
10148
+ if (fits() || el.clientWidth <= 0) return;
10149
+ let lo = 1, hi = full - 1;
10150
+ while (lo < hi) {
10151
+ const mid = Math.ceil((lo + hi) / 2);
10152
+ show(mid);
10153
+ if (fits()) lo = mid; else hi = mid - 1;
10154
+ }
10155
+ show(lo);
10156
+ }
10157
+
10158
+ // Refit names when their pane, row or view changes size.
10159
+ const _arrayNameRO = typeof ResizeObserver === 'function'
10160
+ ? new ResizeObserver(entries => {
10161
+ const names = new Set();
10162
+ for (const e of entries) {
10163
+ if (!e.target.isConnected) { _arrayNameRO.unobserve(e.target); continue; }
10164
+ e.target.querySelectorAll('.av-name').forEach(n => names.add(n));
10165
+ }
10166
+ names.forEach(_fitArrayName);
10167
+ })
10168
+ : null;
10169
+
10170
+ // Hover text for a shortened name: the file's full path when known.
10171
+ function _arrayNameHover(sidVal, label) {
10172
+ return _compareFilepathBySid[sidVal] || label;
10173
+ }
10174
+
10175
+ function _setArrayName(el, parts, fullName, slots, place) {
10176
+ el.classList.add('av-name');
10177
+ el._nameParts = parts;
10178
+ el._namePlace = place || null;
10179
+ el.title = fullName;
10180
+ _fitArrayName(el);
10181
+ if (_arrayNameRO) slots.forEach(slot => slot && _arrayNameRO.observe(slot));
10182
+ }
10183
+
10184
+ // compare-qMRI / compare-3plane: the name sits left of its row, in
10185
+ // the margin the layout keeps free (_rowNameReserve). The view
10186
+ // clips at its own edge, not the window's.
10187
+ function _placeRowName(el) {
10188
+ const r = el.parentElement.getBoundingClientRect();
10189
+ const viewL = document.getElementById('qmri-view-wrap').getBoundingClientRect().left;
10190
+ const w = Math.min(NAME_SIDE_MAX_W, r.left - viewL - NAME_SIDE_EDGE - NAME_SIDE_GAP);
10191
+ el.style.width = Math.max(40, w) + 'px';
10192
+ el.style.maxHeight = Math.max(16, r.height) + 'px';
10193
+ }
10194
+
10195
+ // Compare panes: names move into the margin beside the panes when
10196
+ // every pane has one (one or two columns) with room; otherwise they
10197
+ // all stay inside their images, never a mix.
10198
+ function _compareTitleSides() {
10199
+ const wrap = document.getElementById('compare-view-wrap');
10200
+ if (!wrap.classList.contains('compare-titles-inset')) return null;
10201
+ const panes = [...wrap.querySelectorAll('.compare-pane.active')]
10202
+ .filter(q => q.getBoundingClientRect().width > 0);
10203
+ if (!panes.length) return null;
10204
+ const rects = panes.map(q => q.getBoundingClientRect());
10205
+ const minL = Math.min(...rects.map(r => r.left));
10206
+ const maxR = Math.max(...rects.map(r => r.right));
10207
+ const roomL = minL - NAME_SIDE_EDGE - NAME_SIDE_GAP;
10208
+ const roomR = window.innerWidth - maxR - NAME_SIDE_EDGE - NAME_SIDE_GAP;
10209
+ const sides = new Map();
10210
+ for (let i = 0; i < panes.length; i++) {
10211
+ const r = rects[i];
10212
+ if (r.left <= minL + 1 && roomL >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'left', w: roomL, h: r.height });
10213
+ else if (r.right >= maxR - 1 && roomR >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'right', w: roomR, h: r.height });
10214
+ else return null;
10215
+ }
10216
+ return sides;
10217
+ }
10218
+
10219
+ function _placeCompareTitle(el) {
10220
+ const title = el.closest('.compare-title');
10221
+ const pane = title && title.closest('.compare-pane');
10222
+ const sides = pane && !title.classList.contains('compare-side-title') ? _compareTitleSides() : null;
10223
+ const spot = sides && sides.get(pane);
10224
+ const side = spot ? spot.side : null;
10225
+ title.classList.toggle('compare-title-out-left', side === 'left');
10226
+ title.classList.toggle('compare-title-out-right', side === 'right');
10227
+ el.classList.toggle('av-name-side', !!side);
10228
+ el.style.width = side ? Math.min(NAME_SIDE_MAX_W, spot.w) + 'px' : '';
10229
+ el.style.maxHeight = side ? spot.h + 'px' : '';
10230
+ }
10231
+
9980
10232
  function _resetDetachedDimState() {
9981
10233
  detachedDimMode = false;
9982
10234
  detachedDim = -1;
@@ -10110,14 +10362,17 @@
10110
10362
  drawRegBlendCb();
10111
10363
  }
10112
10364
  const _logoSvg = '<svg width="14" height="14" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0"><rect width="16" height="16" rx="2.5" fill="#0c0c0c"/><rect x="2" y="2" width="3" height="3" fill="#3a0ca3"/><rect x="6.5" y="2" width="3" height="3" fill="#560bad"/><rect x="11" y="2" width="3" height="3" fill="#c77dff"/><rect x="2" y="6.5" width="3" height="3" fill="#4361ee"/><rect x="6.5" y="6.5" width="3" height="3" fill="#4cc9f0"/><rect x="11" y="6.5" width="3" height="3" fill="#f5c842"/><rect x="2" y="11" width="3" height="3" fill="#4895ef"/><rect x="6.5" y="11" width="3" height="3" fill="#80ed99"/><rect x="11" y="11" width="3" height="3" fill="#f8961e"/></svg>';
10365
+ const nameParts = detachedDimMode ? null : _arrayNameParts(labels);
10113
10366
  compareTitles.forEach((el, idx) => {
10114
10367
  const text = labels[idx] || '';
10115
10368
  const sideTitle = _isCompareBigLeftLayout() && idx < 2;
10116
10369
  el.classList.toggle('compare-side-title', sideTitle);
10117
- if (text) {
10118
- const prefix = (!detachedDimMode && !sideTitle) ? _logoSvg : '';
10119
- const titleHtml = detachedDimMode ? _detachedDimPaneTitleHTML(idx, text) : text.replace(/</g, '&lt;');
10120
- el.innerHTML = prefix + '<span>' + titleHtml + '</span>';
10370
+ if (text && detachedDimMode) {
10371
+ el.innerHTML = '<span>' + _detachedDimPaneTitleHTML(idx, text) + '</span>';
10372
+ } else if (text) {
10373
+ el.innerHTML = (sideTitle ? '' : _logoSvg) + '<span></span>';
10374
+ _setArrayName(el.lastElementChild, nameParts[idx], _arrayNameHover(effectiveSids[idx], text),
10375
+ [el.closest('.compare-pane'), document.getElementById('compare-view-wrap')], _placeCompareTitle);
10121
10376
  } else {
10122
10377
  el.textContent = '';
10123
10378
  }
@@ -37735,12 +37990,6 @@
37735
37990
  // Column header row with map names
37736
37991
  const headerRow = document.createElement('div');
37737
37992
  headerRow.className = 'cq-header-row';
37738
- // Spacer matching row label width
37739
- const headerSpacer = document.createElement('div');
37740
- headerSpacer.className = 'cq-row-label';
37741
- headerSpacer.style.visibility = 'hidden';
37742
- headerSpacer.textContent = '\u00A0';
37743
- headerRow.appendChild(headerSpacer);
37744
37993
  for (let mi = 0; mi < nMaps; mi++) {
37745
37994
  const hLabel = document.createElement('div');
37746
37995
  hLabel.className = 'cq-header-label qv-label';
@@ -37750,16 +37999,18 @@
37750
37999
  wrap.appendChild(headerRow);
37751
38000
 
37752
38001
  compareQmriViews = [];
38002
+ const rowNames = compareQmriSids.map(comparePaneLabelForSid);
38003
+ const rowNameParts = _arrayNameParts(rowNames);
37753
38004
  // One row per compared array (SID)
37754
38005
  for (let si = 0; si < compareQmriSids.length; si++) {
37755
38006
  const sidVal = compareQmriSids[si];
37756
38007
  const rowDiv = document.createElement('div');
37757
38008
  rowDiv.className = 'qv-row cq-row';
37758
- // Row label: array name
37759
- const rowLabel = document.createElement('div');
37760
- rowLabel.className = 'cq-row-label';
37761
- rowLabel.textContent = comparePaneLabelForSid(sidVal);
37762
- rowDiv.appendChild(rowLabel);
38009
+ // Array name, left of its row
38010
+ const rowName = document.createElement('div');
38011
+ rowName.className = 'cq-row-name av-name-side';
38012
+ rowDiv.appendChild(rowName);
38013
+ _setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
37763
38014
  for (let mi = 0; mi < nMaps; mi++) {
37764
38015
  const actualIdx = mapIndices[mi];
37765
38016
  const cmap = cmaps[mi];
@@ -38057,7 +38308,7 @@
38057
38308
  const rows = nRows;
38058
38309
  const colGap = rows >= 3 ? 10 : 24;
38059
38310
  const rowGap = rows >= 3 ? 4 : 14;
38060
- const sidePad = rows >= 3 ? 80 : 120;
38311
+ const sidePad = Math.max(rows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
38061
38312
  const labelH = 24; // column headers
38062
38313
  const anyExpanded = ready.some(v => v._colorBar && v._colorBar._expanded);
38063
38314
  const cbH = anyExpanded ? 33 + (CB_DEFAULT_EXPANDED_H - CB_COLLAPSED_H) : 33;
@@ -38286,23 +38537,18 @@
38286
38537
  }
38287
38538
 
38288
38539
  compareMvViews = [];
38540
+ const rowNames = compareMvSids.map(comparePaneLabelForSid);
38541
+ const rowNameParts = _arrayNameParts(rowNames);
38289
38542
  // One row per compared array (SID)
38290
38543
  for (let si = 0; si < compareMvSids.length; si++) {
38291
38544
  const sidVal = compareMvSids[si];
38292
38545
  const rowDiv = document.createElement('div');
38293
38546
  rowDiv.className = 'qv-row cq-row';
38294
- // Row label: array name (basename only)
38295
- const rowLabel = document.createElement('div');
38296
- rowLabel.className = 'cq-row-label';
38297
- const _rawLabel = comparePaneLabelForSid(sidVal);
38298
- const _shortLabel = l => l.split('/').pop().split('\\').pop().replace(/\.[^.]+$/, '') || l;
38299
- // Basenames alone, unless that would make two rows look alike;
38300
- // then keep the folders the pane titles use to tell them apart.
38301
- const _shorts = compareMvSids.map(x => _shortLabel(comparePaneLabelForSid(x)));
38302
- rowLabel.textContent = _shorts.filter(x => x === _shorts[si]).length > 1
38303
- ? _rawLabel.replace(/\.[^./\\\s]+(\s\(\d+\))?$/, '$1') : _shorts[si];
38304
- rowLabel.title = _rawLabel; // full name on hover
38305
- rowDiv.appendChild(rowLabel);
38547
+ // Array name, left of its row
38548
+ const rowName = document.createElement('div');
38549
+ rowName.className = 'cq-row-name av-name-side';
38550
+ rowDiv.appendChild(rowName);
38551
+ _setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
38306
38552
  // 3 planes per row
38307
38553
  for (let pi = 0; pi < planeDefs.length; pi++) {
38308
38554
  const def = planeDefs[pi];
@@ -38684,7 +38930,7 @@
38684
38930
  const nCols = 3; // always 3 planes
38685
38931
  const colGap = nRows >= 3 ? 12 : 24;
38686
38932
  const rowGap = nRows >= 3 ? 6 : 14;
38687
- const sidePad = nRows >= 3 ? 80 : 120; // tighter for multi-array
38933
+ const sidePad = Math.max(nRows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
38688
38934
  const labelH = 0; // no column headers in compare-multiview
38689
38935
  const cbWrap = document.getElementById('mv-cb-wrap');
38690
38936
  const cbH = cbWrap && colorbarVisible() ? (cbWrap.offsetHeight || 33) : 0;
@@ -43044,7 +43290,11 @@
43044
43290
 
43045
43291
  function _reconcileCompareTitlesInset() {
43046
43292
  compareWrap.classList.toggle('compare-touching', _compareTouching());
43047
- compareWrap.classList.toggle('compare-titles-inset', _compareTitlesInset());
43293
+ const inset = _compareTitlesInset();
43294
+ if (inset !== compareWrap.classList.contains('compare-titles-inset')) {
43295
+ compareWrap.classList.toggle('compare-titles-inset', inset);
43296
+ compareWrap.querySelectorAll('.av-name').forEach(_fitArrayName);
43297
+ }
43048
43298
  }
43049
43299
 
43050
43300
  // ── Centralized CB / island visibility reconciler ────────────
@@ -1234,7 +1234,7 @@ class TestModeCombinations:
1234
1234
  page.wait_for_function("() => compareActive && compareMvActive", timeout=15_000)
1235
1235
  # Panes are titled with the file names, not session ids.
1236
1236
  page.wait_for_function(
1237
- "() => [...document.querySelectorAll('.cq-row-label')].map(e => e.textContent).join(' ').includes('cmportho0')",
1237
+ "() => [...document.querySelectorAll('.cq-row-name')].map(e => e.textContent).join(' ').includes('cmportho0')",
1238
1238
  timeout=5_000,
1239
1239
  )
1240
1240
 
@@ -1341,8 +1341,10 @@ class TestModeCombinations:
1341
1341
  np.save(path, arr_3d)
1342
1342
  sids.append(client.post("/load", json={"filepath": str(path), "name": "t1.npy"}).json()["sid"])
1343
1343
  page.goto(f"{server_url}/?sid={sids[0]}&compare_sid={sids[1]}&compare_sids={sids[1]}")
1344
+ # Only the part that differs is shown; the full path is on hover.
1344
1345
  page.wait_for_function(
1345
- """() => { const t = document.getElementById('compare-left-title')?.textContent || '';
1346
- return t.includes('pat_a/t1.npy'); }""",
1346
+ """() => { const n = document.querySelector('#compare-left-title .av-name');
1347
+ return n && n.textContent === 'pat_a'
1348
+ && n.title.endsWith('pat_a/t1.npy'); }""",
1347
1349
  timeout=15_000,
1348
1350
  )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes