arrayview 0.46.2__py3-none-any.whl → 0.47.0__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.
@@ -96,10 +96,12 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
96
96
  result = []
97
97
  for session in SESSIONS.values():
98
98
  dtype_str = str(getattr(session.data, "dtype", "unknown"))
99
+ code = getattr(session, "code_source", None) or {}
99
100
  result.append(
100
101
  {
101
102
  "sid": session.sid,
102
103
  "name": session.name,
104
+ "var": code.get("name") or None,
103
105
  "shape": [int(x) for x in session.shape],
104
106
  "filepath": session.filepath,
105
107
  "dtype": dtype_str,
arrayview/_viewer.html CHANGED
@@ -236,22 +236,24 @@
236
236
  border-radius: 10px;
237
237
  overflow: hidden;
238
238
  }
239
- /* Orientation arrow — hovering a dim label in the DimBar shows a
240
- small arrow near that dimension's zero point, pointing toward
241
- increasing values. See _showOrientEdgeHighlight(). */
242
- .orient-arrow {
243
- position: absolute; width: 32px; height: 32px;
244
- pointer-events: none; z-index: 5;
239
+ /* Orientation cross — hovering the DimBar (or pressing r / t) shows a
240
+ small axis cross in the origin corner of every image pane, one arrow
241
+ per in-plane direction with its letter. See _showOrientEdgeHighlight().
242
+ Yellow with a black outline so it reads on bright and dark image
243
+ content alike. */
244
+ .orient-cross {
245
+ position: fixed; left: 0; top: 0; width: 64px; height: 64px;
246
+ transform-origin: 0 0; pointer-events: none; z-index: 60;
245
247
  opacity: 0; transition: opacity 0.15s ease;
246
248
  }
247
- .orient-arrow.showing { opacity: 0.9; }
248
- /* Dark halo so the arrow reads against bright/washed-out image content
249
- regardless of the active colormap (same technique used elsewhere
250
- for on-canvas markers). A single polygon (not a separate shaft +
251
- head) so the outline wraps one shape with no seam down the middle. */
252
- .orient-arrow polygon {
253
- paint-order: stroke;
254
- stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round;
249
+ .orient-cross.showing { opacity: 1; }
250
+ .orient-cross .oc-halo { stroke: rgba(0, 0, 0, 0.7); stroke-width: 4px; fill: none; stroke-linecap: round; }
251
+ .orient-cross .oc-ln { stroke: var(--active-dim); stroke-width: 1.5px; fill: none; stroke-linecap: round; }
252
+ .orient-cross .oc-hd { fill: var(--active-dim); stroke: rgba(0, 0, 0, 0.7); stroke-width: 2.5px; stroke-linejoin: round; paint-order: stroke; }
253
+ .orient-cross text {
254
+ font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace;
255
+ font-size: 12px; font-weight: 600; fill: var(--active-dim);
256
+ stroke: rgba(0, 0, 0, 0.7); stroke-width: 3px; stroke-linejoin: round; paint-order: stroke;
255
257
  }
256
258
  #slim-cb-wrap {
257
259
  text-align: center;
@@ -2087,6 +2089,18 @@
2087
2089
  .drop-action:hover:not(:disabled) { color: var(--active-dim); background: var(--surface-2); }
2088
2090
  .drop-action:disabled { color: var(--muted); cursor: default; opacity: 0.5; }
2089
2091
  .drop-action-reason { display: block; margin-top: 2px; font-size: 9px; color: var(--muted); }
2092
+ #compare-with-menu {
2093
+ display: none; position: fixed; z-index: 70; left: 50%; top: 50%;
2094
+ transform: translate(-50%, -50%); width: min(340px, calc(100vw - 24px));
2095
+ max-height: calc(100vh - 48px); overflow-y: auto;
2096
+ padding: 8px; background: var(--surface); border: 1px solid var(--border);
2097
+ border-radius: var(--radius-lg); color: var(--text); font: 11px var(--mono);
2098
+ backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
2099
+ }
2100
+ #compare-with-menu.visible { display: block; }
2101
+ #compare-with-title { padding: 4px 6px 2px; }
2102
+ #compare-with-summary { color: var(--muted); padding: 0 6px 7px; font-size: 10px; }
2103
+ .compare-with-key { color: var(--muted); margin-right: 8px; }
2090
2104
  .mode-badge {
2091
2105
  display: inline-block; font-size: 0.62em; font-weight: 600;
2092
2106
  letter-spacing: 0.08em; padding: 2px 6px; border-radius: 4px;
@@ -4247,6 +4261,13 @@
4247
4261
  /* qMRI 3-plane multiview: one map per row (3 planes) + a vertical colorbar on the right */
4248
4262
  .qmri-mv-row { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 14px; }
4249
4263
  .qmri-mv-row .qv-canvas-clip { align-items: center; justify-content: center; }
4264
+ /* qMRI ortho: the 3x3 panes touch and read as one block, so only the
4265
+ outer corners of the block are rounded. */
4266
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip { border-radius: 0; }
4267
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-tl { border-top-left-radius: 10px; }
4268
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-tr { border-top-right-radius: 10px; }
4269
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-bl { border-bottom-left-radius: 10px; }
4270
+ body.rounded-panes .qmri-mv-row .qv-canvas-clip.qmv-br { border-bottom-right-radius: 10px; }
4250
4271
  .qmri-mv-row > .qv-row { flex: 0 1 auto; }
4251
4272
  .qmri-mv-cb { display: flex; align-items: center; flex-shrink: 0; }
4252
4273
  .qmri-mv-vcb-island { padding: 4px 8px; margin-top: 0; flex-direction: column !important; gap: 3px; }
@@ -5741,6 +5762,11 @@
5741
5762
  <div id="drop-action-summary"></div>
5742
5763
  <div id="drop-action-list"></div>
5743
5764
  </div>
5765
+ <div id="compare-with-menu" aria-hidden="true">
5766
+ <div id="compare-with-title">Compare with…</div>
5767
+ <div id="compare-with-summary"></div>
5768
+ <div id="compare-with-list"></div>
5769
+ </div>
5744
5770
  <div id="help-overlay">
5745
5771
  <div id="help-box">
5746
5772
  <div id="help-tabs"></div>
@@ -7507,6 +7533,7 @@
7507
7533
  let _cmvCrosshairAnim = null; // animation frame id for compare-mv crosshair fade
7508
7534
  let _qmvCrosshairAlpha = 0; // current crosshair opacity for qMRI multiview
7509
7535
  let _qmvCrosshairTarget = 0; // target opacity for qMRI multiview
7536
+ let _qmvCrosshairMap = null; // map row the lines are limited to (click-hold); null = every row
7510
7537
  let _qmvCrosshairAnim = null; // animation frame id for qMRI-mv crosshair fade
7511
7538
  let _qmvScrollHintTimer = null; // fade-out timer after scrolling a qMRI-mv pane
7512
7539
  let _qmvScrollHintDir = -1; // plane being scrolled (its own lines stay hidden)
@@ -20994,6 +21021,20 @@
20994
21021
  return `<span class="${cls}">${_dimIndexHtml(shownValue, maxValue)}</span>`;
20995
21022
  }
20996
21023
 
21024
+ // Axis letters follow the data, not the screen: the array axis that
21025
+ // started out as x keeps the letter x after t / r, so the DimBar and the
21026
+ // orientation cross both show which way each axis now runs. Remembered
21027
+ // per pair of displayed dims; picking different dims starts over.
21028
+ let _axisIdent = null;
21029
+ function _axisLetter(d) {
21030
+ const key = Math.min(dim_x, dim_y) + ',' + Math.max(dim_x, dim_y);
21031
+ if (!_axisIdent || _axisIdent.key !== key) {
21032
+ _axisIdent = { key, letters: { [dim_x]: 'x', [dim_y]: 'y' } };
21033
+ }
21034
+ if (d === dim_z) return 'z';
21035
+ return _axisIdent.letters[d] || (d === dim_x ? 'x' : 'y');
21036
+ }
21037
+
20997
21038
  function renderInfo() {
20998
21039
  _ensureQmriActiveDimVisible();
20999
21040
  _syncOverlayPaletteSlice();
@@ -21046,13 +21087,13 @@
21046
21087
  }
21047
21088
  if (i === dim_x) {
21048
21089
  const sign = flip_x ? '<span class="dim-flip-sign">-</span>' : '';
21049
- const inner = sign + '<span class="dim-axis-letter">x</span>' + `<span class="dim-slash">/</span><span class="dim-size" id="dim-x-size">${sizeOf(i)}</span>`;
21090
+ const inner = sign + `<span class="dim-axis-letter">${_axisLetter(i)}</span>` + `<span class="dim-slash">/</span><span class="dim-size" id="dim-x-size">${sizeOf(i)}</span>`;
21050
21091
  return (active || playing) ? `<span class="spatial-dim ${dimCls} dim-label" ${d}>${inner}</span>`
21051
21092
  : `<span class="spatial-dim dim-label" ${d}>${inner}</span>`;
21052
21093
  }
21053
21094
  if (i === dim_y) {
21054
21095
  const sign = flip_y ? '<span class="dim-flip-sign">-</span>' : '';
21055
- const inner = sign + '<span class="dim-axis-letter">y</span>' + `<span class="dim-slash">/</span><span class="dim-size" id="dim-y-size">${sizeOf(i)}</span>`;
21096
+ const inner = sign + `<span class="dim-axis-letter">${_axisLetter(i)}</span>` + `<span class="dim-slash">/</span><span class="dim-size" id="dim-y-size">${sizeOf(i)}</span>`;
21056
21097
  return (active || playing) ? `<span class="spatial-dim ${dimCls} dim-label" ${d}>${inner}</span>`
21057
21098
  : `<span class="spatial-dim dim-label" ${d}>${inner}</span>`;
21058
21099
  }
@@ -21272,60 +21313,122 @@
21272
21313
  if (typeof obliqueVecs !== 'undefined' && obliqueVecs) return false;
21273
21314
  return true;
21274
21315
  }
21275
- function _clearOrientEdgeHighlight() {
21276
- document.querySelectorAll('.orient-arrow.showing').forEach(el => {
21277
- el.classList.remove('showing');
21278
- });
21316
+ const _ORIENT_CROSS_BOX = 64;
21317
+ const _ORIENT_CROSS_MARGIN = 8;
21318
+ let _orientCrossEls = [];
21319
+ let _orientShown = false;
21320
+ let _orientHovering = false;
21321
+ let _orientRaf = 0;
21322
+ let _orientFlashTimer = 0;
21323
+ // Every image pane in the current mode: its canvas, the letters of its
21324
+ // horizontal / vertical dims, and which way each axis is flipped.
21325
+ function _orientPanes() {
21326
+ const out = [];
21327
+ const add = (cv, lx, ly, fx, fy) => { if (cv) out.push({ cv, lx, ly, fx: !!fx, fy: !!fy }); };
21328
+ const grid = (views, dims) => {
21329
+ const letter = (d) => ['x', 'y', 'z'][Math.max(0, dims.indexOf(d))];
21330
+ (views || []).forEach(v => add(v.canvas, letter(v.dimX), letter(v.dimY), _mvFlipX(v), _mvFlipY(v)));
21331
+ };
21332
+ const plain = (cvs) => cvs.forEach(cv => add(cv, _axisLetter(dim_x), _axisLetter(dim_y), flip_x, flip_y));
21333
+ if (stackMosaicActive) plain((stackMosaicViews || []).map(v => v.canvas));
21334
+ else if (compareQmriActive) plain((compareQmriViews || []).map(v => v.canvas));
21335
+ else if (compareMvActive) grid(compareMvViews, compareMvDims);
21336
+ else if (multiViewActive) grid(mvViews, mvDims);
21337
+ else if (compareActive) plain([...document.querySelectorAll('#compare-panes .compare-canvas')]);
21338
+ else if (qmriMvActive) grid(qmriMvViews, qmriMvDims);
21339
+ else if (qmriActive) plain((qmriViews || []).map(v => v.canvas));
21340
+ else if (mipActive || cutawayActive) return out;
21341
+ // Mosaic tiles are flipped one by one, so the whole canvas has no single origin corner.
21342
+ else if (!(dim_z >= 0 && (flip_x || flip_y))) plain([canvas]);
21343
+ return out;
21279
21344
  }
21280
- const _ORIENT_ARROW_MARGIN = 10;
21281
- function _orientArrowEl(container) {
21282
- if (!container) return null;
21283
- let el = container.querySelector(':scope > .orient-arrow');
21284
- if (!el) {
21285
- const NS = 'http://www.w3.org/2000/svg';
21286
- el = document.createElementNS(NS, 'svg');
21287
- el.setAttribute('class', 'orient-arrow');
21288
- el.setAttribute('viewBox', '0 0 32 32');
21289
- el.innerHTML = '<polygon points="3,14.5 21,14.5 21,10 31,16 21,22 21,17.5 3,17.5" fill="var(--active-dim)"/>';
21290
- container.appendChild(el);
21291
- }
21292
- return el;
21345
+ // The part of `cv` that is actually on screen: its box clipped by every scrolling/clipping ancestor.
21346
+ function _orientVisibleRect(cv) {
21347
+ const r = cv.getBoundingClientRect();
21348
+ let l = r.left, t = r.top, rt = r.right, bt = r.bottom;
21349
+ for (let el = cv.parentElement; el && el !== document.body; el = el.parentElement) {
21350
+ const cs = getComputedStyle(el);
21351
+ if (cs.overflowX === 'visible' && cs.overflowY === 'visible') continue;
21352
+ const pr = el.getBoundingClientRect();
21353
+ l = Math.max(l, pr.left); t = Math.max(t, pr.top);
21354
+ rt = Math.min(rt, pr.right); bt = Math.min(bt, pr.bottom);
21355
+ }
21356
+ return (rt - l > 8 && bt - t > 8) ? { left: l, top: t, right: rt, bottom: bt } : null;
21357
+ }
21358
+ function _orientCrossSvg(el, letterX, letterY, flipX, flipY) {
21359
+ const key = letterX + letterY + (flipX ? 1 : 0) + (flipY ? 1 : 0);
21360
+ if (el._ocKey === key) return;
21361
+ el._ocKey = key;
21362
+ const B = _ORIENT_CROSS_BOX, o = 8, len = 30, hl = 7, hw = 3.6;
21363
+ const sx = flipX ? -1 : 1, sy = flipY ? -1 : 1;
21364
+ const ox = flipX ? B - o : o, oy = flipY ? B - o : o;
21365
+ const xTip = ox + sx * len, yTip = oy + sy * len;
21366
+ const xHead = `${xTip},${oy} ${xTip - sx * hl},${oy - hw} ${xTip - sx * hl},${oy + hw}`;
21367
+ const yHead = `${ox},${yTip} ${ox - hw},${yTip - sy * hl} ${ox + hw},${yTip - sy * hl}`;
21368
+ const segs = [[ox, oy, xTip - sx * 4, oy], [ox, oy, ox, yTip - sy * 4]];
21369
+ el.innerHTML = segs.map(([x1, y1, x2, y2]) => `<line class="oc-halo" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`
21370
+ + `<line class="oc-ln" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join('')
21371
+ + `<polygon class="oc-hd" points="${xHead}"/><polygon class="oc-hd" points="${yHead}"/>`
21372
+ + `<circle class="oc-hd" cx="${ox}" cy="${oy}" r="2"/>`
21373
+ + `<text x="${xTip + sx * 9}" y="${oy + 4}" text-anchor="${flipX ? 'end' : 'start'}">${letterX}</text>`
21374
+ + `<text x="${ox}" y="${yTip + sy * 13}" text-anchor="middle">${letterY}</text>`;
21375
+ }
21376
+ // Puts one cross in the origin corner of each pane: the x arrow runs
21377
+ // along the top (bottom when flipped) edge toward increasing x, the y
21378
+ // arrow along the left (right when flipped) edge toward increasing y.
21379
+ function _placeOrientCrosses() {
21380
+ const panes = _orientPanes().map(p => ({ p, r: _orientVisibleRect(p.cv) })).filter(q => q.r);
21381
+ while (_orientCrossEls.length < panes.length) {
21382
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
21383
+ el.setAttribute('class', 'orient-cross');
21384
+ el.setAttribute('viewBox', `0 0 ${_ORIENT_CROSS_BOX} ${_ORIENT_CROSS_BOX}`);
21385
+ document.body.appendChild(el);
21386
+ _orientCrossEls.push(el);
21387
+ }
21388
+ _orientCrossEls.forEach((el, i) => {
21389
+ const q = panes[i];
21390
+ if (!q) { el.classList.remove('showing'); return; }
21391
+ const { p, r } = q;
21392
+ _orientCrossSvg(el, p.lx, p.ly, p.fx, p.fy);
21393
+ const w = r.right - r.left, h = r.bottom - r.top;
21394
+ // Small panes (grids, mosaics) get a proportionally smaller cross.
21395
+ const k = Math.max(0.55, Math.min(1, Math.min(w, h) / 230));
21396
+ const size = _ORIENT_CROSS_BOX * k, m = _ORIENT_CROSS_MARGIN * k;
21397
+ const x = p.fx ? r.right - m - size : r.left + m;
21398
+ const y = p.fy ? r.bottom - m - size : r.top + m;
21399
+ el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px) scale(${k})`;
21400
+ el.classList.add('showing');
21401
+ });
21293
21402
  }
21294
- // Places a small arrow near the (flipX, flipY) origin corner of `container`,
21295
- // pointing along `axis` ('x' or 'y') in the direction that dim increases.
21296
- // `flipOwn` is the flip of `axis` itself (picks the arrow's direction and
21297
- // which edge it hugs along that axis); `flipOther` is the flip of the
21298
- // perpendicular axis (picks where along that axis the arrow sits).
21299
- function _placeOrientArrow(container, axis, flipOwn, flipOther) {
21300
- const el = _orientArrowEl(container);
21301
- if (!el) return;
21302
- const m = _ORIENT_ARROW_MARGIN + 'px';
21303
- el.style.left = el.style.right = el.style.top = el.style.bottom = '';
21304
- if (axis === 'x') {
21305
- if (flipOwn) el.style.right = m; else el.style.left = m;
21306
- if (flipOther) el.style.bottom = m; else el.style.top = m;
21307
- el.style.transform = flipOwn ? 'rotate(180deg)' : 'rotate(0deg)';
21308
- } else {
21309
- if (flipOwn) el.style.bottom = m; else el.style.top = m;
21310
- if (flipOther) el.style.right = m; else el.style.left = m;
21311
- el.style.transform = flipOwn ? 'rotate(-90deg)' : 'rotate(90deg)';
21312
- }
21313
- el.classList.add('showing');
21403
+ // Panes can still be moving or re-rendering when this starts (after
21404
+ // r / t, or while zooming), so keep the crosses glued to them.
21405
+ function _orientLoop() {
21406
+ _orientRaf = 0;
21407
+ if (!_orientShown) return;
21408
+ _placeOrientCrosses();
21409
+ _orientRaf = requestAnimationFrame(_orientLoop);
21314
21410
  }
21315
- function _showOrientEdgeHighlight(dim) {
21316
- _clearOrientEdgeHighlight();
21317
- if (!_orientEdgeAllowed() || dim == null || dim < 0) return;
21318
- if (multiViewActive) {
21319
- if (!Array.isArray(mvViews)) return;
21320
- mvViews.forEach(v => {
21321
- if (v.dimX === dim) _placeOrientArrow(v.cvBox, 'x', _mvFlipX(v), _mvFlipY(v));
21322
- else if (v.dimY === dim) _placeOrientArrow(v.cvBox, 'y', _mvFlipY(v), _mvFlipX(v));
21323
- });
21324
- return;
21325
- }
21326
- const viewport = document.getElementById('canvas-viewport');
21327
- if (dim === dim_x) _placeOrientArrow(viewport, 'x', flip_x, flip_y);
21328
- else if (dim === dim_y) _placeOrientArrow(viewport, 'y', flip_y, flip_x);
21411
+ function _clearOrientEdgeHighlight() {
21412
+ _orientShown = false;
21413
+ if (_orientRaf) { cancelAnimationFrame(_orientRaf); _orientRaf = 0; }
21414
+ if (_orientFlashTimer) { clearTimeout(_orientFlashTimer); _orientFlashTimer = 0; }
21415
+ _orientCrossEls.forEach(el => el.classList.remove('showing'));
21416
+ }
21417
+ // Shown for every image pane while the pointer is over the DimBar.
21418
+ function _showOrientEdgeHighlight() {
21419
+ if (!_orientEdgeAllowed()) { _clearOrientEdgeHighlight(); return; }
21420
+ _orientShown = true;
21421
+ if (!_orientRaf) _orientLoop();
21422
+ }
21423
+ // Brief look after r / t so a rotate or transpose can be read off the cross.
21424
+ function _flashOrientCross(ms = 1400) {
21425
+ if (!_orientEdgeAllowed()) return;
21426
+ _showOrientEdgeHighlight();
21427
+ if (_orientFlashTimer) clearTimeout(_orientFlashTimer);
21428
+ _orientFlashTimer = setTimeout(() => {
21429
+ _orientFlashTimer = 0;
21430
+ if (!_orientHovering) _clearOrientEdgeHighlight();
21431
+ }, ms);
21329
21432
  }
21330
21433
 
21331
21434
  function setStatus(msg) {
@@ -23317,6 +23420,79 @@
23317
23420
  }
23318
23421
  }, true);
23319
23422
 
23423
+ // ── Compare with an array that is open in another tab ─────────
23424
+ let _compareWithMenuVisible = false;
23425
+ let _compareWithChoices = [];
23426
+
23427
+ function _closeCompareWithMenu() {
23428
+ if (!_compareWithMenuVisible) return;
23429
+ _compareWithMenuVisible = false;
23430
+ _compareWithChoices = [];
23431
+ _reconcileUI();
23432
+ try { document.getElementById('keyboard-sink').focus(); } catch (_) {}
23433
+ }
23434
+
23435
+ async function _pickCompareWith(choice) {
23436
+ if (!choice || !choice.enabled) return;
23437
+ _closeCompareWithMenu();
23438
+ const ok = await enterCompareModeBySid(choice.sid, choice.label);
23439
+ if (ok !== false) showStatus(`Compare: ${choice.label}`);
23440
+ }
23441
+
23442
+ async function openCompareWithMenu() {
23443
+ let sessions = [];
23444
+ try {
23445
+ const res = await proxyFetch('/sessions');
23446
+ if (res.ok) sessions = await res.json();
23447
+ } catch (_) {}
23448
+ const ownShape = JSON.stringify((shape || []).map(Number));
23449
+ _compareWithChoices = sessions
23450
+ .filter(s => s.sid && s.sid !== sid)
23451
+ .map(s => {
23452
+ const sameShape = JSON.stringify((s.shape || []).map(Number)) === ownShape;
23453
+ const label = s.var || s.name || s.sid.slice(0, 8);
23454
+ if (s.name) compareNameBySid[s.sid] = label;
23455
+ return {
23456
+ sid: s.sid,
23457
+ label,
23458
+ detail: `${(s.shape || []).join(' x ')}${s.var && s.name ? ` · ${s.name}` : ''}`,
23459
+ enabled: sameShape,
23460
+ };
23461
+ });
23462
+ if (!_compareWithChoices.length) {
23463
+ showStatus('No other open arrays to compare with');
23464
+ return;
23465
+ }
23466
+ const list = document.getElementById('compare-with-list');
23467
+ list.innerHTML = _compareWithChoices.map((c, i) => {
23468
+ const keyHint = i < 9 ? `<span class="compare-with-key">${i + 1}</span>` : '';
23469
+ const reason = c.enabled ? c.detail : `${c.detail} · needs shape ${(shape || []).join(' x ')}`;
23470
+ return `<button class="drop-action" data-compare-with="${i}" ${c.enabled ? '' : 'disabled'}>${keyHint}${_escapeHtml(c.label)}<span class="drop-action-reason">${_escapeHtml(reason)}</span></button>`;
23471
+ }).join('');
23472
+ list.querySelectorAll('.drop-action:not(:disabled)').forEach(button => {
23473
+ button.addEventListener('click', () => _pickCompareWith(_compareWithChoices[Number(button.dataset.compareWith)]));
23474
+ });
23475
+ document.getElementById('compare-with-summary').textContent =
23476
+ 'Arrays open in your other tabs · click or press a number · Esc to cancel';
23477
+ _compareWithMenuVisible = true;
23478
+ _reconcileUI();
23479
+ }
23480
+
23481
+ document.addEventListener('pointerdown', (event) => {
23482
+ if (_compareWithMenuVisible && !document.getElementById('compare-with-menu').contains(event.target)) {
23483
+ _closeCompareWithMenu();
23484
+ }
23485
+ });
23486
+ window.addEventListener('keydown', (event) => {
23487
+ if (!_compareWithMenuVisible) return;
23488
+ if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) return;
23489
+ event.preventDefault();
23490
+ event.stopImmediatePropagation();
23491
+ if (event.key === 'Escape') { _closeCompareWithMenu(); return; }
23492
+ const n = Number(event.key);
23493
+ if (Number.isInteger(n) && n >= 1 && n <= 9) _pickCompareWith(_compareWithChoices[n - 1]);
23494
+ }, true);
23495
+
23320
23496
  /* ── JS: Rendering Pipeline (updateView, play, screenshot) ── */
23321
23497
 
23322
23498
  function updateView() {
@@ -27757,6 +27933,7 @@
27757
27933
  _roiClearOnDimChange();
27758
27934
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27759
27935
  renderInfo(); saveState();
27936
+ _flashOrientCross();
27760
27937
  return;
27761
27938
  }
27762
27939
  const wasMosaic = dim_z >= 0;
@@ -27772,6 +27949,7 @@
27772
27949
  showStatus(wasMosaic ? 'transposed x/y (mosaic)' : 'transposed x/y');
27773
27950
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27774
27951
  renderInfo(); saveState();
27952
+ _flashOrientCross();
27775
27953
  },
