arrayview 0.48.3__py3-none-any.whl → 0.48.4__py3-none-any.whl

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.
arrayview/_diff.py CHANGED
@@ -85,6 +85,43 @@ def _resize_normalized_like(source: np.ndarray, target: np.ndarray) -> np.ndarra
85
85
  return np.array(image, dtype=np.float32) / 255.0
86
86
 
87
87
 
88
+ def _render_values(session, dim_x, dim_y, idx_tuple, complex_mode, log_scale):
89
+ """Extract a slice in the array's own units (complex/log applied, no rescale)."""
90
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
91
+ data = apply_complex_mode(raw, complex_mode)
92
+ if log_scale:
93
+ data = np.log1p(np.abs(data))
94
+ return np.asarray(data, dtype=np.float32)
95
+
96
+
97
+ def _render_values_mosaic(session, dim_x, dim_y, dim_z, idx_tuple, complex_mode, log_scale):
98
+ """Mosaic of every dim_z slice in the array's own units, plus separator mask."""
99
+ idx_list = list(idx_tuple)
100
+ frames = [
101
+ _render_values(
102
+ session,
103
+ dim_x,
104
+ dim_y,
105
+ [i if j == dim_z else idx_list[j] for j in range(len(session.shape))],
106
+ complex_mode,
107
+ log_scale,
108
+ )
109
+ for i in range(session.shape[dim_z])
110
+ ]
111
+ all_data = np.stack(frames)
112
+ grid, _, _ = _build_mosaic_grid(all_data, len(frames))
113
+ return grid.astype(np.float32), np.isnan(grid)
114
+
115
+
116
+ def _resize_values_like(source: np.ndarray, target: np.ndarray) -> np.ndarray:
117
+ """Resize float source data to target shape without quantising it."""
118
+ if source.shape == target.shape:
119
+ return source
120
+ image = _pil_image().fromarray(np.nan_to_num(source).astype(np.float32), mode="F")
121
+ image = image.resize((target.shape[1], target.shape[0]), _pil_image().BILINEAR)
122
+ return np.array(image, dtype=np.float32)
123
+
124
+
88
125
  def _compute_diff(
89
126
  session_a,
90
127
  session_b,
@@ -114,65 +151,41 @@ def _compute_diff(
114
151
  idx_b = _parse_indices(indices_b)[:ndim_b] if indices_b is not None else idx_tuple[:ndim_b]
115
152
  nan_mask = None
116
153
 
154
+ # True A−B in the arrays' own units. Each side used to be rescaled to its
155
+ # own per-slice display range first, which cancelled real differences
156
+ # (B = 0.9·A showed as zero) and made the map flicker while scrolling.
117
157
  if dim_z >= 0:
118
- a, nan_mask_a = _render_normalized_mosaic(
119
- session_a,
120
- dim_x,
121
- dim_y,
122
- dim_z,
123
- idx_a,
124
- dr,
125
- complex_mode,
126
- log_scale,
158
+ a, nan_mask_a = _render_values_mosaic(
159
+ session_a, dim_x, dim_y, dim_z, idx_a, complex_mode, log_scale
127
160
  )
128
- b, nan_mask_b = _render_normalized_mosaic(
129
- session_b,
130
- dim_x,
131
- dim_y,
132
- dim_z,
133
- idx_b,
134
- dr,
135
- complex_mode,
136
- log_scale,
161
+ b, nan_mask_b = _render_values_mosaic(
162
+ session_b, dim_x, dim_y, dim_z, idx_b, complex_mode, log_scale
137
163
  )
138
- nan_mask = nan_mask_a | nan_mask_b
164
+ b = _resize_values_like(b, a)
165
+ if nan_mask_b.shape == nan_mask_a.shape:
166
+ nan_mask = nan_mask_a | nan_mask_b
167
+ else:
168
+ nan_mask = nan_mask_a
139
169
  else:
140
- a = _render_normalized(
141
- session_a,
142
- dim_x,
143
- dim_y,
144
- idx_a,
145
- dr,
146
- complex_mode,
147
- log_scale,
148
- )
149
- b = _render_normalized(
150
- session_b,
151
- dim_x,
152
- dim_y,
153
- idx_b,
154
- dr,
155
- complex_mode,
156
- log_scale,
157
- )
158
-
159
- b = _resize_normalized_like(b, a)
170
+ a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
171
+ b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
172
+ b = _resize_values_like(b, a)
160
173
  if diff_mode == 1:
161
174
  raw = a - b
162
175
  lo = float(np.nanpercentile(raw, 1))
163
176
  hi = float(np.nanpercentile(raw, 99))
164
177
  vmax = max(abs(lo), abs(hi)) or 1.0
165
178
  vmin = -vmax
166
- colormap = "RdBu_r"
179
+ colormap = "icefire"
167
180
  elif diff_mode == 2:
168
181
  raw = np.abs(a - b)
169
- vmax = float(raw.max()) or 1.0
182
+ vmax = float(np.nanmax(raw)) or 1.0
170
183
  vmin = 0.0
171
184
  colormap = "afmhot"
172
185
  else:
173
186
  raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
174
187
  raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
175
- vmax = float(raw.max()) or 1.0
188
+ vmax = float(np.nanmax(raw)) or 1.0
176
189
  vmin = 0.0
177
190
  colormap = "afmhot"
178
191
 
@@ -188,7 +201,7 @@ def _render_diff_rgba(
188
201
  ) -> np.ndarray:
189
202
  """Map raw diff data to RGBA."""
190
203
  if vmax > vmin:
191
- normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
204
+ normalized = np.clip(np.nan_to_num((raw - vmin) / (vmax - vmin)), 0, 1)
192
205
  else:
193
206
  normalized = np.zeros_like(raw)
194
207
  _ensure_lut(colormap)
@@ -201,9 +214,12 @@ def _render_diff_rgba(
201
214
 
202
215
  def _diff_histogram(raw: np.ndarray, bins: int) -> dict[str, object]:
203
216
  """Return histogram payload for raw diff data."""
217
+ raw = raw[np.isfinite(raw)]
218
+ if raw.size == 0:
219
+ raw = np.zeros(1, dtype=np.float32)
204
220
  vmin = float(raw.min())
205
221
  vmax = float(raw.max())
206
- counts, edges = np.histogram(raw.ravel(), bins=bins)
222
+ counts, edges = np.histogram(raw, bins=bins)
207
223
  return {
208
224
  "counts": counts.tolist(),
209
225
  "edges": edges.tolist(),
arrayview/_render.py CHANGED
@@ -34,6 +34,43 @@ _mpl_colormaps = None
34
34
  _luts_lock = threading.Lock()
35
35
 
36
36
 
37
+ _ICEFIRE_STOPS = [
38
+ (0.7394, 0.9044, 0.8576),
39
+ (0.6645, 0.8497, 0.8387),
40
+ (0.5749, 0.7908, 0.8202),
41
+ (0.4818, 0.734, 0.8084),
42
+ (0.3863, 0.6778, 0.8043),
43
+ (0.3052, 0.6184, 0.8053),
44
+ (0.2418, 0.5555, 0.8106),
45
+ (0.2141, 0.4873, 0.8157),
46
+ (0.2487, 0.4134, 0.7887),
47
+ (0.2831, 0.3444, 0.715),
48
+ (0.2846, 0.2908, 0.6028),
49
+ (0.2577, 0.2497, 0.4821),
50
+ (0.2205, 0.2121, 0.3737),
51
+ (0.182, 0.1769, 0.2809),
52
+ (0.1492, 0.1465, 0.2053),
53
+ (0.1276, 0.1254, 0.1501),
54
+ (0.1213, 0.1183, 0.1188),
55
+ (0.1538, 0.1167, 0.1227),
56
+ (0.2063, 0.1255, 0.1416),
57
+ (0.2734, 0.1407, 0.1703),
58
+ (0.3514, 0.157, 0.2039),
59
+ (0.4375, 0.1703, 0.2357),
60
+ (0.5298, 0.1784, 0.2601),
61
+ (0.6251, 0.1832, 0.2686),
62
+ (0.7138, 0.2028, 0.2533),
63
+ (0.7898, 0.2469, 0.2268),
64
+ (0.85, 0.3142, 0.2058),
65
+ (0.8971, 0.3932, 0.2012),
66
+ (0.9279, 0.482, 0.2415),
67
+ (0.9479, 0.5722, 0.3318),
68
+ (0.9659, 0.659, 0.4438),
69
+ (0.9829, 0.7447, 0.5597),
70
+ (0.9992, 0.831, 0.6764),
71
+ ]
72
+
73
+
37
74
  def _init_luts():
38
75
  """Build colourmap LUTs on first use (defers matplotlib + qmricolors import)."""
39
76
  global _mpl_colormaps
@@ -46,6 +83,12 @@ def _init_luts():
46
83
  from matplotlib import colormaps
47
84
  from matplotlib.colors import LinearSegmentedColormap
48
85
 
86
+ if "icefire" not in colormaps:
87
+ # seaborn's icefire (blue → near-black → red), sampled at 33 stops so
88
+ # we don't depend on seaborn. Default for the A−B compare center.
89
+ colormaps.register(
90
+ LinearSegmentedColormap.from_list("icefire", _ICEFIRE_STOPS)
91
+ )
49
92
  _mpl_colormaps = colormaps
50
93
  for name in COLORMAPS:
51
94
  lut = np.concatenate(
@@ -210,6 +253,12 @@ def _read_slice(session, dim_x, dim_y, idx_list, progress):
210
253
  if progress is None:
211
254
  progress = getattr(session, 'progress_sink', None)
212
255
  data = session.data
256
+ if not getattr(session, "_ram_upgrade_started", False):
257
+ # Not every loading route asks for this (compare arrays, for one), and
258
+ # without it a scattered plane is re-read from the file on every step.
259
+ from ._session import upgrade_memmap_in_background
260
+
261
+ upgrade_memmap_in_background(session)
213
262
  if isinstance(data, np.memmap) and data.filename:
214
263
  # A slice scattered one element per page reads far faster as many
215
264
  # parallel small reads than by faulting through the map (local disks).
@@ -441,6 +441,54 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
441
441
  return Response(status_code=422)
442
442
  return _diff_histogram(raw, bins)
443
443
 
444
+ @app.get("/diff-values/{sid_a}/{sid_b}")
445
+ def get_diff_values(
446
+ sid_a: str,
447
+ sid_b: str,
448
+ dim_x: int,
449
+ dim_y: int,
450
+ indices: str,
451
+ indices_a: str | None = None,
452
+ indices_b: str | None = None,
453
+ dim_z: int = -1,
454
+ dr: int = 1,
455
+ complex_mode: int = 0,
456
+ log_scale: bool = False,
457
+ diff_mode: int = 1,
458
+ ):
459
+ """Raw float32 diff values, so hovering a histogram bin can highlight pixels."""
460
+ session_a = SESSIONS.get(sid_a)
461
+ session_b = SESSIONS.get(sid_b)
462
+ if not session_a or not session_b:
463
+ return Response(status_code=404)
464
+ try:
465
+ raw, _, _, _, _ = _compute_diff(
466
+ session_a,
467
+ session_b,
468
+ dim_x,
469
+ dim_y,
470
+ indices,
471
+ dim_z,
472
+ dr,
473
+ complex_mode,
474
+ log_scale,
475
+ diff_mode,
476
+ indices_a=indices_a,
477
+ indices_b=indices_b,
478
+ )
479
+ except Exception:
480
+ return Response(status_code=422)
481
+ raw = np.ascontiguousarray(raw, dtype=np.float32)
482
+ return Response(
483
+ content=raw.tobytes(),
484
+ media_type="application/octet-stream",
485
+ headers={
486
+ "X-ArrayView-Width": str(raw.shape[1]),
487
+ "X-ArrayView-Height": str(raw.shape[0]),
488
+ "Cache-Control": "no-cache",
489
+ },
490
+ )
491
+
444
492
  @app.get("/oblique/{sid}")
445
493
  def get_oblique(
446
494
  sid: str,
@@ -50,6 +50,47 @@ _RECOVERABLE_DISCONNECT_GRACE_SECONDS = float(
50
50
  )
51
51
 
52
52
 
53
+ # Leading word of a compressed frame: b"AVZ1" read as a little-endian uint32.
54
+ # A raw frame starts with its sequence number, which never gets this large.
55
+ _FRAME_Z_MAGIC = b"AVZ1"
56
+ # Below this a frame crosses any link quickly enough as it is.
57
+ _FRAME_Z_MIN_BYTES = 32 * 1024
58
+ _frame_z_remote = None
59
+
60
+
61
+ def _frame_compression_wanted() -> bool:
62
+ """True when frames should be deflated before they are sent.
63
+
64
+ Over a VS Code tunnel or Remote-SSH forward every frame crosses the network
65
+ as raw RGBA: three compared 224x224 slices are 600 KB per scroll step, and
66
+ that transfer, not rendering, is the lag. Locally the bytes are free, so
67
+ frames stay raw. ``ARRAYVIEW_FRAME_COMPRESS=1``/``0`` forces either way.
68
+ """
69
+ global _frame_z_remote
70
+ forced = os.environ.get("ARRAYVIEW_FRAME_COMPRESS")
71
+ if forced in ("0", "1"):
72
+ return forced == "1"
73
+ if _frame_z_remote is None:
74
+ try:
75
+ from arrayview._platform import _is_vscode_remote
76
+
77
+ _frame_z_remote = bool(_is_vscode_remote())
78
+ except Exception:
79
+ _frame_z_remote = False
80
+ return _frame_z_remote
81
+
82
+
83
+ def _pack_frame(payload: bytes) -> bytes:
84
+ """Deflate *payload* behind the magic word, or return it unchanged when
85
+ that does not save at least a third (noise-like images barely shrink)."""
86
+ import zlib
87
+
88
+ packed = zlib.compress(payload, 1)
89
+ if len(packed) * 3 > len(payload) * 2:
90
+ return payload
91
+ return _FRAME_Z_MAGIC + packed
92
+
93
+
53
94
  def _advance_viewer_epoch(sid: str) -> int:
54
95
  epoch = _session_mod.VIEWER_CONNECTION_EPOCHS.get(sid, 0) + 1
55
96
  _session_mod.VIEWER_CONNECTION_EPOCHS[sid] = epoch
@@ -368,6 +409,10 @@ def register_websocket_routes(app) -> None:
368
409
  native_request_id = ws.query_params.get("native_request_id")
369
410
  launch_request_id = ws.query_params.get("launch_request_id")
370
411
  launch_token = ws.query_params.get("launch_token")
412
+ # Only viewers that can inflate frames ask for them packed.
413
+ compress_frames = (
414
+ ws.query_params.get("frame_z") == "1" and _frame_compression_wanted()
415
+ )
371
416
  # Answer the handshake before the array is ready. Holding it closed
372
417
  # until the load finished made the viewer decide the backend was dead:
373
418
  # it gives up after three attempts and shows "could not connect", which
@@ -752,6 +797,9 @@ def register_websocket_routes(app) -> None:
752
797
  ).tobytes()
753
798
  vf_scale = np.array([vf_result["scale"]], dtype=np.float32).tobytes()
754
799
  payload += vf_hdr + vf_scale + arrows.tobytes()
800
+ if compress_frames and len(payload) >= _FRAME_Z_MIN_BYTES:
801
+ raw_payload = payload
802
+ payload = await _render(loop, lambda: _pack_frame(raw_payload))
755
803
  await ws.send_bytes(payload)
756
804
  if perf:
757
805
  total_ms = (time.perf_counter() - total_t0) * 1000.0
arrayview/_session.py CHANGED
@@ -870,6 +870,9 @@ def upgrade_memmap_in_background(session) -> bool:
870
870
  identical, so a render already holding the lazy array stays correct and the
871
871
  slice caches stay valid.
872
872
  """
873
+ if getattr(session, "_ram_upgrade_started", False):
874
+ return False
875
+ session._ram_upgrade_started = True
873
876
  data = getattr(session, "data", None)
874
877
  filepath = getattr(session, "filepath", None)
875
878
  if data is None or not filepath:
arrayview/_viewer.html CHANGED
@@ -81,6 +81,9 @@
81
81
  + 'launch_request_id=' + encodeURIComponent(requestId)
82
82
  + '&launch_token=' + encodeURIComponent(token);
83
83
  }
84
+ if (typeof DecompressionStream === 'function') {
85
+ target += (target.indexOf('?') >= 0 ? '&' : '?') + 'frame_z=1';
86
+ }
84
87
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
85
88
  var url = proto + '//' + location.host + target;
86
89
  var early = { url: url, ws: new WebSocket(url), opened: false, events: [] };
@@ -2130,15 +2133,19 @@
2130
2133
  .drop-action-reason { display: block; margin-top: 2px; font-size: 9px; color: var(--muted); }
2131
2134
  #compare-with-menu {
2132
2135
  display: none; position: fixed; z-index: 70; left: 50%; top: 50%;
2133
- transform: translate(-50%, -50%); width: min(340px, calc(100vw - 24px));
2136
+ transform: translate(-50%, -50%); width: min(560px, calc(100vw - 24px));
2134
2137
  max-height: calc(100vh - 48px); overflow-y: auto;
2135
2138
  padding: 8px; background: var(--surface); border: 1px solid var(--border);
2136
- border-radius: var(--radius-lg); color: var(--text); font: 11px var(--mono);
2139
+ border-radius: var(--radius-lg); color: var(--text); font: 9px var(--mono);
2137
2140
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
2138
2141
  }
2139
2142
  #compare-with-menu.visible { display: block; }
2140
2143
  #compare-with-title { padding: 4px 6px 2px; }
2141
- #compare-with-summary { color: var(--muted); padding: 0 6px 7px; font-size: 10px; }
2144
+ #compare-with-summary { color: var(--muted); padding: 0 6px 7px; font-size: 9px; }
2145
+ #compare-with-title { font-size: 10px; }
2146
+ #compare-with-list { font-size: 10px; }
2147
+ .compare-with-path { word-break: break-all; }
2148
+ #compare-with-list .drop-action-reason { font-size: 8px; }
2142
2149
  .compare-with-key { color: var(--muted); margin-right: 8px; }
2143
2150
  .mode-badge {
2144
2151
  display: inline-block; font-size: 0.62em; font-weight: 600;
@@ -6359,6 +6366,41 @@
6359
6366
  return ws;
6360
6367
  }
6361
6368
 
6369
+ const _FRAME_Z_SUPPORTED = typeof DecompressionStream === 'function';
6370
+ const _FRAME_Z_MAGIC = 0x315A5641; // "AVZ1" read little-endian
6371
+
6372
+ function _inflateFrame(buf) {
6373
+ const body = new Blob([new Uint8Array(buf, 4)]).stream()
6374
+ .pipeThrough(new DecompressionStream('deflate'));
6375
+ return new Response(body).arrayBuffer();
6376
+ }
6377
+
6378
+ /**
6379
+ * Wrap a socket message handler so deflated frames arrive as the raw
6380
+ * frames every handler expects. Inflating is asynchronous, so while
6381
+ * one is in flight later messages queue behind it and keep their
6382
+ * order; with none pending a message is handed over synchronously.
6383
+ */
6384
+ function _inflatingMessageHandler(handler) {
6385
+ let pending = null;
6386
+ return function (event) {
6387
+ const data = event.data;
6388
+ const packed = data instanceof ArrayBuffer && data.byteLength >= 8
6389
+ && new Uint32Array(data, 0, 1)[0] === _FRAME_Z_MAGIC;
6390
+ if (!packed && !pending) { handler.call(this, event); return; }
6391
+ const self = this;
6392
+ const ready = packed ? _inflateFrame(data) : null;
6393
+ const step = (pending || Promise.resolve())
6394
+ .then(() => ready)
6395
+ .then(raw => {
6396
+ handler.call(self, raw ? new MessageEvent('message', { data: raw }) : event);
6397
+ })
6398
+ .catch(err => console.error('frame inflate failed', err));
6399
+ pending = step;
6400
+ step.then(() => { if (pending === step) pending = null; });
6401
+ };
6402
+ }
6403
+
6362
6404
  function createTransport(transportSid, onMessage, onOpen, onClose, onError, adoptEarly = false) {
6363
6405
  let wsTarget = resolveServerPath(`/ws/${transportSid}`);
6364
6406
  const nativeRequestId = urlParams.get('native_request_id');
@@ -6376,6 +6418,12 @@
6376
6418
  wsTarget += `${separator}launch_request_id=${encodeURIComponent(launchRequestId)}`;
6377
6419
  wsTarget += `&launch_token=${encodeURIComponent(launchToken)}`;
6378
6420
  }
6421
+ // Ask for frames deflated; the server only does so over a remote
6422
+ // link. Must stay last so the URL matches the early socket's.
6423
+ if (_FRAME_Z_SUPPORTED) {
6424
+ const separator = wsTarget.includes('?') ? '&' : '?';
6425
+ wsTarget += `${separator}frame_z=1`;
6426
+ }
6379
6427
  const socketUrl = () => {
6380
6428
  if (_embeddedServerOrigin) {
6381
6429
  const wsUrl = new URL(wsTarget);
@@ -6393,7 +6441,7 @@
6393
6441
  const ws = early || (hedged ? _createHedgedWebSocket(makeSocket) : makeSocket());
6394
6442
  ws.binaryType = 'arraybuffer';
6395
6443
  if (onOpen) ws.onopen = onOpen;
6396
- if (onMessage) ws.onmessage = onMessage;
6444
+ if (onMessage) ws.onmessage = _inflatingMessageHandler(onMessage);
6397
6445
  if (onClose) ws.onclose = onClose;
6398
6446
  if (onError) ws.onerror = onError;
6399
6447
  return ws;
@@ -7487,7 +7535,12 @@
7487
7535
  let _diffAutoRangeKey = null;
7488
7536
  let _diffCenterColormap = ''; // override colormap for diff/center pane; '' = server default
7489
7537
  let _diffCenterColormapStops = null; // gradient stops for center pane colorbar
7490
- const DIFF_COLORMAPS = ['RdBu_r', 'coolwarm', 'bwr', 'seismic', 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdYlBu_r', 'RdYlGn', 'twilight_shifted'];
7538
+ const DIFF_COLORMAPS = ['icefire', 'RdBu_r', 'coolwarm', 'bwr', 'seismic', 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdYlBu_r', 'RdYlGn', 'twilight_shifted'];
7539
+ // A−B default follows the theme: icefire's dark centre blends into the
7540
+ // dark viewport (masked voxels stay dark); light theme keeps RdBu_r.
7541
+ function _diffDefaultColormap() {
7542
+ return diffMode === 1 ? (isDark ? 'icefire' : 'RdBu_r') : 'afmhot';
7543
+ }
7491
7544
  const ABS_DIFF_COLORMAPS = ['afmhot', 'hot', 'inferno', 'magma', 'plasma', 'viridis', 'gray', 'cividis'];
7492
7545
  let _mouseOverCenterPane = false; // true when mouse hovers diff or wipe pane
7493
7546
  let compareFrames = [];
@@ -10295,6 +10348,7 @@
10295
10348
  });
10296
10349
  },
10297
10350
  fetchHistogram: () => _fetchHistogramCompare(),
10351
+ getHistogramVersion: () => _compareHistogramVersion(),
10298
10352
  onWindowChange: () => { compareRender(); },
10299
10353
  }));
10300
10354
  }
@@ -10356,13 +10410,18 @@
10356
10410
  hoverValueInEggs: true,
10357
10411
  hoverValueAnchor: () => modeManager.getViewById('compare-diff-center')?.canvas
10358
10412
  || document.getElementById('compare-diff-pane'),
10359
- getStops: () => _diffCenterColormapStops || COLORMAP_GRADIENT_STOPS[diffMode === 1 ? 'RdBu_r' : 'afmhot'] || COLORMAP_GRADIENT_STOPS['gray'] || [],
10413
+ getStops: () => _diffCenterColormapStops || COLORMAP_GRADIENT_STOPS[_diffDefaultColormap()] || COLORMAP_GRADIENT_STOPS['gray'] || [],
10360
10414
  getRange: () => ({ vmin: _lastDiffVmin, vmax: _lastDiffVmax }),
10361
10415
  getWindow: () => ({ vmin: _diffManualVmin ?? _lastDiffVmin, vmax: _diffManualVmax ?? _lastDiffVmax }),
10362
10416
  setWindow: (lo, hi) => { _diffManualVmin = lo; _diffManualVmax = hi; { const _dv = modeManager.getViewById('compare-diff-center'); if (_dv) { _dv.displayState.vmin = _diffManualVmin; _dv.displayState.vmax = _diffManualVmax; } } },
10363
10417
  fetchHistogram: () => _fetchDiffHistogram(),
10364
10418
  getHistogramVersion: () => _diffHistogramVersion(),
10365
10419
  onWindowChange: () => { fetchAndDrawDiff(); },
10420
+ onMouseEnter: () => { _colorbarLebesgueHover = true; },
10421
+ onMouseLeave: () => { _stopColorbarLebesgueHover(); },
10422
+ onDragStart: () => { _stopColorbarLebesgueHover(); },
10423
+ onDragEnd: () => { if (_diffCenterCb?._mouseOver) _colorbarLebesgueHover = true; },
10424
+ onBinHover: (binIdx) => { _drawLebesgueHighlightForColorbar(_diffCenterCb, binIdx); },
10366
10425
  });
10367
10426
  }
10368
10427
  }
@@ -14049,7 +14108,7 @@
14049
14108
  getWindow: () => ({ vmin: manualVmin ?? currentVmin, vmax: manualVmax ?? currentVmax }),
14050
14109
  setWindow: (lo, hi) => { manualVmin = lo; manualVmax = hi; { const _dv = modeManager.getFocusedView() || modeManager.currentViews[0]; if (_dv) { _dv.displayState.vmin = manualVmin; _dv.displayState.vmax = manualVmax; } } },
14051
14110
  fetchHistogram: () => compareActive ? _fetchHistogramCompare() : _fetchVolumeHistogram(_volHistSpatialOpts()),
14052
- getHistogramVersion: () => compareActive ? null : _volumeHistCacheKey(_volHistSpatialOpts()),
14111
+ getHistogramVersion: () => compareActive ? _compareHistogramVersion() : _volumeHistCacheKey(_volHistSpatialOpts()),
14053
14112
  onAnimFrame: (cb) => {
14054
14113
  // Layout recalculation during expand/collapse animation
14055
14114
  if (multiViewActive || compareMvActive) {
@@ -14149,7 +14208,8 @@
14149
14208
  const sdKey = scrollDims.slice().sort((a, b) => a - b).join(',');
14150
14209
  const qmriRole = opts.qmriRole || '';
14151
14210
  const numBins = opts.bins || 64;
14152
- return `${fetchDimX}:${fetchDimY}:${sdKey}:${fixedIndices}:${cMode}:${qmriRole}:${numBins}`;
14211
+ const sidKey = opts.sid && opts.sid !== sid ? `${opts.sid}|` : '';
14212
+ return `${sidKey}${fetchDimX}:${fetchDimY}:${sdKey}:${fixedIndices}:${cMode}:${qmriRole}:${numBins}`;
14153
14213
  }
14154
14214
 
14155
14215
  async function _fetchVolumeHistogram(opts = {}) {
@@ -14181,7 +14241,9 @@
14181
14241
  const qmriRole = opts.qmriRole || '';
14182
14242
  const numBins = opts.bins || 64;
14183
14243
  const exclZ = excludeZeros ? '1' : '0';
14184
- const cacheKey = `${fetchDimX}:${fetchDimY}:${sdKey}:${fixedIndices}:${cMode}:${qmriRole}:${numBins}:ez${exclZ}`;
14244
+ const fetchSid = opts.sid || sid;
14245
+ const sidKey = fetchSid !== sid ? `${fetchSid}|` : '';
14246
+ const cacheKey = `${sidKey}${fetchDimX}:${fetchDimY}:${sdKey}:${fixedIndices}:${cMode}:${qmriRole}:${numBins}:ez${exclZ}`;
14185
14247
 
14186
14248
  if (_volHistCache[cacheKey]) {
14187
14249
  _histDataVersion = cacheKey;
@@ -14203,13 +14265,9 @@
14203
14265
  if (qmriRole) params.set('qmri_role', qmriRole);
14204
14266
  if (excludeZeros) params.set('exclude_zeros', '1');
14205
14267
  const request = (async () => {
14206
- try {
14207
- const resp = await proxyFetch(`/volume-histogram/${sid}?${params}`);
14208
- if (!resp.ok) return null;
14209
- const data = await resp.json();
14210
- _volHistCache[cacheKey] = data;
14211
- return data;
14212
- } catch (_) { return null; }
14268
+ const data = await _fetchHistogramJson(`/volume-histogram/${fetchSid}?${params}`);
14269
+ if (data) _volHistCache[cacheKey] = data;
14270
+ return data;
14213
14271
  })();
14214
14272
  _volHistInFlight.set(cacheKey, request);
14215
14273
  try {
@@ -14612,12 +14670,7 @@
14612
14670
  if (!normalized) return null;
14613
14671
  const lo = normalized.vmin;
14614
14672
  const hi = normalized.vmax;
14615
- view.lockedVmin = lo;
14616
- view.lockedVmax = hi;
14617
- const ownerViews = compareQmriActive ? compareQmriViews : qmriViews;
14618
- const idx = ownerViews.indexOf(view);
14619
- const qv = idx >= 0 ? modeManager.currentViews[idx] : null;
14620
- if (qv) { qv.displayState.vmin = lo; qv.displayState.vmax = hi; }
14673
+ _qmriStoreWindow(view, lo, hi);
14621
14674
  return { vmin: lo, vmax: hi };
14622
14675
  }
14623
14676
 
@@ -14642,6 +14695,11 @@
14642
14695
  vmax: range.vmin + span * preset.hi,
14643
14696
  };
14644
14697
  }
14698
+ } else if (compareQmriActive) {
14699
+ hist = await _cqFetchColumnHistogram(view);
14700
+ if (generation != null && generation !== _qmriPresetCycleGeneration) return false;
14701
+ win = _quantileWindowFromHistogram(hist, preset);
14702
+ hist = _normalizeQmriHistogramForColorbar(view, hist);
14645
14703
  } else {
14646
14704
  const fixedIndices = `${qmriDim}:${view.qmriIdx}`;
14647
14705
  hist = await _fetchVolumeHistogram({
@@ -14680,14 +14738,17 @@
14680
14738
  });
14681
14739
  }
14682
14740
  if (view._colorBar) { view._colorBar.draw(); view._colorBar.updateLabels(); }
14683
- if (compareQmriActive) compareQmriRender(view);
14684
- else qvRender(view);
14741
+ _qmriRenderWindowOwners(view);
14685
14742
  return true;
14686
14743
  }
14687
14744
 
14688
14745
  async function _cycleQmriPaneRangePreset() {
14689
14746
  if (qmriMvActive) { await _cycleQmriMvRangePreset(); return true; }
14690
- const view = _hoveredQmriView;
14747
+ // In compare-qMRI only the bottom row shows colorbars, and each map
14748
+ // column shares one range, so act through the column's bottom pane.
14749
+ const view = compareQmriActive && _hoveredQmriView
14750
+ ? _cqColumnLead(_hoveredQmriView)
14751
+ : _hoveredQmriView;
14691
14752
  const activeCb = view
14692
14753
  ? (qmriMosaicActive && view._mosaicColorBar ? view._mosaicColorBar : view._colorBar)
14693
14754
  : null;
@@ -14712,7 +14773,9 @@
14712
14773
  if (view) {
14713
14774
  await _applyQmriRangePresetToView(view, preset, generation);
14714
14775
  } else {
14715
- const paneViews = compareQmriActive ? compareQmriViews : qmriViews;
14776
+ const paneViews = compareQmriActive
14777
+ ? compareQmriViews.filter(v => _cqColumnLead(v) === v)
14778
+ : qmriViews;
14716
14779
  await Promise.all(paneViews.map(v => _applyQmriRangePresetToView(v, preset, generation)));
14717
14780
  if (!compareQmriActive) qvScaleAllCanvases();
14718
14781
  }
@@ -14843,8 +14906,46 @@
14843
14906
  }
14844
14907
 
14845
14908
  /** Fetch histogram for all compare sessions and merge bins element-wise. */
14846
- async function _fetchHistogramCompare() {
14847
- if (_histFetching) return;
14909
+ /**
14910
+ * Identity of the slice histogram `_fetchHistogramCompare` builds.
14911
+ * Without it the colorbars kept the first histogram for good, so after
14912
+ * moving to another map `d` cut presets from the old map's values.
14913
+ */
14914
+ function _compareHistogramVersion() {
14915
+ return [
14916
+ getCompareRenderSids().join('/'), indices.join(','),
14917
+ dim_x, dim_y, complexMode,
14918
+ ].join('|');
14919
+ }
14920
+
14921
+ // `d` waits on the histogram request in flight, and every later press
14922
+ // joins that same request. One lost on a forwarded link never settles,
14923
+ // which left `d` dead for the rest of the session, so cap the wait;
14924
+ // the next press then asks again.
14925
+ const _HIST_FETCH_TIMEOUT_MS = 15000;
14926
+ async function _fetchHistogramJson(url) {
14927
+ const ctl = typeof AbortController === 'function' ? new AbortController() : null;
14928
+ const timer = ctl ? setTimeout(() => ctl.abort(), _HIST_FETCH_TIMEOUT_MS) : null;
14929
+ try {
14930
+ const resp = await proxyFetch(url, ctl ? { signal: ctl.signal } : undefined);
14931
+ return resp.ok ? await resp.json() : null;
14932
+ } catch (_) {
14933
+ return null;
14934
+ } finally {
14935
+ if (timer) clearTimeout(timer);
14936
+ }
14937
+ }
14938
+
14939
+ let _histCompareInFlight = null;
14940
+ function _fetchHistogramCompare() {
14941
+ if (_histCompareInFlight) return _histCompareInFlight;
14942
+ const request = _fetchHistogramCompareOnce();
14943
+ _histCompareInFlight = request;
14944
+ request.finally(() => { if (_histCompareInFlight === request) _histCompareInFlight = null; });
14945
+ return request;
14946
+ }
14947
+
14948
+ async function _fetchHistogramCompareOnce() {
14848
14949
  _histFetching = true;
14849
14950
  const allSids = getCompareRenderSids();
14850
14951
  const fetchDimX = dim_x;
@@ -14857,12 +14958,10 @@
14857
14958
  bins: 64,
14858
14959
  });
14859
14960
  try {
14860
- const histPromises = allSids.map(s =>
14861
- proxyFetch(`/histogram/${s}?${params}`).then(r => r.ok ? r.json() : null)
14862
- );
14961
+ const histPromises = allSids.map(s => _fetchHistogramJson(`/histogram/${s}?${params}`));
14863
14962
  const allHists = await Promise.all(histPromises);
14864
14963
  const valid = allHists.filter(Boolean);
14865
- if (!valid.length) { _histFetching = false; return; }
14964
+ if (!valid.length) { _histFetching = false; return null; }
14866
14965
  // Start from first valid result, then sum counts from the rest
14867
14966
  const merged = JSON.parse(JSON.stringify(valid[0]));
14868
14967
  for (let i = 1; i < valid.length; i++) {
@@ -14895,7 +14994,8 @@
14895
14994
  ].join('|');
14896
14995
  }
14897
14996
 
14898
- async function _fetchDiffHistogram() {
14997
+ /** sids + query shared by the diff histogram and diff value requests. */
14998
+ function _diffAnalysisQuery() {
14899
14999
  if (!diffMode || !compareActive || compareDisplaySids.length < 2) return null;
14900
15000
  const [sid_a, sid_b] = compareDisplaySids;
14901
15001
  const params = new URLSearchParams({
@@ -14906,7 +15006,6 @@
14906
15006
  complex_mode: String(complexMode),
14907
15007
  log_scale: String(logScale),
14908
15008
  diff_mode: String(diffMode),
14909
- bins: '64',
14910
15009
  });
14911
15010
  if (detachedDimMode && detachedDim >= 0) {
14912
15011
  const indicesA = [...indices];
@@ -14916,8 +15015,14 @@
14916
15015
  params.set('indices_a', indicesA.join(','));
14917
15016
  params.set('indices_b', indicesB.join(','));
14918
15017
  }
15018
+ return { path: `${sid_a}/${sid_b}?${params}` };
15019
+ }
15020
+
15021
+ async function _fetchDiffHistogram() {
15022
+ const q = _diffAnalysisQuery();
15023
+ if (!q) return null;
14919
15024
  try {
14920
- const resp = await proxyFetch(`/diff-histogram/${sid_a}/${sid_b}?${params}`);
15025
+ const resp = await proxyFetch(`/diff-histogram/${q.path}&bins=64`);
14921
15026
  if (!resp.ok) return null;
14922
15027
  return await resp.json();
14923
15028
  } catch (_) { return null; }
@@ -15142,7 +15247,7 @@
15142
15247
  }
15143
15248
 
15144
15249
  /** Draw Lebesgue highlight on a single overlay canvas using the given slice data. */
15145
- function _drawLebesgueOnCanvas(overlayCv, targetCv, sliceData, sliceW, sliceH, binLow, binHigh) {
15250
+ function _drawLebesgueOnCanvas(overlayCv, targetCv, sliceData, sliceW, sliceH, binLow, binHigh, flipLikeDiff = false) {
15146
15251
  const octx = overlayCv.getContext('2d');
15147
15252
  if (!octx) return;
15148
15253
  const cw = targetCv.width, ch = targetCv.height;
@@ -15152,7 +15257,9 @@
15152
15257
  }
15153
15258
  overlayCv.style.width = targetCv.style.width;
15154
15259
  overlayCv.style.height = targetCv.style.height;
15155
- const fx = flip_x, fy = flip_y;
15260
+ // The diff pane flips through applyFlips (per mosaic tile), so its
15261
+ // mask is built unflipped and flipped the same way afterwards.
15262
+ const fx = flipLikeDiff ? false : flip_x, fy = flipLikeDiff ? false : flip_y;
15156
15263
  if (!overlayCv._lebesgueMask) overlayCv._lebesgueMask = document.createElement('canvas');
15157
15264
  const maskCv = overlayCv._lebesgueMask;
15158
15265
  if (maskCv.width !== sliceW || maskCv.height !== sliceH) {
@@ -15161,7 +15268,7 @@
15161
15268
  }
15162
15269
  const mctx = maskCv.getContext('2d');
15163
15270
  if (!mctx) return;
15164
- const img = mctx.createImageData(sliceW, sliceH);
15271
+ let img = mctx.createImageData(sliceW, sliceH);
15165
15272
  const buf = img.data;
15166
15273
  const alpha = Math.round(255 * 0.55);
15167
15274
  for (let y = 0; y < sliceH; y++) {
@@ -15175,6 +15282,7 @@
15175
15282
  buf[i + 3] = alpha;
15176
15283
  }
15177
15284
  }
15285
+ if (flipLikeDiff && (flip_x || flip_y)) img = applyFlips(img, sliceW, sliceH);
15178
15286
  mctx.putImageData(img, 0, 0);
15179
15287
  octx.clearRect(0, 0, cw, ch);
15180
15288
  octx.imageSmoothingEnabled = false;
@@ -15196,11 +15304,11 @@
15196
15304
  return overlay;
15197
15305
  }
15198
15306
 
15199
- function _drawLebesgueHighlightOnCanvas(targetCv, sliceEntry, binLow, binHigh) {
15307
+ function _drawLebesgueHighlightOnCanvas(targetCv, sliceEntry, binLow, binHigh, flipLikeDiff = false) {
15200
15308
  if (!targetCv || !sliceEntry || !sliceEntry.slice || !sliceEntry.w || !sliceEntry.h) return false;
15201
15309
  const overlay = _getLebesgueOverlay(targetCv);
15202
15310
  if (!overlay) return false;
15203
- _drawLebesgueOnCanvas(overlay, targetCv, sliceEntry.slice, sliceEntry.w, sliceEntry.h, binLow, binHigh);
15311
+ _drawLebesgueOnCanvas(overlay, targetCv, sliceEntry.slice, sliceEntry.w, sliceEntry.h, binLow, binHigh, flipLikeDiff);
15204
15312
  return true;
15205
15313
  }
15206
15314
 
@@ -15228,6 +15336,41 @@
15228
15336
  }
15229
15337
  }
15230
15338
 
15339
+ // Raw diff values for the centre pane, keyed by the diff query they answer.
15340
+ let _diffLebesgueEntry = null;
15341
+ let _diffLebesguePending = null;
15342
+
15343
+ async function _fetchDiffLebesgueValues(path) {
15344
+ try {
15345
+ const resp = await proxyFetch(`/diff-values/${path}`);
15346
+ if (!resp.ok) return null;
15347
+ const w = parseInt(resp.headers.get('X-ArrayView-Width')) || 0;
15348
+ const h = parseInt(resp.headers.get('X-ArrayView-Height')) || 0;
15349
+ return { slice: new Float32Array(await resp.arrayBuffer()), w, h, key: path };
15350
+ } catch (_) { return null; }
15351
+ }
15352
+
15353
+ function _drawDiffLebesgueHighlight(cb, binIdx, binLow, binHigh) {
15354
+ const q = _diffAnalysisQuery();
15355
+ const target = document.getElementById('compare-diff-canvas');
15356
+ if (!q || !target) return;
15357
+ if (_diffLebesgueEntry && _diffLebesgueEntry.key === q.path) {
15358
+ _drawLebesgueHighlightOnCanvas(target, _diffLebesgueEntry, binLow, binHigh, true);
15359
+ return;
15360
+ }
15361
+ if (_diffLebesguePending === q.path) return;
15362
+ _diffLebesguePending = q.path;
15363
+ _fetchDiffLebesgueValues(q.path).then(entry => {
15364
+ if (_diffLebesguePending === q.path) _diffLebesguePending = null;
15365
+ if (!entry) return;
15366
+ _diffLebesgueEntry = entry;
15367
+ // Still hovering the diff colorbar → draw the bin under the cursor now.
15368
+ if (_colorbarLebesgueHoverActive() && _lastLebesgueColorBar === cb && _lastLebesgueBin >= 0) {
15369
+ _drawLebesgueHighlightForColorbar(cb, _lastLebesgueBin, true);
15370
+ }
15371
+ });
15372
+ }
15373
+
15231
15374
  function _drawLebesgueHighlightForColorbar(cb, binIdx, force = false) {
15232
15375
  if (!_colorbarLebesgueHoverActive()) return;
15233
15376
  const hd = cb?._histData;
@@ -15246,6 +15389,7 @@
15246
15389
  const binLow = edges[binIdx];
15247
15390
  const binHigh = edges[binIdx + 1] + (binIdx === counts.length - 1 ? 1e-9 : 0);
15248
15391
  if (cb?._lebesgueView) _drawLebesgueHighlightForQmriPane(cb._lebesgueView, binLow, binHigh);
15392
+ else if (cb && cb === _diffCenterCb) _drawDiffLebesgueHighlight(cb, binIdx, binLow, binHigh);
15249
15393
  else _drawLebesgueHighlight(binLow, binHigh);
15250
15394
  if (cb === primaryCb) _highlightHistBin(binIdx);
15251
15395
  }
@@ -16265,7 +16409,7 @@
16265
16409
  if (_metadataProcessed) updateView(); // reconnect: metadata already known
16266
16410
  };
16267
16411
 
16268
- ws.onmessage = (event) => {
16412
+ ws.onmessage = _inflatingMessageHandler((event) => {
16269
16413
  // Text message: server pushes metadata as first JSON message
16270
16414
  if (typeof event.data === 'string') {
16271
16415
  try {
@@ -16396,7 +16540,7 @@
16396
16540
  } else if (isPlaying) {
16397
16541
  playNext();
16398
16542
  }
16399
- };
16543
+ });
16400
16544
 
16401
16545
  ws.onclose = () => {
16402
16546
  _clearWsOpenTimer();
@@ -17106,7 +17250,7 @@
17106
17250
  params.set('indices_a', indicesA.join(','));
17107
17251
  params.set('indices_b', indicesB.join(','));
17108
17252
  }
17109
- if (_diffCenterColormap) params.set('diff_colormap', _diffCenterColormap);
17253
+ params.set('diff_colormap', _diffCenterColormap || _diffDefaultColormap());
17110
17254
  const requestVmin = _diffManualVmin ?? _diffAutoVmin;
17111
17255
  const requestVmax = _diffManualVmax ?? _diffAutoVmax;
17112
17256
  if (requestVmin !== null) params.set('vmin_override', String(requestVmin));
@@ -23908,6 +24052,7 @@
23908
24052
  return {
23909
24053
  sid: s.sid,
23910
24054
  label,
24055
+ display: s.filepath || label,
23911
24056
  detail: `${(s.shape || []).join(' x ')}${s.var && s.name ? ` · ${s.name}` : ''}`,
23912
24057
  enabled: sameShape,
23913
24058
  };
@@ -23920,7 +24065,7 @@
23920
24065
  list.innerHTML = _compareWithChoices.map((c, i) => {
23921
24066
  const keyHint = i < 9 ? `<span class="compare-with-key">${i + 1}</span>` : '';
23922
24067
  const reason = c.enabled ? c.detail : `${c.detail} · needs shape ${(shape || []).join(' x ')}`;
23923
- 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>`;
24068
+ return `<button class="drop-action" data-compare-with="${i}" ${c.enabled ? '' : 'disabled'}>${keyHint}<span class="compare-with-path">${_escapeHtml(c.display)}</span><span class="drop-action-reason">${_escapeHtml(reason)}</span></button>`;
23924
24069
  }).join('');
23925
24070
  list.querySelectorAll('.drop-action:not(:disabled)').forEach(button => {
23926
24071
  button.addEventListener('click', () => _pickCompareWith(_compareWithChoices[Number(button.dataset.compareWith)]));
@@ -24522,6 +24667,7 @@
24522
24667
  if (idx >= 0) {
24523
24668
  applyTheme(idx);
24524
24669
  _reconcileLayout();
24670
+ if (compareActive && diffMode === 1 && !_diffCenterColormap) fetchAndDrawDiff();
24525
24671
  }
24526
24672
  } else if (key === 'rounded_panes') {
24527
24673
  roundedPanes = !!value;
@@ -25017,11 +25163,8 @@
25017
25163
  const nextVmin = center - w / 2;
25018
25164
  const nextVmax = center + w / 2;
25019
25165
  if (_bcTargetView) {
25020
- _bcTargetView.lockedVmin = nextVmin;
25021
- _bcTargetView.lockedVmax = nextVmax;
25022
- const _qv = modeManager.currentViews.find(v => v && v.qmriIdx === _bcTargetView.qmriIdx);
25023
- if (_qv) { _qv.displayState.vmin = nextVmin; _qv.displayState.vmax = nextVmax; }
25024
- qvRender(_bcTargetView);
25166
+ _qmriStoreWindow(_bcTargetView, nextVmin, nextVmax);
25167
+ _qmriRenderWindowOwners(_bcTargetView);
25025
25168
  } else {
25026
25169
  manualVmin = nextVmin;
25027
25170
  manualVmax = nextVmax;
@@ -25361,6 +25504,7 @@
25361
25504
  }
25362
25505
  function _qmriArmWindowLevel(view, e) {
25363
25506
  if (_pixelInfoHeld) { _pixelInfoHeld = false; hideAllPixelInfo(); }
25507
+ if (compareQmriActive) view = _cqColumnLead(view);
25364
25508
  const pending = _bcPendingDrag = {
25365
25509
  startX: e.clientX, startY: e.clientY,
25366
25510
  targetView: view, targetCb: view._colorBar,
@@ -25407,7 +25551,7 @@
25407
25551
  ? under.closest(_INFO_CURSOR_CANVAS_SELECTOR)
25408
25552
  : null;
25409
25553
  _reconcileInfoCursor();
25410
- if (targetView) qvRender(targetView);
25554
+ if (targetView) _qmriRenderWindowOwners(targetView);
25411
25555
  else updateView();
25412
25556
  saveState();
25413
25557
  // The drag has definitively ended (this is mouseup, not a hover
@@ -27985,7 +28129,7 @@
27985
28129
  // Only diff modes 1-3 have their own center colorbar; overlay/wipe/flicker/checker do not
27986
28130
  if (diffMode === 0) return;
27987
28131
  const pool = (diffMode === 1) ? DIFF_COLORMAPS : ABS_DIFF_COLORMAPS;
27988
- const idx = pool.indexOf(_diffCenterColormap);
28132
+ const idx = pool.indexOf(_diffCenterColormap || _diffDefaultColormap());
27989
28133
  const next = (idx + 1) % pool.length;
27990
28134
  _diffCenterColormap = pool[next];
27991
28135
  _diffCenterColormapStops = COLORMAP_GRADIENT_STOPS[_diffCenterColormap] || null;
@@ -28466,6 +28610,7 @@
28466
28610
  run: (ctx, e) => {
28467
28611
  applyTheme((themeIdx + 1) % THEMES.length);
28468
28612
  drawSlimColorbar(cbMarkerFrac);
28613
+ if (compareActive && diffMode === 1 && !_diffCenterColormap) fetchAndDrawDiff();
28469
28614
  showStatus(`theme: ${THEMES[themeIdx]}`);
28470
28615
  saveState();
28471
28616
  },
@@ -29471,7 +29616,7 @@
29471
29616
  if ((_cmapStrip && _cmapStrip.classList.contains('visible')) && !qmriActive && !compareQmriActive && !rgbMode) {
29472
29617
  if (_cmapStripMode === 'diff-center') {
29473
29618
  const pool = (diffMode === 1) ? DIFF_COLORMAPS : ABS_DIFF_COLORMAPS;
29474
- const idx = pool.indexOf(_diffCenterColormap);
29619
+ const idx = pool.indexOf(_diffCenterColormap || _diffDefaultColormap());
29475
29620
  _diffCenterColormap = pool[(idx <= 0 ? pool.length - 1 : idx - 1)];
29476
29621
  _diffCenterColormapStops = COLORMAP_GRADIENT_STOPS[_diffCenterColormap] || null;
29477
29622
  fetchAndDrawDiff();
@@ -29541,7 +29686,7 @@
29541
29686
  if ((_cmapStrip && _cmapStrip.classList.contains('visible')) && !qmriActive && !compareQmriActive && !rgbMode) {
29542
29687
  if (_cmapStripMode === 'diff-center') {
29543
29688
  const pool = (diffMode === 1) ? DIFF_COLORMAPS : ABS_DIFF_COLORMAPS;
29544
- const idx = pool.indexOf(_diffCenterColormap);
29689
+ const idx = pool.indexOf(_diffCenterColormap || _diffDefaultColormap());
29545
29690
  _diffCenterColormap = pool[(idx + 1) % pool.length];
29546
29691
  _diffCenterColormapStops = COLORMAP_GRADIENT_STOPS[_diffCenterColormap] || null;
29547
29692
  fetchAndDrawDiff();
@@ -37225,6 +37370,96 @@
37225
37370
 
37226
37371
  // --------------- Compare + qMRI combined mode ---------------
37227
37372
  // Layout: rows = compared arrays, columns = parameter maps
37373
+ // Each map column shares one display range across the compared arrays:
37374
+ // only the bottom row shows colorbars, so that one bar must be true
37375
+ // for every pane above it.
37376
+
37377
+ function _cqColumnViews(view) {
37378
+ return compareQmriViews.filter(v => v.qmriIdx === view.qmriIdx);
37379
+ }
37380
+
37381
+ /** The bottom-row pane of a column — the one whose colorbar is shown. */
37382
+ function _cqColumnLead(view) {
37383
+ const col = _cqColumnViews(view);
37384
+ return col.length ? col[col.length - 1] : view;
37385
+ }
37386
+
37387
+ /** Store a display window on a qMRI pane; in compare-qMRI on its whole column. */
37388
+ function _qmriStoreWindow(view, lo, hi) {
37389
+ const ownerViews = compareQmriActive ? compareQmriViews : qmriViews;
37390
+ const targets = compareQmriActive && ownerViews.includes(view) ? _cqColumnViews(view) : [view];
37391
+ targets.forEach(t => {
37392
+ t.lockedVmin = lo;
37393
+ t.lockedVmax = hi;
37394
+ const idx = ownerViews.indexOf(t);
37395
+ const qv = idx >= 0 ? modeManager.currentViews[idx] : null;
37396
+ if (qv && qv.displayState) { qv.displayState.vmin = lo; qv.displayState.vmax = hi; }
37397
+ });
37398
+ }
37399
+
37400
+ function _qmriRenderWindowOwners(view) {
37401
+ if (compareQmriActive && compareQmriViews.includes(view)) {
37402
+ _cqColumnViews(view).forEach(v => {
37403
+ compareQmriRender(v);
37404
+ if (v._colorBar) { v._colorBar.draw(); v._colorBar.updateLabels(); }
37405
+ });
37406
+ } else {
37407
+ qvRender(view);
37408
+ }
37409
+ }
37410
+
37411
+ /** First frame of a pane: once every pane in its column has reported
37412
+ * its own range, lock the column to the union so all arrays share it. */
37413
+ function _cqNoteFrameRange(v, lo, hi) {
37414
+ if (v.lockedVmin != null) return;
37415
+ v._cqNaturalRange = { vmin: lo, vmax: hi };
37416
+ const col = _cqColumnViews(v);
37417
+ if (!col.every(c => c._cqNaturalRange)) return;
37418
+ const win = _normalizeQmriWindowPolicy(v, {
37419
+ vmin: Math.min(...col.map(c => c._cqNaturalRange.vmin)),
37420
+ vmax: Math.max(...col.map(c => c._cqNaturalRange.vmax)),
37421
+ });
37422
+ if (!win) return;
37423
+ _qmriStoreWindow(v, win.vmin, win.vmax);
37424
+ col.forEach(c => compareQmriRender(c));
37425
+ }
37426
+
37427
+ function _cqHistogramOpts(view, sidVal) {
37428
+ return {
37429
+ sid: sidVal,
37430
+ dimX: dim_x, dimY: dim_y,
37431
+ scrollDim: current_slice_dim,
37432
+ complexMode: complexMode,
37433
+ fixedIndices: compareQmriDim + ':' + view.qmriIdx,
37434
+ qmriRole: view.qmriRole,
37435
+ bins: 128,
37436
+ };
37437
+ }
37438
+
37439
+ /** One histogram of a map across every compared array, re-binned
37440
+ * onto a common range so presets and the shown bar cover them all. */
37441
+ async function _cqFetchColumnHistogram(view) {
37442
+ const sids = _cqColumnViews(view).map(v => v.sid);
37443
+ const hists = (await Promise.all(sids.map(s => _fetchVolumeHistogram(_cqHistogramOpts(view, s)))))
37444
+ .filter(h => h && h.counts && h.counts.length && h.edges && h.edges.length === h.counts.length + 1);
37445
+ if (!hists.length) return null;
37446
+ if (hists.length === 1) return hists[0];
37447
+ const lo = Math.min(...hists.map(h => Number(h.vmin)));
37448
+ const hi = Math.max(...hists.map(h => Number(h.vmax)));
37449
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return hists[0];
37450
+ const bins = 128;
37451
+ const step = (hi - lo) / bins;
37452
+ const counts = new Array(bins).fill(0);
37453
+ for (const h of hists) {
37454
+ for (let i = 0; i < h.counts.length; i++) {
37455
+ const c = (Number(h.edges[i]) + Number(h.edges[i + 1])) / 2;
37456
+ const b = Math.max(0, Math.min(bins - 1, Math.floor((c - lo) / step)));
37457
+ counts[b] += h.counts[i];
37458
+ }
37459
+ }
37460
+ const edges = Array.from({ length: bins + 1 }, (_, i) => lo + i * step);
37461
+ return { counts, edges, vmin: lo, vmax: hi };
37462
+ }
37228
37463
  function enterCompareQmri() {
37229
37464
  if (!compareActive) return;
37230
37465
  _qmriRenderGeneration++;
@@ -37327,35 +37562,19 @@
37327
37562
  getStops: () => COLORMAP_GRADIENT_STOPS[view.colormap] || COLORMAP_GRADIENT_STOPS['gray'] || [],
37328
37563
  getRange: () => ({ vmin: view.vmin, vmax: view.vmax }),
37329
37564
  getWindow: () => ({ vmin: view.lockedVmin ?? view.vmin, vmax: view.lockedVmax ?? view.vmax }),
37330
- setWindow: (lo, hi) => { view.lockedVmin = lo; view.lockedVmax = hi; },
37565
+ setWindow: (lo, hi) => { _qmriStoreWindow(view, lo, hi); },
37331
37566
  fetchHistogram: async () => {
37332
- const fixedPairs = [];
37333
- fixedPairs.push(compareQmriDim + ':' + view.qmriIdx);
37334
- const fixedIndices = fixedPairs.join(',');
37335
- const hist = await _fetchVolumeHistogram({
37336
- dimX: dim_x, dimY: dim_y,
37337
- scrollDim: current_slice_dim,
37338
- complexMode: complexMode,
37339
- fixedIndices,
37340
- qmriRole: view.qmriRole,
37341
- bins: 128,
37342
- });
37567
+ const hist = await _cqFetchColumnHistogram(view);
37343
37568
  return _normalizeQmriHistogramForColorbar(view, hist);
37344
37569
  },
37345
- getHistogramVersion: () => _volumeHistCacheKey({
37346
- dimX: dim_x, dimY: dim_y,
37347
- scrollDim: current_slice_dim,
37348
- complexMode: complexMode,
37349
- fixedIndices: compareQmriDim + ':' + view.qmriIdx,
37350
- qmriRole: view.qmriRole,
37351
- bins: 128,
37352
- }),
37570
+ getHistogramVersion: () => _cqColumnViews(view)
37571
+ .map(v => _volumeHistCacheKey(_cqHistogramOpts(view, v.sid))).join(';'),
37353
37572
  onMouseEnter: () => { _startColorbarLebesgueHover(); },
37354
37573
  onMouseLeave: () => { _stopColorbarLebesgueHover(); },
37355
37574
  onDragStart: () => { _stopColorbarLebesgueHover(); },
37356
37575
  onDragEnd: () => { if (cqCb && cqCb._mouseOver) _startColorbarLebesgueHover(); },
37357
37576
  onBinHover: (binIdx, frac) => { _drawLebesgueHighlightForColorbar(_cqCb, binIdx); },
37358
- onWindowChange: () => { compareQmriRender(view); },
37577
+ onWindowChange: () => { _cqColumnViews(view).forEach(v => compareQmriRender(v)); },
37359
37578
  onAnimFrame: () => { compareQmriScaleAllCanvases(); },
37360
37579
  });
37361
37580
  _cqCb._lebesgueView = view;
@@ -37521,6 +37740,8 @@
37521
37740
  canvas_h: _qch,
37522
37741
  projection_mode: projectionMode,
37523
37742
  projection_dim: projectionMode > 0 ? projectionDim : -1,
37743
+ vmin_override: v.lockedVmin != null && v.lockedVmax != null ? v.lockedVmin : undefined,
37744
+ vmax_override: v.lockedVmin != null && v.lockedVmax != null ? v.lockedVmax : undefined,
37524
37745
  perf: _PERF_ENABLED,
37525
37746
  }));
37526
37747
  }
@@ -37560,10 +37781,7 @@
37560
37781
  const frameWindow = _normalizeQmriWindowPolicy(v, { vmin: frame.vmin, vmax: frame.vmax }) || frame;
37561
37782
  v.vmin = frameWindow.vmin;
37562
37783
  v.vmax = frameWindow.vmax;
37563
- if (v.lockedVmin === undefined || v.lockedVmin === null) {
37564
- v.lockedVmin = v.vmin;
37565
- v.lockedVmax = v.vmax;
37566
- }
37784
+ _cqNoteFrameRange(v, v.vmin, v.vmax);
37567
37785
  v.lastImageData = frame.imageData;
37568
37786
  v.lastW = frame.width;
37569
37787
  v.lastH = frame.height;
@@ -37675,7 +37893,9 @@
37675
37893
  const width = h32[1], height = h32[2];
37676
37894
  if (h32[0] === v.seq && compareQmriActive && v._qmriGeneration === _qmriRenderGeneration && compareQmriViews.includes(v)) {
37677
37895
  const f32 = new Float32Array(buf, 12, 2);
37678
- v.vmin = f32[0]; v.vmax = f32[1];
37896
+ const frameWindow = _normalizeQmriWindowPolicy(v, { vmin: f32[0], vmax: f32[1] }) || { vmin: f32[0], vmax: f32[1] };
37897
+ v.vmin = frameWindow.vmin; v.vmax = frameWindow.vmax;
37898
+ _cqNoteFrameRange(v, v.vmin, v.vmax);
37679
37899
  const rgba = new Uint8ClampedArray(buf.slice(20, 20 + width * height * 4));
37680
37900
  const drawT0 = _perfNow();
37681
37901
  const frame = _applyAutoCropIfReady(
@@ -42807,7 +43027,7 @@
42807
43027
  preview: _ensureCmapPreviewEl(host),
42808
43028
  pool,
42809
43029
  currentIdx: () => {
42810
- const idx = pool.indexOf(_diffCenterColormap);
43030
+ const idx = pool.indexOf(_diffCenterColormap || _diffDefaultColormap());
42811
43031
  return idx >= 0 ? idx : 0;
42812
43032
  },
42813
43033
  captureState: () => ({
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.3
3
+ Version: 0.48.4
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
@@ -7,7 +7,7 @@ arrayview/_bootstrap_app.py,sha256=ljiZ0o5D3nkJqEGcyyVF06dFWxXujBufQjbcn50QzXM,4
7
7
  arrayview/_config.py,sha256=5ovQTO2I-j3fxCxEKcZKE2pqiahLESGTGS24nQdP61I,13493
8
8
  arrayview/_diagnostics.py,sha256=XB0rCoDn3cpfhz4H-ayLktOgCMIoVJ0wM9N0ep_kKkQ,11018
9
9
  arrayview/_dicom.py,sha256=tNY353MNWTvcXavHeIkqj3242dbgfH_ctwcx9NDee-E,19607
10
- arrayview/_diff.py,sha256=10Uqvccr7Ocouphl14dMJzLMUHPhsD81LkM1IbsmLqg,5826
10
+ arrayview/_diff.py,sha256=mWLyRSJ6cy-hngyhINxNM88P1QsXXhpMFMFO0PBxidQ,7374
11
11
  arrayview/_extra_ports.py,sha256=MZcx04NI9Eg4coydb-yPIHp7uJbE6UscyYGnw8fxid0,11125
12
12
  arrayview/_icon.png,sha256=a8RTAbvweLhh3hzD5eGg76Peq-KSDcS6YrCphRb_HEs,2748
13
13
  arrayview/_imaging.py,sha256=MQX1uy4m1lofOeoLDhCp6cHO5p_V5fXm4qlSu9Ykn-c,534
@@ -19,7 +19,7 @@ arrayview/_launcher.py,sha256=Hljfeq4AmxVTDiDgYyMtHT5eiatgFdrvcr6bq75onZY,243267
19
19
  arrayview/_lifecycle.py,sha256=bqG0oKr-e-5S1ssCTlGmF-yBE47ilrpO1S_Owfjs9Z8,5365
20
20
  arrayview/_overlays.py,sha256=CksypG9rWfCz_3RRGIeyaKeXse17AFxvkEjmYyXnIIE,5399
21
21
  arrayview/_platform.py,sha256=qSWjRcx9rLIdLvKrRArSueFnbyn5GPKfTEMOc5mCIUs,29970
22
- arrayview/_render.py,sha256=W2FyrBYNt2dwim_CjYMM5U_Dq_vL5DEU4PL6_ybxIZ8,38564
22
+ arrayview/_render.py,sha256=PwbDVzaVNCqJQw2AGuXS5TG5is5lq9N4yBJt3Ge40MI,40220
23
23
  arrayview/_routes_analysis.py,sha256=Xz9Ysr04u07pzSRB4Fzmcl6XwVxyaG6xCWxGwYZi_80,33085
24
24
  arrayview/_routes_drop.py,sha256=FC4e7STdCo3hYIA0C4Yy9Ka2UKFx7nLDU5dkujmc1Vo,12477
25
25
  arrayview/_routes_export.py,sha256=Ha8mMO2E9TlRL5x25-ADV8_8xUiC-jRz9N1oWwgKOsg,3898
@@ -28,14 +28,14 @@ arrayview/_routes_persistence.py,sha256=T03csHb7ppGT-8yMDf227XkqhnhjgSLYj0RW2wmg
28
28
  arrayview/_routes_preferences.py,sha256=xoMHbPKliYspgjaclhx2r8HbP9mFo9Td-nTF76eWVMg,4680
29
29
  arrayview/_routes_preload.py,sha256=KOsankNguxdIWmFW9iZcY9p2SvUN1mOKY7KkW1qMGcc,1729
30
30
  arrayview/_routes_query.py,sha256=HEEmMKuFycUTBNVUSWtUFXCnWB7ZrM3hv9IZWbXz11o,25012
31
- arrayview/_routes_rendering.py,sha256=6DD8VxPglnPsG0rnE-qk6ogcNRe2S_KAh3K54QYUHFQ,23380
31
+ arrayview/_routes_rendering.py,sha256=5373qwaNYVedPnBHNo0b2PXBGIiVx37SqEcMCksFNJo,24874
32
32
  arrayview/_routes_segmentation.py,sha256=x2bBjQffZryeTMmOH38YdBWethw1o3KpA8V0chJcZHI,23599
33
33
  arrayview/_routes_state.py,sha256=T_xoYcRlv7L47ydTxxiXb0Fp5M5x9KR2U-wkxaVeuMY,12462
34
34
  arrayview/_routes_vectorfield.py,sha256=OWZtAONwRKQHdYnbDAMbOVf0q6pH7kqV8fa1icDhKHM,9734
35
- arrayview/_routes_websocket.py,sha256=zfwojLG-c6NaxZanZgmiPpoQUpCmQFqCOglaydjtzJ4,43675
35
+ arrayview/_routes_websocket.py,sha256=405t-7gSERkNlO2pYarkWNzz3Y7CsZ_8CwCU8emXTWU,45541
36
36
  arrayview/_segmentation.py,sha256=aENWHmefrKDScXhcZL1X3KgYXE784dpMkmFFo83qnrw,7377
37
37
  arrayview/_server.py,sha256=Ejh-6rWpWo0luhBhtsVq1Z4RFIyOPVv5nzExHtXb7iA,23042
38
- arrayview/_session.py,sha256=D4I6FLazLEXbo0IdkyRrzhvMNhvAI6dSyYu5I3tMCaM,42137
38
+ arrayview/_session.py,sha256=BX94Zx3J4YsqR0t5mr4SJrsVGG5pf_UJK4Ij45iubPc,42254
39
39
  arrayview/_session_spec.py,sha256=mKDfMRsPYQdEmN9hLXmDU_9N7etiDyBXr8mz-fB8QBo,6825
40
40
  arrayview/_shell.html,sha256=vNa2Kiwx_eoB5H329jolYafLhwJDCH9DsJafBqvVMWU,13194
41
41
  arrayview/_source_safety.py,sha256=cI7JAL7DWYuxuB_LnYExvJZaICX2R2qJEJWD1M0z_gg,27664
@@ -43,7 +43,7 @@ arrayview/_synthetic_mri.py,sha256=KcSYC26dgjBH25bUoqHT0IgJIzOxgXCSLkWr-ZnFq08,7
43
43
  arrayview/_torch.py,sha256=13sqIMCUu_-b9SQ9KfDCExDz2UMStL87EDVjnbJxAEo,7536
44
44
  arrayview/_tutorial.py,sha256=2zZHJGJDa4n30GE1-9ADjKFAifGBH9vvHXP4uDt7BaA,19547
45
45
  arrayview/_vectorfield.py,sha256=eDqMCMEGjO8Jdt-6Auh01cz3WD9dbF7xYUWD9xHAf1E,8115
46
- arrayview/_viewer.html,sha256=jKI9Ic8YwKYoIKhii_k019xb0ujcf-tgi8kkzfLyyl0,2402661
46
+ arrayview/_viewer.html,sha256=LhC5-SnDwxyA20T8AwKE2MWtbvKSOrPi1-kO4e_ywdI,2414456
47
47
  arrayview/_vscode.py,sha256=EKnGN2zrjlybc4FQOmYA8BvYdtkcJbLHDEdgtvO7ul4,837
48
48
  arrayview/_vscode_browser.py,sha256=VCH9JpAAjFuhO0wQe8tIGjAL5837AXg_bpmBFtWOQqc,20573
49
49
  arrayview/_vscode_extension.py,sha256=eYsWAQmik2tt7HMWad9vBnn2kpRyRpLrh42FBGQy0M4,53156
@@ -52,8 +52,8 @@ arrayview/_watch.py,sha256=PxxS_5hcj_S6xQf9Ep4p1HLRkrI5iDMh1aRa4olV7ew,1873
52
52
  arrayview/arrayview-opener.vsix,sha256=44uiTM39qj_RXji3pFQaR8TA1jhNB3LYwVU_dGFbP4c,62284
53
53
  arrayview/gsap.min.js,sha256=VGihm4idI0E1uqrWezWsiScB9F_jMosn7qs6pPSkeac,72304
54
54
  arrayview/todo.md,sha256=KhnRzpn6_jezR0nDDB61-zhox-rbOmEGW-A7Z0_7FnU,622
55
- arrayview-0.48.3.dist-info/METADATA,sha256=fYsnZ0m9pQEfwNxFPezS5qk0lTNE6FQQ-jQkaJ1mV5I,2288
56
- arrayview-0.48.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
- arrayview-0.48.3.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
- arrayview-0.48.3.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
- arrayview-0.48.3.dist-info/RECORD,,
55
+ arrayview-0.48.4.dist-info/METADATA,sha256=PLSqbJ2d6fFpvAkOLc3Vw8xxcq7F4yuXohp11HXsK9I,2288
56
+ arrayview-0.48.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ arrayview-0.48.4.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
+ arrayview-0.48.4.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
+ arrayview-0.48.4.dist-info/RECORD,,