27776
27954
  },
27777
27955
  'view.rotateOrFlip': {
@@ -27802,6 +27980,7 @@
27802
27980
  showStatus('rotated 90°'); saveState();
27803
27981
  }
27804
27982
  if (typeof _cropReconcileBindings === 'function') _cropReconcileBindings();
27983
+ _flashOrientCross();
27805
27984
  },
27806
27985
  },
27807
27986
  'theme.cycle': {
@@ -29084,6 +29263,11 @@
29084
29263
  commands['qmri.cycle'].run(ctx, e);
29085
29264
  },
29086
29265
  },
29266
+ 'compare.withOpenArray': {
29267
+ title: 'Compare with an array open in another tab',
29268
+ when: [],
29269
+ run: (ctx, e) => { openCompareWithMenu(); },
29270
+ },
29087
29271
  'tool.comparecenterDirect': {
29088
29272
  title: 'Cycle compare center mode',
29089
29273
  when: [],
@@ -29515,11 +29699,12 @@
29515
29699
  },
29516
29700
  {
29517
29701
  id: 'compare', label: 'Compare',
29518
- desc: 'View two or more arrays side by side, blend them together, or compute a difference map. Useful for checking registration, comparing model outputs, or inspecting before-and-after changes. Open multiple arrays with the file picker first.',
29702
+ desc: 'View two or more arrays side by side, blend them together, or compute a difference map. Useful for checking registration, comparing model outputs, or inspecting before-and-after changes. Press / then c to compare with an array open in another tab.',
29519
29703
  sections: [
29520
29704
  {
29521
29705
  title: 'Layout',
29522
29706
  entries: [
29707
+ { key: '/ c', action: 'Compare with…', hint: 'pick an array that is open in another tab; it must have the same shape' },
29523
29708
  { key: 'g', action: 'Cycle layout', hint: 'horizontal → vertical → grid; with exactly 2 arrays + center mode active, toggles horizontal ↔ big-left' },
29524
29709
  { key: 'Drag title', action: 'Reorder panes by dragging their title bar' },
29525
29710
  ]
@@ -29558,6 +29743,7 @@
29558
29743
  {
29559
29744
  title: 'After /', columnBreak: true,
29560
29745
  entries: [
29746
+ { key: '/ c', action: 'Compare with…', hint: 'side by side with an array open in another tab' },
29561
29747
  { key: '/ r', action: 'ROI', hint: 'measure a region and see live stats' },
29562
29748
  { key: '/ S', action: 'Segmentation', hint: 'local mask editing and nnInteractive options' },
29563
29749
  { key: '/ o', action: 'Overlays', hint: 'show mask names and visibility' },
@@ -29983,6 +30169,16 @@
29983
30169
  { key: 'q', toolId: 'qmri', command: 'tool.qmriDirect' },
29984
30170
  { key: 's', toolId: 'save', command: 'saveMenu.open' },
29985
30171
  { key: 'C', toolId: 'crop', command: 'tool.cropDirect' },
30172
+ {
30173
+ key: 'c',
30174
+ id: 'comparewith',
30175
+ label: 'Compare with…',
30176
+ command: 'compare.withOpenArray',
30177
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="8" height="14" rx="1"/><rect x="13" y="5" width="8" height="14" rx="1"/></svg>',
30178
+ enabled: () => !isInlineEmbed,
30179
+ active: () => compareActive,
30180
+ unavailableReason: () => 'Not available in notebook output.',
30181
+ },
29986
30182
  { key: 'P', toolId: 'comparecenter', command: 'tool.comparecenterDirect' },
29987
30183
  { key: 'r', toolId: 'roi', command: 'tool.roiDirect' },
29988
30184
  { key: 'S', toolId: 'segmentation', command: 'tool.segmentationDirect' },
@@ -37771,6 +37967,7 @@
37771
37967
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return;
37772
37968
  sink.focus();
37773
37969
  _qmriMvDragView = view;
37970
+ _qmvCrosshairMap = view.qmriIdx;
37774
37971
  _qmvRecomputeCrosshairTarget();
37775
37972
  handleQmriMvPointer(view, e);
37776
37973
  const onMove = (me) => { if (_qmriMvDragView === view) handleQmriMvPointer(view, me); };
@@ -37923,7 +38120,7 @@
37923
38120
  _hoveredQmriMvView = null;
37924
38121
  if (_qmvCrosshairAnim) { cancelAnimationFrame(_qmvCrosshairAnim); _qmvCrosshairAnim = null; }
37925
38122
  if (_qmvScrollHintTimer) { clearTimeout(_qmvScrollHintTimer); _qmvScrollHintTimer = null; }
37926
- _qmvCrosshairAlpha = 0; _qmvCrosshairTarget = 0; _qmvScrollHintDir = -1;
38123
+ _qmvCrosshairAlpha = 0; _qmvCrosshairTarget = 0; _qmvScrollHintDir = -1; _qmvCrosshairMap = null;
37927
38124
  _reconcileOrthoCursorHidden();
37928
38125
  _reconcileMvOrientationVisibility();
37929
38126
  qmriMvViews.forEach(v => { if (v.ws) { v.ws.onclose = null; v.ws.close(); } });
@@ -38097,7 +38294,9 @@
38097
38294
  }
38098
38295
  /** Position lines on the pane's overlay, like compareMvDrawFrame. */
38099
38296
  function _qmvDrawCrosshair(v) {
38100
- const alpha = (_qmvScrollHintTimer && v.sliceDir === _qmvScrollHintDir) ? 0 : _qmvCrosshairAlpha;
38297
+ const alpha = (_qmvScrollHintTimer && v.sliceDir === _qmvScrollHintDir) ? 0
38298
+ : (_qmvCrosshairMap !== null && v.qmriIdx !== _qmvCrosshairMap) ? 0
38299
+ : _qmvCrosshairAlpha;
38101
38300
  const ov = _getMvCrosshairOverlay(v);
38102
38301
  if (!ov) return;
38103
38302
  const metrics = _getMvCrosshairMetrics(v);
@@ -38130,8 +38329,8 @@
38130
38329
  if (!ready.length) return;
38131
38330
  const nRows = Math.max(1, Math.round(qmriMvViews.length / 3));
38132
38331
  const nCols = 3;
38133
- const colGap = 24;
38134
- const rowGap = 14;
38332
+ const colGap = 0;
38333
+ const rowGap = 0;
38135
38334
  const sidePad = 120;
38136
38335
  const vcbW = 80; // vertical colorbar island horizontal footprint (bar + padding)
38137
38336
  const bottomPad = 16;
@@ -38147,6 +38346,13 @@
38147
38346
  const baseScale = absMaxT / maxSide;
38148
38347
  const panStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
38149
38348
  for (const v of ready) {
38349
+ const qi = qmriMvViews.indexOf(v), qr = Math.floor(qi / 3), qc = qi % 3;
38350
+ if (v.clip) {
38351
+ v.clip.classList.toggle('qmv-tl', qr === 0 && qc === 0);
38352
+ v.clip.classList.toggle('qmv-tr', qr === 0 && qc === 2);
38353
+ v.clip.classList.toggle('qmv-bl', qr === nRows - 1 && qc === 0);
38354
+ v.clip.classList.toggle('qmv-br', qr === nRows - 1 && qc === 2);
38355
+ }
38150
38356
  _scalePane(
38151
38357
  { canvas: v.canvas, w: v.lastW, h: v.lastH, pan: panStub },
38152
38358
  v.clip || null,
@@ -39155,6 +39361,7 @@
39155
39361
  * scrolled plane's own lines stay put, so they are left hidden. */
39156
39362
  function _qmvScrollHint(view) {
39157
39363
  _qmvScrollHintDir = view ? view.sliceDir : -1;
39364
+ if (!_qmriMvDragView && view) _qmvCrosshairMap = view.qmriIdx;
39158
39365
  if (_qmvScrollHintTimer) clearTimeout(_qmvScrollHintTimer);
39159
39366
  _qmvScrollHintTimer = setTimeout(() => {
39160
39367
  _qmvScrollHintTimer = null;
@@ -39887,9 +40094,10 @@
39887
40094
  let _histDimHovering = false;
39888
40095
  infoEl.addEventListener('mouseover', (e) => {
39889
40096
  const span = e.target.closest('[data-dim]');
40097
+ _orientHovering = true;
40098
+ _showOrientEdgeHighlight();
39890
40099
  if (span) {
39891
40100
  hoveredDim = parseInt(span.dataset.dim);
39892
- _showOrientEdgeHighlight(hoveredDim);
39893
40101
  if (_histDimControlsVisible()) {
39894
40102
  _histDimHovering = true;
39895
40103
  _showHistTooltip(_tooltipTextForDim(hoveredDim), infoEl.getBoundingClientRect());
@@ -39905,7 +40113,6 @@
39905
40113
  return;
39906
40114
  }
39907
40115
  _histDimHovering = false;
39908
- _clearOrientEdgeHighlight();
39909
40116
  _hideHistTooltip();
39910
40117
  if (!_lebesgueManual && primaryCb && primaryCb._expanded && !_histAutoDismissTimer) {
39911
40118
  _restartHistAutoDismiss();
@@ -39915,6 +40122,7 @@
39915
40122
  infoEl.addEventListener('mouseleave', () => {
39916
40123
  hoveredDim = -1;
39917
40124
  _histDimHovering = false;
40125
+ _orientHovering = false;
39918
40126
  _clearOrientEdgeHighlight();
39919
40127
  _hideHistTooltip();
39920
40128
  if (!_lebesgueManual && primaryCb && primaryCb._expanded && !_histAutoDismissTimer) {
@@ -41644,6 +41852,11 @@
41644
41852
  dropMenu.classList.toggle('visible', _dropMenuVisible);
41645
41853
  dropMenu.setAttribute('aria-hidden', _dropMenuVisible ? 'false' : 'true');
41646
41854
  }
41855
+ const compareWithMenu = document.getElementById('compare-with-menu');
41856
+ if (compareWithMenu) {
41857
+ compareWithMenu.classList.toggle('visible', _compareWithMenuVisible);
41858
+ compareWithMenu.setAttribute('aria-hidden', _compareWithMenuVisible ? 'false' : 'true');
41859
+ }
41647
41860
  if (_zenActive) _setZenQmriChromeOpacity('0');
41648
41861
  renderToolMenu();
41649
41862
  if (typeof _reconcileOverlayPalette === 'function') _reconcileOverlayPalette();
@@ -58,7 +58,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
58
58
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
59
59
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
60
60
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
61
- _VSCODE_EXT_VERSION = "0.15.61" # current bundled extension version
61
+ _VSCODE_EXT_VERSION = "0.15.62" # current bundled extension version
62
62
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
63
63
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
64
64
  # script opening several arrays) states the mismatch once instead of per call.
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.46.2
3
+ Version: 0.47.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
@@ -23,7 +23,7 @@ arrayview/_render.py,sha256=sSNPWreJumIyFKvZtAS5v2gxbNHRVyAdc7JGj34caPw,34121
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
26
- arrayview/_routes_loading.py,sha256=RNivOL6MPbOM5u1-JhD9IKitEmWHJHkhqvCZSspfJMw,41546
26
+ arrayview/_routes_loading.py,sha256=ZOHOFdqMDwmdjtzhq07LQUGG7BHmP1od4IiZDWWRZtQ,41662
27
27
  arrayview/_routes_persistence.py,sha256=T03csHb7ppGT-8yMDf227XkqhnhjgSLYj0RW2wmg59g,17176
28
28
  arrayview/_routes_preferences.py,sha256=SKnhTf2X2vRyYi06Wb2si4dXMB1y0XiSBr28RZ5oKUw,4691
29
29
  arrayview/_routes_preload.py,sha256=KOsankNguxdIWmFW9iZcY9p2SvUN1mOKY7KkW1qMGcc,1729
@@ -43,17 +43,17 @@ arrayview/_synthetic_mri.py,sha256=CNL-2WC835i6WCqkvmWBCO6WQ5w7HE5trNb8OyEfe64,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=Ik61c-_RUE6pzfmtRQPcAcZewvOo_VlbVCjS7rijpcM,2358853
46
+ arrayview/_viewer.html,sha256=pUCkoJRPvZMCvNEh-zoGDOTtjX0A1D7vdLqHI9Tzr3A,2371938
47
47
  arrayview/_vscode.py,sha256=EKnGN2zrjlybc4FQOmYA8BvYdtkcJbLHDEdgtvO7ul4,837
48
48
  arrayview/_vscode_browser.py,sha256=VCH9JpAAjFuhO0wQe8tIGjAL5837AXg_bpmBFtWOQqc,20573
49
- arrayview/_vscode_extension.py,sha256=WjA4NVP6ufl2skTJCqQs2yjTTGKqR_STBEhda2pNJEo,53156
49
+ arrayview/_vscode_extension.py,sha256=AUfGJpPBPzKIRyVF0wvrDqr5F0eVrZpFEHoxuNjO15M,53156
50
50
  arrayview/_vscode_signal.py,sha256=N3PCHG6-3tCEc_tHLjg2dhSEQ2EnF_psE2IJRBpAuac,54938
51
51
  arrayview/_watch.py,sha256=PxxS_5hcj_S6xQf9Ep4p1HLRkrI5iDMh1aRa4olV7ew,1873
52
- arrayview/arrayview-opener.vsix,sha256=0RyABz8Y2m8rE0HxlwfVrbUP0fTGhQLXOoZAKUiAOxE,60690
52
+ arrayview/arrayview-opener.vsix,sha256=1kbHgrG_kyxbDFa6WQ_KojmMPiogHlcu62L86HraVbk,61155
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.46.2.dist-info/METADATA,sha256=qMu3fAEyUOwr5vFecXoIcy23IM4k6aUBOqHLFrgFoPo,2288
56
- arrayview-0.46.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
- arrayview-0.46.2.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
- arrayview-0.46.2.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
- arrayview-0.46.2.dist-info/RECORD,,
55
+ arrayview-0.47.0.dist-info/METADATA,sha256=x5u-7ZK2lktztD4BFipekM4rP8bh1YL5KwI9Jd1F5Ss,2288
56
+ arrayview-0.47.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ arrayview-0.47.0.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
+ arrayview-0.47.0.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
+ arrayview-0.47.0.dist-info/RECORD,,