arrayview 0.45.1__py3-none-any.whl → 0.46.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.
arrayview/_viewer.html CHANGED
@@ -477,6 +477,32 @@
477
477
  position: absolute; bottom: 0; left: 0; width: 100%;
478
478
  height: 0; overflow: visible; pointer-events: none; z-index: 1;
479
479
  }
480
+ /* Tick values under the strip (see ColorBar._drawTicks). Hangs off
481
+ the column so the bar itself never changes size; glass islands
482
+ pad their bottom to enclose it. */
483
+ .cb-ticks {
484
+ position: absolute; top: 100%; left: 0; width: 100%;
485
+ height: 15px; line-height: 10px; overflow: visible;
486
+ pointer-events: none; z-index: 1; opacity: 0;
487
+ font-size: 9px; font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace;
488
+ font-variant-numeric: tabular-nums; color: var(--text);
489
+ transition: opacity 0.2s ease;
490
+ }
491
+ .cb-ticks span {
492
+ position: absolute; top: 5px; transform: translateX(-50%);
493
+ white-space: nowrap; opacity: 0.55;
494
+ }
495
+ /* A short marker under the bar's edge above each round value. */
496
+ .cb-ticks span::before {
497
+ content: ''; position: absolute; left: 50%; top: -4px;
498
+ width: 1px; height: 3px; background: currentColor; opacity: 0.8;
499
+ }
500
+
501
+ /* The longhand is needed too: `.cb-wrap` (every ColorBar-built
502
+ island) and `#mv-cb-wrap` set their own padding shorthand after
503
+ `.cb-island`, so the variable alone never reaches them. */
504
+ .cb-island.cb-has-ticks, .cb-wrap.cb-has-ticks, #mv-cb-wrap.cb-has-ticks { --cb-base-pad-bottom: 18px; padding-bottom: 18px; }
505
+ body.fullscreen-mode #slim-cb-wrap.cb-has-ticks { padding-bottom: 18px; }
480
506
  .cb-tooltip {
481
507
  position: absolute; top: 0; left: 0;
482
508
  font-size: 12px; font-family: 'SF Mono', ui-monospace, 'Cascadia Code', monospace;
@@ -503,10 +529,12 @@
503
529
  #slim-cb-tri-zone.expanded .slim-cb-tri { opacity: 1; }
504
530
  .cb-tri-zone.expanded .slim-cb-tri { opacity: 1; }
505
531
  #slim-cb-labels { display: none; /* vmin/vmax now shown as inline spans in the island */ }
506
- .slim-cb-val { font-size: 12px; color: var(--text); font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; white-space: nowrap; flex: 0 0 auto; min-width: 5ch; text-align: center; line-height: 1; cursor: pointer; display: inline-block; transition: color 140ms ease, opacity 140ms ease; }
532
+ /* Same face and size as the tick row under the bar, so the ends and
533
+ the values between them read as one scale. */
534
+ .slim-cb-val { font-size: 10px; color: var(--text); opacity: 0.9; font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; white-space: nowrap; flex: 0 0 auto; min-width: 5ch; text-align: center; line-height: 1; cursor: pointer; display: inline-block; transition: color 140ms ease, opacity 140ms ease; }
507
535
  .slim-cb-val .exp { font-size: 0.7em; opacity: 0.5; vertical-align: baseline; letter-spacing: -0.3px; }
508
- .slim-cb-val.is-locked,
509
- .slim-cb-val.locked { color: var(--muted); opacity: 0.5; }
536
+ .cb-expanded .slim-cb-val.is-locked,
537
+ .cb-expanded .slim-cb-val.locked { color: var(--muted); opacity: 0.5; }
510
538
 
511
539
  [data-cb-bound] {
512
540
  cursor: pointer; box-sizing: border-box;
@@ -518,8 +546,8 @@
518
546
  }
519
547
  [data-cb-bound]:hover { background: rgba(255,255,255,0.06); }
520
548
  :root.light [data-cb-bound]:hover { background: rgba(0,0,0,0.045); }
521
- [data-cb-bound].is-locked,
522
- [data-cb-bound].locked { color: var(--muted) !important; opacity: 0.5; }
549
+ .cb-expanded [data-cb-bound].is-locked,
550
+ .cb-expanded [data-cb-bound].locked { color: var(--muted) !important; opacity: 0.5; }
523
551
 
524
552
  .slim-cb-val-input { font-size: 12px; font-family: system-ui, -apple-system, sans-serif; font-variant-numeric: tabular-nums; width: 52px; text-align: center; background: rgba(255,255,255,0.12); color: #fff; border: 1px solid rgba(255,255,255,0.25); border-radius: 4px; padding: 1px 2px; outline: none; line-height: 1; }
525
553
  :root.light .slim-cb-val-input { background: rgba(0,0,0,0.08); color: #1a1a1a; border-color: rgba(0,0,0,0.2); }
@@ -925,14 +953,6 @@
925
953
  opacity: 1;
926
954
  transform: translateY(0);
927
955
  }
928
- /* Only the axis letter (x/y/z) itself is purple — the slash and
929
- * size/hover-position after it dim the same way as any other dim's
930
- * does, not tinted purple (a plain color on .dim-hover-idx wouldn't
931
- * be enough to override an inherited -webkit-text-fill-color). */
932
- #info .dim-axis-letter {
933
- color: var(--spatial-dim);
934
- -webkit-text-fill-color: var(--spatial-dim);
935
- }
936
956
  #info .active-dim.dim-label .dim-slash,
937
957
  #info .active-dim.dim-label .dim-size {
938
958
  color: var(--text);
@@ -5938,15 +5958,39 @@
5938
5958
  // A single connection is not enough (it took three flickers down to
5939
5959
  // one, not zero) — the drops come in short runs, and connections opened
5940
5960
  // together absorb a run instead of queueing behind it.
5961
+ let _clientPathWarmBatch = null;
5962
+ let _clientPathWarmSuspended = false;
5941
5963
  function _warmClientPath(connections) {
5942
- if (!_isIntegratedBrowserViewer) return;
5943
- const count = Math.max(1, Math.min(6, Number(connections) || 3));
5944
- for (let i = 0; i < count; i++) {
5945
- // Distinct URLs so nothing is served from cache and each one is
5946
- // a real connection through the forward.
5947
- fetch(`ping?warm=${Date.now()}-${i}`, { cache: 'no-store' })
5948
- .catch(() => {});
5949
- }
5964
+ if (!_isIntegratedBrowserViewer || _clientPathWarmBatch
5965
+ || _clientPathWarmSuspended) return;
5966
+ // Chromium shares its HTTP connection budget across tabs on this
5967
+ // origin. Unbounded background pings can occupy every slot and
5968
+ // prevent even a new page's first request from being sent.
5969
+ const count = Math.max(1, Math.min(3, Number(connections) || 3));
5970
+ const controller = new AbortController();
5971
+ const stopWarming = () => {
5972
+ // The private proxy may retain upstream work after a browser
5973
+ // abort. Do not add another burst to a route already known to
5974
+ // be stuck. A newly loaded viewer can warm its working route.
5975
+ _clientPathWarmSuspended = true;
5976
+ controller.abort();
5977
+ };
5978
+ const timeout = setTimeout(stopWarming, 2000);
5979
+ const requests = Array.from({ length: count }, (_, i) =>
5980
+ fetch(`ping?warm=${Date.now()}-${i}`, {
5981
+ cache: 'no-store',
5982
+ signal: controller.signal,
5983
+ }).then(response => {
5984
+ if (!response.ok) throw new Error('Connection check failed');
5985
+ // Keep the deadline until the body has arrived too: fetch
5986
+ // resolves at headers, while the connection can stay busy.
5987
+ return response.arrayBuffer();
5988
+ }).catch(stopWarming)
5989
+ );
5990
+ _clientPathWarmBatch = Promise.allSettled(requests).finally(() => {
5991
+ clearTimeout(timeout);
5992
+ _clientPathWarmBatch = null;
5993
+ });
5950
5994
  }
5951
5995
  // isInlineEmbed: true when explicitly launched as an inline Jupyter/notebook embed
5952
5996
  // (the Python launcher adds &inline=1 to the URL for inline IFrames).
@@ -6520,6 +6564,45 @@
6520
6564
  let _roiContextJustOpened = false;
6521
6565
  let _qvRoiDragView = null; // which qMRI view initiated current ROI drag
6522
6566
  let _mvRoiDragView = null; // which multiview pane initiated current ROI drag
6567
+ // Hover-info quick-measure: while hover info is on, a plain left-drag
6568
+ // always draws a disposable freehand outline (never pan, never the
6569
+ // real ROI tool) instead. It never touches _rois[]/the ROI panel/export.
6570
+ let _infoFreehandDragging = false; // true while the quick-measure drag is in progress
6571
+ let _infoFreehandPts = []; // points collected during that drag
6572
+ let _infoFreehandView = null; // which mv/qv pane initiated it (main mode: unused)
6573
+ // Every finished quick-measure shape is APPENDED (not overwritten) to
6574
+ // its own surface's list below, and a matching removal closure is
6575
+ // pushed onto _infoQuickRoiStack — one global creation order across
6576
+ // all 5 surfaces — so Backspace (see roi.deleteSelected) can always
6577
+ // pop the single most-recently-drawn shape, whichever surface it's on.
6578
+ let _infoQuickRois = []; // normal-mode canvas: [{type:'freehand', points, _infoQuick:true, statsText}]
6579
+ let _infoQuickRoisMv = []; // multiview: [{..., mvDimX, mvDimY, statsText}]
6580
+ let _infoQuickRoisQv = []; // qMRI: [{..., qvStatsByPaneIdx:{[view.qmriIdx]:statsText}}] — shown on every pane, one stat per pane
6581
+ // Compare mode: split panes each get their own list (indexed like
6582
+ // compareCanvases/compareDisplaySids), plus one for the centre diff
6583
+ // pane. Points are in that pane's own canvas-pixel space (cv.width/
6584
+ // height), never in the cached slice's grid — see _infoQuickCompareStats.
6585
+ let _infoQuickRoisCompare = []; // _infoQuickRoisCompare[paneIdx] = [{points, _infoQuick:true, statsText}]
6586
+ let _infoQuickRoisCompareDiff = []; // [{points, _infoQuick:true, statsText}] for the centre diff pane
6587
+ let _infoQuickRoiStack = []; // LIFO of {remove(){...}} closures, one per shape, across all 5 surfaces — Backspace pops this
6588
+ function _infoQuickStatsText(stats) {
6589
+ return stats ? `${_roiStatsFmt(stats.mean)} ± ${_roiStatsFmt(stats.std)}` : null;
6590
+ }
6591
+ function _clearInfoQuickRoi() {
6592
+ _infoFreehandDragging = false;
6593
+ _infoFreehandPts = [];
6594
+ _infoFreehandView = null;
6595
+ _infoQuickRois = [];
6596
+ _infoQuickRoisMv = [];
6597
+ _infoQuickRoisQv = [];
6598
+ _infoQuickRoisCompare = [];
6599
+ _infoQuickRoisCompareDiff = [];
6600
+ _infoQuickRoiStack = [];
6601
+ if (Array.isArray(compareCanvases)) compareCanvases.forEach(cv => cv && _infoHideCompareOverlay(cv));
6602
+ const diffCvForClear = document.getElementById('compare-diff-canvas');
6603
+ if (diffCvForClear) _infoHideCompareOverlay(diffCvForClear);
6604
+ _redrawRoiOverlays();
6605
+ }
6523
6606
  let _islandDragPos = null; // {x, y} — remembered drag position, null = default left
6524
6607
  let toolMenuOpen = false;
6525
6608
  let _toolLauncherOpen = false;
@@ -7213,7 +7296,7 @@
7213
7296
  // info-cursor behavior is one rule, not one per mode. (qMRI multiview
7214
7297
  // panning has no crosshair-line overlay, so it is not part of this.)
7215
7298
  function _anyOrthoDragActive() {
7216
- return !!(mvDraggingView || cmvDraggingView);
7299
+ return !!(mvDraggingView || cmvDraggingView || _qmriMvDragView);
7217
7300
  }
7218
7301
  function _reconcileOrthoCursorHidden() {
7219
7302
  document.body.classList.toggle('crosshair-cursor-hidden', _anyOrthoDragActive());
@@ -7484,6 +7567,11 @@
7484
7567
  let _cmvCrosshairAlpha = 0; // current crosshair opacity for compare-mv
7485
7568
  let _cmvCrosshairTarget = 0; // target opacity for compare-mv
7486
7569
  let _cmvCrosshairAnim = null; // animation frame id for compare-mv crosshair fade
7570
+ let _qmvCrosshairAlpha = 0; // current crosshair opacity for qMRI multiview
7571
+ let _qmvCrosshairTarget = 0; // target opacity for qMRI multiview
7572
+ let _qmvCrosshairAnim = null; // animation frame id for qMRI-mv crosshair fade
7573
+ let _qmvScrollHintTimer = null; // fade-out timer after scrolling a qMRI-mv pane
7574
+ let _qmvScrollHintDir = -1; // plane being scrolled (its own lines stay hidden)
7487
7575
  let compareMvSids = []; // SIDs being compared in compare-mv mode
7488
7576
  let compareMvDims = []; // [dimX, dimY, sliceDir×3] spatial dims used
7489
7577
  let qmriMvActive = false; // qMRI + 3-plane multiview combined mode (sub-mode of qMRI)
@@ -8663,7 +8751,10 @@
8663
8751
 
8664
8752
  // const MAX_CANVAS_FIT_PX = 600;
8665
8753
  const NORMAL_SIDE_RESERVE = 80;
8666
- const NORMAL_BOTTOM_RESERVE = 48;
8754
+ // Room under the image for the colorbar island and its tick row.
8755
+ // Constant whether ticks are on or off, so cycling them never moves
8756
+ // the image, dimbar or colorbar.
8757
+ const NORMAL_BOTTOM_RESERVE = 62;
8667
8758
  const NORMAL_UI_RESERVE_FLOOR = 44;
8668
8759
  const MV_COLORBAR_BOTTOM_CLEARANCE = 42;
8669
8760
 
@@ -8829,10 +8920,12 @@
8829
8920
  if (!_fullscreenActive) _normalFitZoom = userZoom;
8830
8921
  _zoomRendered = userZoom;
8831
8922
  } else if (allowOverflowRecalc && !_zoomAdjustedByUser && !_fullscreenActive && !(_scrubDetached && _scrubState)) {
8832
- const projW = Math.round(w * baseScale * userZoom);
8833
- const projH = Math.round(h * baseScale * userZoom);
8834
- if (projW > maxW + 2 || projH > maxH + 2) {
8835
- userZoom = Math.max(1.0, capScale / baseScale);
8923
+ // Untouched zoom means "fit": follow the window both ways,
8924
+ // so growing it after a shrink fills the space again rather
8925
+ // than leaving the image (and the chrome hugging it) small.
8926
+ const fit = Math.max(1.0, capScale / baseScale);
8927
+ if (Math.abs(fit - userZoom) > 1e-6) {
8928
+ userZoom = fit;
8836
8929
  _fitZoom = userZoom;
8837
8930
  _normalFitZoom = userZoom;
8838
8931
  _zoomRendered = userZoom;
@@ -10995,8 +11088,10 @@
10995
11088
  const labelFmt = primaryCb && primaryCb._dragLabelFormatter
10996
11089
  ? primaryCb._dragLabelFormatter
10997
11090
  : formatValueGroup(labelValues);
10998
- slimVmin.innerHTML = _cbFmt(displayValues[0], labelValues, labelFmt);
10999
- slimVmax.innerHTML = _cbFmt(displayValues[1], labelValues, labelFmt);
11091
+ // _signPad reserves the sign's width so crossing zero doesn't
11092
+ // shift the centred digits — see the comment on _signPad.
11093
+ slimVmin.innerHTML = _signPad(_cbFmt(displayValues[0], labelValues, labelFmt));
11094
+ slimVmax.innerHTML = _signPad(_cbFmt(displayValues[1], labelValues, labelFmt));
11000
11095
  const vminIsLocked = _cbBoundLocked(primaryCb, 'vmin');
11001
11096
  const vmaxIsLocked = _cbBoundLocked(primaryCb, 'vmax');
11002
11097
  slimVmin.classList.toggle('is-locked', vminIsLocked);
@@ -11027,6 +11122,31 @@
11027
11122
  const CB_LABEL_H = 0; // no label zone — dmin/dmax shown as DOM elements
11028
11123
  let _cbExpandedH = 18; // histogram fits within colorbar height (no vertical expansion)
11029
11124
  const _cbAnimDuration = 350; // animation duration in ms (shared with ColorBar class)
11125
+
11126
+ // ── Colorbar tick marks ──────────────────────────────────────────────
11127
+ // Values under the collapsed strip between the flanking vmin/vmax
11128
+ // labels (which share the row's face and size): the window split
11129
+ // into equal steps, at growing density. Equal steps (rather than
11130
+ // round values) keep the spacing even and never crowd the ends.
11131
+ // Level 0 shows only the flanking labels. Cycled with `w`; every
11132
+ // horizontal ColorBar reads it.
11133
+ const CB_TICK_TARGETS = [0, 4, 8]; // steps between vmin and vmax per level
11134
+ const CB_TICK_LABELS = ['off', 'few', 'many'];
11135
+ const CB_TICK_ROW_H = 15; // marker + value row under the strip, CSS px (matches .cb-ticks)
11136
+ let _cbTickLevel = 0;
11137
+ /** vmin..vmax in `steps` equal steps, ends included. */
11138
+ function _cbEvenTicks(lo, hi, steps) {
11139
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || !(hi > lo) || !(steps > 0)) return [];
11140
+ const ticks = [];
11141
+ for (let k = 0; k <= steps; k++) ticks.push(k === steps ? hi : lo + (hi - lo) * k / steps);
11142
+ return ticks;
11143
+ }
11144
+ function _cycleCbTickLevel() {
11145
+ _cbTickLevel = (_cbTickLevel + 1) % CB_TICK_TARGETS.length;
11146
+ ColorBar.redrawAll();
11147
+ showStatus('colorbar ticks: ' + CB_TICK_LABELS[_cbTickLevel]);
11148
+ saveState();
11149
+ }
11030
11150
  // Legacy animation globals removed (Task 4) — primaryCb owns all animation state
11031
11151
  let _kdeSmooth = null; // smoothed histogram values (Float64Array, NPTS entries)
11032
11152
  const _KDE_NPTS = 300; // resolution of the smooth curve
@@ -11134,8 +11254,19 @@
11134
11254
  return { vminFrac, vmaxFrac };
11135
11255
  }
11136
11256
  class ColorBar {
11257
+ // Every live instance, so a global display change (tick level)
11258
+ // can repaint all bars without each mode wiring its own hook.
11259
+ static _live = new Set();
11260
+ static redrawAll() {
11261
+ for (const cb of ColorBar._live) {
11262
+ if (!cb.canvas || !cb.canvas.isConnected || cb.canvas.clientWidth === 0) continue;
11263
+ try { cb.draw(); } catch (e) { /* a bar mid-teardown must not block the rest */ }
11264
+ }
11265
+ }
11266
+
11137
11267
  constructor(opts) {
11138
11268
  this.opts = opts;
11269
+ ColorBar._live.add(this);
11139
11270
  this.orientation = opts.orientation || 'horizontal';
11140
11271
  this.interactive = opts.interactive !== false;
11141
11272
  this.id = opts.id || ('cb-' + Math.random().toString(36).slice(2, 8));
@@ -11587,6 +11718,169 @@
11587
11718
  ctx.restore();
11588
11719
  }
11589
11720
 
11721
+ /**
11722
+ * Tick marks for the collapsed strip. Notches sit on the bar's
11723
+ * bottom edge; the values go in a small row hanging under the
11724
+ * bar (`.cb-ticks`), like an axis, so the strip itself stays
11725
+ * clean. When that row would run off the bottom of the window
11726
+ * the values are drawn inside the strip instead. The strip
11727
+ * spans the current window, so ticks are placed against
11728
+ * getWindow(). They fade out as the histogram expands — the
11729
+ * expanded view has its own handle labels and the strip then
11730
+ * spans the data range.
11731
+ */
11732
+ _ensureTickRow() {
11733
+ if (this._tickRow || !this.colWrap) return this._tickRow;
11734
+ const row = document.createElement('div');
11735
+ row.className = 'cb-ticks';
11736
+ this.colWrap.appendChild(row);
11737
+ this._tickRow = row;
11738
+ this._tickRowKey = '';
11739
+ return row;
11740
+ }
11741
+ _setTickRow(labels, cssW, alpha) {
11742
+ const row = this._tickRow;
11743
+ if (!row) return;
11744
+ const key = labels ? cssW + '|' + labels.map(l => l.x + ':' + l.text).join(',') : '';
11745
+ if (key !== this._tickRowKey) {
11746
+ this._tickRowKey = key;
11747
+ row.textContent = '';
11748
+ for (const l of labels || []) {
11749
+ const span = document.createElement('span');
11750
+ span.textContent = l.text;
11751
+ span.style.left = (l.x / cssW * 100) + '%';
11752
+ row.appendChild(span);
11753
+ }
11754
+ }
11755
+ const on = !!(labels && labels.length);
11756
+ row.style.opacity = on ? String(alpha) : '0';
11757
+ if (this.wrap) this.wrap.classList.toggle('cb-has-ticks', on);
11758
+ }
11759
+ _tickRowClipped() {
11760
+ if (!this.colWrap) return true;
11761
+ // Walk offsetTop rather than reading the client rect: the
11762
+ // island flies in / fades via transforms, and a rect taken
11763
+ // mid-flight would report the bar lower than it will rest.
11764
+ let bottom = this.colWrap.offsetHeight;
11765
+ for (let el = this.colWrap; el; el = el.offsetParent) bottom += el.offsetTop;
11766
+ return bottom + CB_TICK_ROW_H > window.innerHeight;
11767
+ }
11768
+ _drawTicks(ctx, cssW, cssH, aT) {
11769
+ const row = this._ensureTickRow();
11770
+ // `w` pins the tick row permanently visible at the level it
11771
+ // cycles to (_cbTickLevel > 0); hovering always gives a peek
11772
+ // even while off, at the 'few' density as a default (there's
11773
+ // no pinned density to fall back on at level 0). Layout
11774
+ // space (cb-has-ticks padding-bottom) is reserved whenever
11775
+ // ticks are actually showing either way, so pinned viewing
11776
+ // never jumps; a hover-only peek at level 0 does reserve it
11777
+ // right as hovering starts, which reads as part of the peek
11778
+ // rather than an unrelated layout shift.
11779
+ //
11780
+ // Hover-info mode (_pixelInfoVisible, `i`) peeks the ticks
11781
+ // too, on every colorbar at once, not just the one under the
11782
+ // cursor — once you're reading out values via hover-info,
11783
+ // the ticks are a hint for the whole bar, not just wherever
11784
+ // the mouse happens to be.
11785
+ const pinned = _cbTickLevel > 0;
11786
+ const peeking = this._mouseOver || _pixelInfoVisible;
11787
+ const activeLevel = pinned ? _cbTickLevel : 1;
11788
+ const expandAlpha = 1 - Math.min(1, aT / 0.4);
11789
+ const tickAlpha = (pinned || peeking) ? expandAlpha : 0;
11790
+ let ticks = [];
11791
+ let wLo = null, wHi = null;
11792
+ let fmt = null;
11793
+ const inside = !row || this._tickRowClipped();
11794
+ const showText = !inside || cssH >= 14;
11795
+ if ((pinned || peeking) && this.opts.getWindow && expandAlpha > 0 && cssW >= 60) {
11796
+ const win = this.opts.getWindow();
11797
+ wLo = win ? win.vmin : null; wHi = win ? win.vmax : null;
11798
+ ctx.save();
11799
+ ctx.font = '9px monospace';
11800
+ // Halve the step count until every label has room, so a
11801
+ // narrow bar thins the ruler evenly instead of dropping
11802
+ // values here and there.
11803
+ for (let steps = CB_TICK_TARGETS[activeLevel]; steps >= 2; steps = Math.floor(steps / 2)) {
11804
+ const all = _cbEvenTicks(wLo, wHi, steps);
11805
+ if (!all.length) break;
11806
+ // Precision chosen with the ends in the group, so the
11807
+ // row matches the flanking labels' scale.
11808
+ fmt = formatValueGroup(all);
11809
+ ticks = all.slice(1, -1);
11810
+ const widest = Math.max(...ticks.map(v => ctx.measureText(_signPad(fmt.format(v))).width));
11811
+ const room = inside ? cssW - widest : cssW;
11812
+ // Same clearance the per-label overlap check below
11813
+ // demands (2px side pad each + 4px gap).
11814
+ if (!showText || (widest + 8) * steps <= room) break;
11815
+ ticks = [];
11816
+ }
11817
+ ctx.restore();
11818
+ }
11819
+ if (!ticks.length) { this._setTickRow(null, cssW, 0); return; }
11820
+ // Row mode keeps the strip clean: the markers live in the row
11821
+ // (see .cb-ticks span::before). Only the inside fallback (no
11822
+ // room for a row) draws notches on the strip itself.
11823
+ const notchH = Math.max(2, Math.min(4, Math.round(cssH / 5)));
11824
+ ctx.save();
11825
+ ctx.globalAlpha = tickAlpha;
11826
+ ctx.font = '9px monospace';
11827
+ ctx.textAlign = 'center';
11828
+ ctx.textBaseline = 'middle';
11829
+ ctx.lineJoin = 'round';
11830
+ ctx.lineWidth = 2.5;
11831
+ const themeStyle = getComputedStyle(document.documentElement);
11832
+ const ink = themeStyle.getPropertyValue('--text').trim() || themeStyle.color;
11833
+ const halo = themeStyle.getPropertyValue('--surface').trim() || themeStyle.backgroundColor;
11834
+ ctx.fillStyle = ink;
11835
+ ctx.strokeStyle = halo;
11836
+ const drawn = [];
11837
+ const rowLabels = [];
11838
+ ticks.forEach((v) => {
11839
+ const f = (v - wLo) / (wHi - wLo);
11840
+ const x = Math.round(f * cssW);
11841
+ // Reserve the sign's width (see _signPad) so a tick
11842
+ // crossing zero mid-drag doesn't shift its own centred
11843
+ // text — a non-breaking figure space in place of an
11844
+ // absent '-' has the same monospace advance width and,
11845
+ // unlike a plain space, survives DOM whitespace
11846
+ // collapsing when this ends up in a <span> below.
11847
+ const text = _signPad(fmt.format(v));
11848
+ const tw = ctx.measureText(text).width;
11849
+ const left = x - tw / 2 - 2;
11850
+ const right = x + tw / 2 + 2;
11851
+ if (showText) {
11852
+ // The row may overhang the strip a little; inside it may not.
11853
+ const slack = inside ? 0 : tw / 2;
11854
+ if (left < 1 - slack || right > cssW - 1 + slack) return;
11855
+ if (drawn.some(d => left < d.right + 4 && right > d.left - 4)) return;
11856
+ }
11857
+ if (inside) {
11858
+ // Notches get the same halo as the values, so they
11859
+ // still read where the colormap matches the ink.
11860
+ const nx = x;
11861
+ for (const y of [0, cssH - notchH]) {
11862
+ ctx.fillStyle = halo;
11863
+ ctx.fillRect(nx - 1, y - 1, 3, notchH + 2);
11864
+ ctx.fillStyle = ink;
11865
+ ctx.fillRect(nx, y, 1, notchH);
11866
+ }
11867
+ }
11868
+ if (showText) {
11869
+ if (inside) {
11870
+ // A stable theme halo keeps values legible without
11871
+ // making each tick react to the colormap beneath it.
11872
+ ctx.strokeText(text, x, cssH / 2 + 0.5);
11873
+ ctx.fillText(text, x, cssH / 2 + 0.5);
11874
+ } else {
11875
+ rowLabels.push({ x, text });
11876
+ }
11877
+ drawn.push({ left, right });
11878
+ }
11879
+ });
11880
+ ctx.restore();
11881
+ this._setTickRow(inside ? null : rowLabels, cssW, tickAlpha);
11882
+ }
11883
+
11590
11884
  /**
11591
11885
  * Core morph renderer — draws the colorbar/histogram as one shape.
11592
11886
  * Adapted from the global _cbMorphDraw function.
@@ -11716,6 +12010,7 @@
11716
12010
  ctx.fillRect(px, y, 1, colH);
11717
12011
  }
11718
12012
 
12013
+ this._drawTicks(ctx, cssW, cssH, aT);
11719
12014
 
11720
12015
  // Solid handle lines + value labels in the label zone
11721
12016
  if (aT > 0.1) {
@@ -11769,21 +12064,25 @@
11769
12064
  ctx.font = '9px monospace';
11770
12065
  const charW = ctx.measureText('0').width;
11771
12066
  const labelY = labelH - 3;
12067
+ // _signPad reserves the sign's width so a label
12068
+ // crossing zero doesn't shift position — matters most
12069
+ // for the 'center'-aligned ones below, where the text
12070
+ // itself (not just a box around it) is centred.
11772
12071
  const labels = [];
11773
- labels.push({ x: 1, text: _cbFmtPlain(dmin, groupValues, labelFormatter), align: 'start', color: dimColor, prio: 0 });
11774
- labels.push({ x: cssW - 1, text: _cbFmtPlain(dmax, groupValues, labelFormatter), align: 'end', color: dimColor, prio: 0 });
12072
+ labels.push({ x: 1, text: _signPad(_cbFmtPlain(dmin, groupValues, labelFormatter)), align: 'start', color: dimColor, prio: 0 });
12073
+ labels.push({ x: cssW - 1, text: _signPad(_cbFmtPlain(dmax, groupValues, labelFormatter)), align: 'end', color: dimColor, prio: 0 });
11775
12074
  if (effVmin > 0.01 && effVmin < 0.99) {
11776
12075
  const vx = Math.round(effVmin * cssW);
11777
- labels.push({ x: vx, text: _cbFmtPlain(wVmin, groupValues, labelFormatter), align: 'center', color: brightColor, prio: 1 });
12076
+ labels.push({ x: vx, text: _signPad(_cbFmtPlain(wVmin, groupValues, labelFormatter)), align: 'center', color: brightColor, prio: 1 });
11778
12077
  }
11779
12078
  if (effVmax > 0.01 && effVmax < 0.99) {
11780
12079
  const vx = Math.round(effVmax * cssW);
11781
- labels.push({ x: vx, text: _cbFmtPlain(wVmax, groupValues, labelFormatter), align: 'center', color: brightColor, prio: 1 });
12080
+ labels.push({ x: vx, text: _signPad(_cbFmtPlain(wVmax, groupValues, labelFormatter)), align: 'center', color: brightColor, prio: 1 });
11782
12081
  }
11783
12082
  if (this._hoverFrac >= 0 && this._hoverFrac <= 1) {
11784
12083
  const hoverVal = dmin + this._hoverFrac * (dmax - dmin);
11785
12084
  const hx = Math.round(this._hoverFrac * cssW);
11786
- labels.push({ x: hx, text: _cbFmtPlain(hoverVal, groupValues, labelFormatter), align: 'center', color: hoverColor, prio: 2 });
12085
+ labels.push({ x: hx, text: _signPad(_cbFmtPlain(hoverVal, groupValues, labelFormatter)), align: 'center', color: hoverColor, prio: 2 });
11787
12086
  }
11788
12087
  labels.sort((a, b) => b.prio - a.prio);
11789
12088
  const drawn = [];
@@ -11830,6 +12129,9 @@
11830
12129
 
11831
12130
  /** Remove the island from the DOM and clean up timers. */
11832
12131
  destroy() {
12132
+ ColorBar._live.delete(this);
12133
+ if (this._tickRow && this._tickRow.parentNode) this._tickRow.parentNode.removeChild(this._tickRow);
12134
+ this._tickRow = null;
11833
12135
  if (this._hoverTimer) clearTimeout(this._hoverTimer);
11834
12136
  if (this._autoDismissTimer) clearTimeout(this._autoDismissTimer);
11835
12137
  this._detachEvents();
@@ -11879,9 +12181,11 @@
11879
12181
  const labelFormatter = this._dragLabelFormatter || formatValueGroup(targetLabelValues);
11880
12182
  const formatLabel = this.opts.formatLabel;
11881
12183
  const defaultLabel = (value) => _cbFmt(value, targetLabelValues, labelFormatter);
12184
+ // _signPad reserves the sign's width so crossing zero
12185
+ // doesn't shift the (centred) digits — see its comment.
11882
12186
  const labelHtml = (value, which) => {
11883
12187
  const custom = formatLabel ? formatLabel(value, which) : null;
11884
- return custom ?? defaultLabel(value);
12188
+ return _signPad(custom ?? defaultLabel(value));
11885
12189
  };
11886
12190
  if (this.vminLabel) {
11887
12191
  this.vminLabel.innerHTML = labelHtml(this._labelVmin, 'vmin');
@@ -12216,6 +12520,9 @@
12216
12520
  this._mouseOver = true;
12217
12521
  if (this._onBinHover) this.ensureHistogramData();
12218
12522
  if (this.opts.onMouseEnter) this.opts.onMouseEnter();
12523
+ // Fade the tick row in immediately (see _drawTicks)
12524
+ // instead of waiting for some unrelated redraw.
12525
+ this.draw();
12219
12526
  });
12220
12527
  this.wrap.addEventListener('mouseleave', () => {
12221
12528
  this._mouseOver = false;
@@ -12226,6 +12533,11 @@
12226
12533
  if (this.opts.hoverValueInEggs) _setEggsValuePill(null);
12227
12534
  else if (this.tooltip) this.tooltip.style.display = 'none';
12228
12535
  if (this._onBinHover) this._onBinHover(-1, -1);
12536
+ } else {
12537
+ // Fade the tick row back out (see _drawTicks) even
12538
+ // on a leave with no prior hoverFrac update (e.g. a
12539
+ // mouseenter/leave with no movement in between).
12540
+ this.draw();
12229
12541
  }
12230
12542
  if (this._autoDismissPending && !this._dragActive) {
12231
12543
  this._autoDismissPending = false;
@@ -14501,9 +14813,17 @@
14501
14813
  else if (_dmenuDiffTargetActive()) _dmenuSetRangeTarget('primary');
14502
14814
  if (which === 'vmin') {
14503
14815
  if (_cbBoundPolicyLocked(cb, which)) return;
14504
- vminLocked = !vminLocked;
14505
- if (vminLocked) _dmenuCaptureBoundLock('vmin');
14506
- else _dmenuZeroFloorVminUnlocked = true;
14816
+ // Toggle what the label shows, not the bare flag: a zero
14817
+ // floor already reads as locked, so the first toggle there
14818
+ // must release it (as the range menu row does) rather than
14819
+ // engage a second, invisible lock underneath.
14820
+ if (!vminLocked && _dmenuZeroFloorLocksVmin()) {
14821
+ _dmenuZeroFloorVminUnlocked = true;
14822
+ } else {
14823
+ vminLocked = !vminLocked;
14824
+ if (vminLocked) _dmenuCaptureBoundLock('vmin');
14825
+ else _dmenuZeroFloorVminUnlocked = true;
14826
+ }
14507
14827
  } else {
14508
14828
  vmaxLocked = !vmaxLocked;
14509
14829
  if (vmaxLocked) _dmenuCaptureBoundLock('vmax');
@@ -17260,6 +17580,7 @@
17260
17580
  rectRoiMode = true;
17261
17581
  if (!['circle', 'rect', 'freehand', 'floodfill'].includes(_roiShape)) _roiShape = 'circle';
17262
17582
  _roiVisible = true;
17583
+ _clearInfoQuickRoi(); // switching to the real ROI tool drops the quick-measure outline
17263
17584
  hideAllPixelInfo();
17264
17585
  _resetDimbarHover();
17265
17586
  canvas.style.cursor = 'crosshair';
@@ -17820,6 +18141,58 @@
17820
18141
  }
17821
18142
  }
17822
18143
 
18144
+ /** Ad-hoc mean/std for the hover-info quick-measure outline — same
18145
+ * /roi_stats/{sid} endpoint and payload shape as the real ROI tool
18146
+ * (_roiFetchStructuredStats above), but the shape never lives in
18147
+ * _rois[] so nothing is added to the ROI panel/export. Used for
18148
+ * normal mode and multiview (which also reuses _roiFetchStructuredStats
18149
+ * for its own real ROIs, so this inherits the same per-pane accuracy). */
18150
+ async function _infoQuickFetchStats(points) {
18151
+ try {
18152
+ const resp = await proxyFetch(`/roi_stats/${sid}`, {
18153
+ method: 'POST',
18154
+ headers: {'Content-Type':'application/json'},
18155
+ body: JSON.stringify({
18156
+ dim_x, dim_y,
18157
+ indices: indices.slice(),
18158
+ complex_mode: complexMode,
18159
+ rois: _roiPayload([{ type: 'freehand', points }], { currentIndices: true }),
18160
+ }),
18161
+ });
18162
+ const data = await resp.json();
18163
+ const result = data.results && data.results[0];
18164
+ return result ? result.stats : null;
18165
+ } catch (err) {
18166
+ return null;
18167
+ }
18168
+ }
18169
+
18170
+ /** qMRI variant: each pane is a different quantitative map, so stats
18171
+ * are fetched per-view (mirrors _fetchQvRoiStats) and stashed on
18172
+ * `entry.qvStatsByPaneIdx[view.qmriIdx]` — the one shape is shared
18173
+ * across every qMRI pane (they show the same slice), but each pane
18174
+ * can independently show a different mean±std for it, same as
18175
+ * today's single-shape case just keyed per entry now that several
18176
+ * shapes can coexist. Drawn by _drawAllQvRois, not shown in a fixed
18177
+ * pill anymore. */
18178
+ function _infoQuickFetchQvStats(entry) {
18179
+ if (!qmriActive) return;
18180
+ const roundedPts = entry.points.map(p => [Math.round(p[0]), Math.round(p[1])]);
18181
+ qmriViews.forEach(v => {
18182
+ const qIndices = [...indices];
18183
+ qIndices[qmriDim] = v.qmriIdx;
18184
+ proxyFetch(`/roi_freehand/${sid}?dim_x=${dim_x}&dim_y=${dim_y}&indices=${qIndices.join(',')}&complex_mode=${complexMode}`, {
18185
+ method: 'POST',
18186
+ headers: {'Content-Type':'application/json'},
18187
+ body: JSON.stringify({ points: roundedPts }),
18188
+ }).then(r => r.json()).then(d => {
18189
+ if (d.error || !_infoQuickRoisQv.includes(entry)) return;
18190
+ entry.qvStatsByPaneIdx[v.qmriIdx] = _infoQuickStatsText(d);
18191
+ _drawAllQvRois();
18192
+ }).catch(() => {});
18193
+ });
18194
+ }
18195
+
17823
18196
  let _roiStatsRefreshTimer = null;
17824
18197
  function _scheduleRoiStatsRefresh(navDim = null) {
17825
18198
  if (!rectRoiMode || !_rois.length) return;
@@ -21256,9 +21629,18 @@
21256
21629
  }
21257
21630
  const _pixelFmt = (v, contextValues = null) => formatScalarValue(v, { contextValues, maxDecimals: 7, minDecimals: 3 });
21258
21631
  function _activeNumericContext(view = null) {
21259
- const vals = view && view.displayState
21260
- ? [view.displayState.vmin, view.displayState.vmax]
21261
- : [manualVmin ?? currentVmin, manualVmax ?? currentVmax];
21632
+ // A pane's own window, falling back to its auto range — the
21633
+ // legacy qMRI / multi-view objects carry vmin/vmax (+ locked*)
21634
+ // directly, the View class carries a displayState; either way
21635
+ // the main array's range is only right for the main canvas.
21636
+ let vals;
21637
+ if (view && view.displayState) {
21638
+ vals = [view.displayState.vmin ?? view.vmin, view.displayState.vmax ?? view.vmax];
21639
+ } else if (view && ('vmin' in view || 'lockedVmin' in view)) {
21640
+ vals = [view.lockedVmin ?? view.vmin, view.lockedVmax ?? view.vmax];
21641
+ } else {
21642
+ vals = [manualVmin ?? currentVmin, manualVmax ?? currentVmax];
21643
+ }
21262
21644
  return vals.filter(Number.isFinite);
21263
21645
  }
21264
21646
  /** Format a hovered pixel value with decimals fixed by the numeric
@@ -21642,6 +22024,7 @@
21642
22024
  colormap_idx,
21643
22025
  indices: [...indices],
21644
22026
  logScale,
22027
+ cbTickLevel: _cbTickLevel,
21645
22028
  projectionMode,
21646
22029
  projectionDim,
21647
22030
  complexMode,
@@ -21712,6 +22095,7 @@
21712
22095
  });
21713
22096
  }
21714
22097
  logScale = s.logScale ?? false;
22098
+ _cbTickLevel = Number.isInteger(s.cbTickLevel) && s.cbTickLevel >= 0 ? Math.min(s.cbTickLevel, CB_TICK_TARGETS.length - 1) : 0;
21715
22099
  projectionMode = Number.isInteger(s.projectionMode) && s.projectionMode >= 0 && s.projectionMode < PROJECTION_LABELS.length + 1 ? s.projectionMode : 0;
21716
22100
  projectionDim = Number.isInteger(s.projectionDim) && s.projectionDim >= 0 && s.projectionDim < shape.length ? s.projectionDim : (projectionMode > 0 ? current_slice_dim : -1);
21717
22101
  if (_isCollectionDim(projectionDim)) {
@@ -22969,6 +23353,26 @@
22969
23353
  if (inner) inner.style.transform = (x || y) ? `translate(${x}px,${y}px)` : '';
22970
23354
  },
22971
23355
  });
23356
+ const qvPan = new PanManager({
23357
+ getViewport: () => {
23358
+ const clip = qmriViews[0]?.canvas?.closest('.qv-canvas-clip');
23359
+ return clip ? { w: clip.offsetWidth, h: clip.offsetHeight } : { w: 0, h: 0 };
23360
+ },
23361
+ getContent: () => {
23362
+ const cvs = qmriViews[0]?.canvas;
23363
+ return cvs ? {
23364
+ w: parseInt(cvs.style.width) || cvs.width,
23365
+ h: parseInt(cvs.style.height) || cvs.height,
23366
+ } : { w: 0, h: 0 };
23367
+ },
23368
+ apply: (x, y) => {
23369
+ const transform = (x || y) ? `translate(${x}px,${y}px)` : '';
23370
+ qmriViews.forEach(v => {
23371
+ v.canvas.style.transform = transform;
23372
+ if (v.roiOverlay) v.roiOverlay.style.transform = transform;
23373
+ });
23374
+ },
23375
+ });
22972
23376
  const mosaicPan = new PanManager({
22973
23377
  getViewport: () => {
22974
23378
  const wrap = document.getElementById('qmri-view-wrap');
@@ -23682,7 +24086,9 @@
23682
24086
  }
23683
24087
  // Left-click pan when zoomed in and not in ROI mode.
23684
24088
  // In mosaic mode (dim_z >= 0) defer to loupe even if canvas overflows.
23685
- if (!rectRoiMode && mainPan.overflows && dim_z < 0) {
24089
+ // Hover-info mode claims plain left-drag for the quick-measure
24090
+ // outline instead (see below), so it must not pan even when zoomed.
24091
+ if (!rectRoiMode && !_pixelInfoVisible && mainPan.overflows && dim_z < 0) {
23686
24092
  mainPan.startDrag(e);
23687
24093
  canvas.style.cursor = 'grabbing';
23688
24094
  return;
@@ -23710,6 +24116,14 @@
23710
24116
  // Hold left mouse button to activate pixel info readout
23711
24117
  if (!rectRoiMode && e.button === 0 && !e.shiftKey && !e.metaKey && !e.altKey
23712
24118
  && !_cropActive && !_segMode && !_rulerMode) {
24119
+ // Hover-info quick-measure: a plain click-drag while hover info
24120
+ // is on always draws a disposable freehand outline — never pan,
24121
+ // never the real ROI tool, regardless of _roiShape/zoom state.
24122
+ if (_pixelInfoVisible && dim_z < 0 && !e.ctrlKey) {
24123
+ _infoFreehandDragging = true;
24124
+ _infoFreehandView = null; // this drag belongs to the main canvas, not an mv/qv pane
24125
+ _infoFreehandPts = [[roiStart.x, roiStart.y]];
24126
+ }
23713
24127
  if (!_pixelInfoVisible && _bcCanDrag()) {
23714
24128
  const pending = _bcPendingDrag = { startX: e.clientX, startY: e.clientY, targetView: null, targetCb: null };
23715
24129
  // A press held still (never crossing the deadzone) still
@@ -24417,44 +24831,285 @@
24417
24831
  }
24418
24832
  return null;
24419
24833
  }
24834
+ // Neutral color for the hover-info quick-measure outline — distinct
24835
+ // from the numbered _roiColors palette so it never reads as a saved ROI.
24836
+ const _INFO_QUICK_ROI_COLOR = { fill: 'rgba(255,255,255,0.05)', stroke: 'rgba(255,255,255,0.88)' };
24837
+ /** Bounding-box anchor (center-x, top-y, bottom-y) for
24838
+ * _infoQuickDrawPill, in the same already-scaled display space
24839
+ * _roiLabelPoint/_roiDrawLabel use for real ROI labels. Built on
24840
+ * _roiBounds, the same helper every real ROI's handles/label point
24841
+ * already use, so a quick-measure shape's pill sits itself the same
24842
+ * way a real ROI already sizes its own label. */
24843
+ function _infoQuickPillAnchor(r, sx, sy) {
24844
+ const b = _roiBounds(r);
24845
+ return { cx: ((b.x0 + b.x1) / 2) * sx, y0: b.y0 * sy, y1: b.y1 * sy };
24846
+ }
24847
+ /** Small pill-shaped chip (rounded-rect background + centered text)
24848
+ * showing a hover-info quick-measure shape's own mean±std reading,
24849
+ * drawn directly above that shape's own bounding box (or, if that
24850
+ * would clip off the top of this canvas, below it instead) — unlike
24851
+ * _roiDrawLabel (a real ROI's numbered label), which has no
24852
+ * background and isn't anchored to a specific shape's extent. One
24853
+ * shared helper so all 5 surfaces (normal/mv/qv/compare split/
24854
+ * compare diff) render this identically rather than duplicating
24855
+ * rounded-rect drawing code five times.
24856
+ * ctx: canvas 2d context to draw on. text: the pill's label, or a
24857
+ * falsy value to skip drawing (stats not back yet from the async
24858
+ * fetch/raster). cx, y0, y1: the shape's bounding box — horizontal
24859
+ * center and vertical extent — already scaled to this canvas's own
24860
+ * display space (see _infoQuickPillAnchor). Matches the CSS of the
24861
+ * actual hovered-value pill (.eggs-value-pill, ~line 2072) as
24862
+ * closely as a canvas shape can — same background/border colors,
24863
+ * corner radius, padding, font — so this reads as the same pill
24864
+ * rather than a separate ROI-tool-styled chip of its own. */
24865
+ function _infoQuickDrawPill(ctx, text, cx, y0, y1) {
24866
+ if (!text) return;
24867
+ const light = document.documentElement.classList.contains('light');
24868
+ const bg = light ? 'rgba(240,240,240,0.8)' : 'rgba(30,30,30,0.75)';
24869
+ const border = 'rgba(255,255,255,0.08)';
24870
+ const valueColor = getComputedStyle(document.documentElement).getPropertyValue('--active-dim').trim()
24871
+ || _INFO_QUICK_ROI_COLOR.stroke;
24872
+ const GAP = 5, PAD_X = 7, H = 18, RAD = 4;
24873
+ ctx.save();
24874
+ ctx.font = '11px "SF Mono", ui-monospace, "Cascadia Code", "JetBrains Mono", monospace';
24875
+ const w = ctx.measureText(text).width + PAD_X * 2;
24876
+ // Anchor above the shape by default; flip to sit below it instead
24877
+ // if drawing above would clip off the top of the canvas (y < 0).
24878
+ let top = y0 - GAP - H;
24879
+ if (top < 0) top = y1 + GAP;
24880
+ const left = cx - w / 2;
24881
+ ctx.beginPath();
24882
+ ctx.moveTo(left + RAD, top);
24883
+ ctx.arcTo(left + w, top, left + w, top + H, RAD);
24884
+ ctx.arcTo(left + w, top + H, left, top + H, RAD);
24885
+ ctx.arcTo(left, top + H, left, top, RAD);
24886
+ ctx.arcTo(left, top, left + w, top, RAD);
24887
+ ctx.closePath();
24888
+ ctx.fillStyle = bg;
24889
+ ctx.fill();
24890
+ ctx.strokeStyle = border;
24891
+ ctx.lineWidth = 1;
24892
+ ctx.stroke();
24893
+ ctx.fillStyle = valueColor;
24894
+ ctx.textAlign = 'center';
24895
+ ctx.textBaseline = 'middle';
24896
+ ctx.fillText(text, cx, top + H / 2 + 0.5);
24897
+ ctx.restore();
24898
+ }
24899
+
24900
+ // ── Hover-info quick-measure overlays for compare mode ──────────────
24901
+ // Compare panes have no shared ROI-overlay canvas to draw on (unlike
24902
+ // normal/mv/qv, which all reuse _drawAllRois/_drawAllMvRois/_drawAllQvRois
24903
+ // against roiOverlayCanvas-style surfaces). Instead, mirror the crop
24904
+ // tool's per-pane overlay pattern (_cropGetCompareOverlay /
24905
+ // _cropRenderCompareOverlay / _cropHideCompareOverlay): one lazily
24906
+ // created absolutely-positioned canvas per pane, cached on the pane's
24907
+ // own canvas element, sized to match it in CSS pixels (no dpr scaling,
24908
+ // same as the crop overlay).
24909
+ function _infoGetCompareOverlay(cv) {
24910
+ if (!cv) return null;
24911
+ if (cv._infoOverlay) return cv._infoOverlay;
24912
+ const parent = cv.parentElement; // .compare-canvas-inner
24913
+ if (!parent) return null;
24914
+ const ov = document.createElement('canvas');
24915
+ ov.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;background:transparent;display:none;z-index:2;';
24916
+ parent.appendChild(ov);
24917
+ cv._infoOverlay = ov;
24918
+ return ov;
24919
+ }
24920
+ function _infoHideCompareOverlay(cv) {
24921
+ if (!cv || !cv._infoOverlay) return;
24922
+ const ov = cv._infoOverlay;
24923
+ const ctx2 = ov.getContext('2d');
24924
+ if (ctx2) ctx2.clearRect(0, 0, ov.width, ov.height);
24925
+ ov.style.display = 'none';
24926
+ }
24927
+ /** Draw (or hide) every committed quick-measure outline for one
24928
+ * compare pane, plus an optional still-being-dragged preview shape.
24929
+ * `roiObjs` is an array of {type:'freehand', points, statsText}
24930
+ * shapes whose points are in `cv`'s own canvas-pixel space
24931
+ * (cv.width/height) — reuses _roiDrawVisibleShape so the
24932
+ * stroke/fill treatment matches the outline drawn everywhere else
24933
+ * this feature appears, and _infoQuickDrawPill for each shape's own
24934
+ * mean±std pill (the in-progress `previewObj` has no stats yet, so
24935
+ * it never gets one). */
24936
+ function _infoRenderCompareOverlay(cv, roiObjs, previewObj) {
24937
+ const ov = _infoGetCompareOverlay(cv);
24938
+ if (!ov) return;
24939
+ const imgW = cv.width, imgH = cv.height;
24940
+ const list = (roiObjs || []).filter(r => r && r.points && r.points.length >= 3);
24941
+ const hasPreview = !!(previewObj && previewObj.points && previewObj.points.length >= 3);
24942
+ if ((!list.length && !hasPreview) || !imgW || !imgH) {
24943
+ _infoHideCompareOverlay(cv);
24944
+ return;
24945
+ }
24946
+ const dispW = parseInt(cv.style.width) || imgW;
24947
+ const dispH = parseInt(cv.style.height) || imgH;
24948
+ ov.style.width = cv.style.width || `${dispW}px`;
24949
+ ov.style.height = cv.style.height || `${dispH}px`;
24950
+ if (ov.width !== dispW || ov.height !== dispH) { ov.width = dispW; ov.height = dispH; }
24951
+ const ctx2 = ov.getContext('2d');
24952
+ if (!ctx2) { _infoHideCompareOverlay(cv); return; }
24953
+ ctx2.clearRect(0, 0, dispW, dispH);
24954
+ const sx = dispW / imgW, sy = dispH / imgH;
24955
+ for (const r of list) {
24956
+ _roiDrawVisibleShape(ctx2, r, sx, sy, _INFO_QUICK_ROI_COLOR, false);
24957
+ const a = _infoQuickPillAnchor(r, sx, sy);
24958
+ _infoQuickDrawPill(ctx2, r.statsText, a.cx, a.y0, a.y1);
24959
+ }
24960
+ if (hasPreview) {
24961
+ _roiDrawVisibleShape(ctx2, previewObj, sx, sy, _INFO_QUICK_ROI_COLOR, false);
24962
+ }
24963
+ ov.style.display = '';
24964
+ }
24965
+ /**
24966
+ * Client-side mean/std of `valueAt(gx,gy)` over grid cells (integer
24967
+ * pixel centers) whose center falls inside polygon `scaledPts` — both
24968
+ * in the same 0..w,0..h grid space. Compare mode never calls the ROI
24969
+ * backend for hover (see _getCachedHoverSlice), so the quick-measure
24970
+ * aggregation for compare panes/the diff pane is done entirely here,
24971
+ * unlike _infoQuickFetchStats/_infoQuickFetchQvStats which POST to
24972
+ * /roi_stats / /roi_freehand for normal/mv/qMRI. Reuses _pointInRoi
24973
+ * (same ray-casting it already does for real freehand ROIs) rather
24974
+ * than duplicating point-in-polygon math.
24975
+ */
24976
+ function _infoQuickRasterStats(w, h, scaledPts, valueAt) {
24977
+ if (!scaledPts || scaledPts.length < 3) return null;
24978
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
24979
+ for (const [x, y] of scaledPts) {
24980
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
24981
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
24982
+ }
24983
+ minX = Math.max(0, Math.floor(minX)); maxX = Math.min(w - 1, Math.ceil(maxX));
24984
+ minY = Math.max(0, Math.floor(minY)); maxY = Math.min(h - 1, Math.ceil(maxY));
24985
+ if (minX > maxX || minY > maxY) return null;
24986
+ const roi = { type: 'freehand', points: scaledPts };
24987
+ let n = 0, sum = 0, sumSq = 0;
24988
+ for (let gy = minY; gy <= maxY; gy++) {
24989
+ for (let gx = minX; gx <= maxX; gx++) {
24990
+ if (!_pointInRoi(roi, gx + 0.5, gy + 0.5, null, null)) continue;
24991
+ const v = valueAt(gx, gy);
24992
+ if (!Number.isFinite(v)) continue;
24993
+ n++; sum += v; sumSq += v * v;
24994
+ }
24995
+ }
24996
+ if (!n) return null;
24997
+ const mean = sum / n;
24998
+ return { mean, std: Math.sqrt(Math.max(0, sumSq / n - mean * mean)) };
24999
+ }
25000
+ /** Split-pane quick-measure stats: rasterize `entry.points` (cv pixel
25001
+ * space) over pane `i`'s own cached hover slice, stash the result on
25002
+ * `entry.statsText`, and redraw so that entry's own pill picks it
25003
+ * up (see _infoRenderCompareOverlay) — the fixed pane pill is no
25004
+ * longer used for this, since each shape now carries its own number. */
25005
+ function _infoQuickComparePaneStats(i, entry) {
25006
+ const cv = compareCanvases[i];
25007
+ const cvSid = compareDisplaySids[i];
25008
+ if (!cv || !cvSid || !cv.width || !cv.height) return;
25009
+ const cached = _getCachedHoverSlice(cvSid, dim_x, dim_y, _comparePaneHoverIndices(i));
25010
+ if (!cached || !cached.w || !cached.h) return;
25011
+ const scaledPts = entry.points.map(p => [p[0] * cached.w / cv.width, p[1] * cached.h / cv.height]);
25012
+ const stats = _infoQuickRasterStats(cached.w, cached.h, scaledPts, (gx, gy) => {
25013
+ const rawX = flip_x ? cached.w - 1 - gx : gx;
25014
+ const rawY = flip_y ? cached.h - 1 - gy : gy;
25015
+ return cached.slice[rawY * cached.w + rawX];
25016
+ });
25017
+ entry.statsText = _infoQuickStatsText(stats);
25018
+ const arr = _infoQuickRoisCompare[i];
25019
+ if (arr && arr.includes(entry)) _infoRenderCompareOverlay(cv, arr);
25020
+ }
25021
+ /** Centre diff-pane quick-measure stats: same per-pixel combine
25022
+ * _compareCenterSample uses (diffMode 1/2/3), applied to every pixel
25023
+ * inside the outline instead of a single hover point. No-op for
25024
+ * diffMode 0 (overlay/wipe/flicker/checker) — there's no per-pixel
25025
+ * value concept for those today and this deliberately doesn't add one.
25026
+ * Stashes the result on `entry.statsText` and redraws so that
25027
+ * entry's own pill picks it up, same as _infoQuickComparePaneStats. */
25028
+ function _infoQuickCompareDiffStats(entry) {
25029
+ const cv = document.getElementById('compare-diff-canvas');
25030
+ if (!cv || diffMode <= 0 || compareDisplaySids.length < 2) return;
25031
+ if (!cv.width || !cv.height) return;
25032
+ const [sidA, sidB] = compareDisplaySids;
25033
+ if (!sidA || !sidB) return;
25034
+ const cachedA = _getCachedHoverSlice(sidA, dim_x, dim_y, _comparePaneHoverIndices(0));
25035
+ const cachedB = _getCachedHoverSlice(sidB, dim_x, dim_y, _comparePaneHoverIndices(1));
25036
+ if (!cachedA || !cachedB || !cachedA.w || !cachedA.h) return;
25037
+ const scaledPts = entry.points.map(p => [p[0] * cachedA.w / cv.width, p[1] * cachedA.h / cv.height]);
25038
+ const stats = _infoQuickRasterStats(cachedA.w, cachedA.h, scaledPts, (gx, gy) => {
25039
+ const rawXA = flip_x ? cachedA.w - 1 - gx : gx;
25040
+ const rawYA = flip_y ? cachedA.h - 1 - gy : gy;
25041
+ const rawXB = flip_x ? cachedB.w - 1 - gx : gx;
25042
+ const rawYB = flip_y ? cachedB.h - 1 - gy : gy;
25043
+ const a = cachedA.slice[rawYA * cachedA.w + rawXA];
25044
+ const b = cachedB.slice[rawYB * cachedB.w + rawXB];
25045
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return NaN;
25046
+ const d = a - b;
25047
+ return diffMode === 1 ? d : diffMode === 2 ? Math.abs(d) : Math.abs(d) / Math.abs(a);
25048
+ });
25049
+ entry.statsText = _infoQuickStatsText(stats);
25050
+ if (_infoQuickRoisCompareDiff.includes(entry)) _infoRenderCompareOverlay(cv, _infoQuickRoisCompareDiff);
25051
+ }
24420
25052
  function _drawAllRois(previewRoi) {
24421
25053
  if (!roiOverlayCanvas || !roiOverlayCtx) return;
24422
25054
  _syncRoiOverlay();
24423
25055
  const { sx, sy, dW, dH } = _roiDispScale();
24424
25056
  roiOverlayCtx.clearRect(0, 0, dW, dH);
24425
25057
  const hasNormalRoi = _rois.some(r => r && r.visible !== false && !_roiIsMvPaneRoi(r));
24426
- if (!_roiVisible || (!hasNormalRoi && !previewRoi) || _roiIsMvPaneRoi(previewRoi)) { roiOverlayCanvas.style.display = 'none'; return; }
25058
+ // The hover-info quick-measure outlines live outside _rois[]/
25059
+ // _roiVisible entirely, so they survive redraws (resize, mode
25060
+ // switches) that call this with no explicit previewRoi — see
25061
+ // _infoQuickRois. infoPreview is the one still-being-dragged
25062
+ // shape (not yet committed/appended); realPreview is the real
25063
+ // ROI tool's own in-progress preview, mutually exclusive with it.
25064
+ const infoPreview = previewRoi && previewRoi._infoQuick ? previewRoi : null;
25065
+ const realPreview = previewRoi && !previewRoi._infoQuick ? previewRoi : null;
25066
+ const hasQuick = !!infoPreview || _infoQuickRois.length > 0;
25067
+ if (!_roiVisible && !hasQuick) { roiOverlayCanvas.style.display = 'none'; return; }
25068
+ if (_roiVisible && !hasQuick && (!hasNormalRoi && !realPreview)) { roiOverlayCanvas.style.display = 'none'; return; }
25069
+ if (_roiIsMvPaneRoi(realPreview)) { roiOverlayCanvas.style.display = 'none'; return; }
24427
25070
  roiOverlayCanvas.style.display = '';
24428
- const replaceIdx = previewRoi && Number.isInteger(previewRoi.replaceIdx) ? previewRoi.replaceIdx : -1;
24429
- for (let i = 0; i < _rois.length; i++) {
24430
- const r = _rois[i];
24431
- if (i === replaceIdx) continue;
24432
- if (_roiIsMvPaneRoi(r)) continue;
24433
- if (!_roiIsVisibleOnCurrentSlice(r)) continue;
24434
- const c = _roiColors[i % _roiColors.length];
24435
- // Floodfill masks are in data space; flip the drawing context
24436
- // to display space so the mask aligns with the flipped image.
24437
- const ffDs = r.type === 'floodfill' && r.mask_b64;
24438
- roiOverlayCtx.save();
24439
- if (ffDs && flip_x) { roiOverlayCtx.translate(dW, 0); roiOverlayCtx.scale(-1, 1); }
24440
- if (ffDs && flip_y) { roiOverlayCtx.translate(0, dH); roiOverlayCtx.scale(1, -1); }
24441
- _roiDrawVisibleShape(roiOverlayCtx, r, sx, sy, c, i === _selectedRoiIdx);
24442
- if (i === _selectedRoiIdx) _roiDrawHandles(roiOverlayCtx, r, sx, sy);
24443
- roiOverlayCtx.restore();
24444
- const lbl = _roiCanvasLabel(i);
24445
- let [lx, ly] = _roiLabelPoint(r, sx, sy);
24446
- if (ffDs) {
24447
- if (flip_x) lx = dW - lx;
24448
- if (flip_y) ly = dH - ly;
24449
- }
24450
- _roiDrawLabel(roiOverlayCtx, lbl, lx, ly, c.stroke);
24451
- }
24452
- // Draw in-progress ROI preview (same border treatment as committed ROIs)
24453
- if (previewRoi) {
25071
+ const replaceIdx = realPreview && Number.isInteger(realPreview.replaceIdx) ? realPreview.replaceIdx : -1;
25072
+ if (_roiVisible) {
25073
+ for (let i = 0; i < _rois.length; i++) {
25074
+ const r = _rois[i];
25075
+ if (i === replaceIdx) continue;
25076
+ if (_roiIsMvPaneRoi(r)) continue;
25077
+ if (!_roiIsVisibleOnCurrentSlice(r)) continue;
25078
+ const c = _roiColors[i % _roiColors.length];
25079
+ // Floodfill masks are in data space; flip the drawing context
25080
+ // to display space so the mask aligns with the flipped image.
25081
+ const ffDs = r.type === 'floodfill' && r.mask_b64;
25082
+ roiOverlayCtx.save();
25083
+ if (ffDs && flip_x) { roiOverlayCtx.translate(dW, 0); roiOverlayCtx.scale(-1, 1); }
25084
+ if (ffDs && flip_y) { roiOverlayCtx.translate(0, dH); roiOverlayCtx.scale(1, -1); }
25085
+ _roiDrawVisibleShape(roiOverlayCtx, r, sx, sy, c, i === _selectedRoiIdx);
25086
+ if (i === _selectedRoiIdx) _roiDrawHandles(roiOverlayCtx, r, sx, sy);
25087
+ roiOverlayCtx.restore();
25088
+ const lbl = _roiCanvasLabel(i);
25089
+ let [lx, ly] = _roiLabelPoint(r, sx, sy);
25090
+ if (ffDs) {
25091
+ if (flip_x) lx = dW - lx;
25092
+ if (flip_y) ly = dH - ly;
25093
+ }
25094
+ _roiDrawLabel(roiOverlayCtx, lbl, lx, ly, c.stroke);
25095
+ }
25096
+ }
25097
+ // Draw in-progress real-ROI-tool preview (same border treatment as committed ROIs)
25098
+ if (realPreview) {
24454
25099
  const colorIdx = replaceIdx >= 0 ? replaceIdx : _rois.length;
24455
25100
  const c = _roiColors[colorIdx % _roiColors.length];
24456
- _roiDrawVisibleShape(roiOverlayCtx, previewRoi, sx, sy, c, replaceIdx === _selectedRoiIdx);
24457
- if (replaceIdx === _selectedRoiIdx) _roiDrawHandles(roiOverlayCtx, previewRoi, sx, sy);
25101
+ _roiDrawVisibleShape(roiOverlayCtx, realPreview, sx, sy, c, replaceIdx === _selectedRoiIdx);
25102
+ if (replaceIdx === _selectedRoiIdx) _roiDrawHandles(roiOverlayCtx, realPreview, sx, sy);
25103
+ }
25104
+ // Still-dragging quick-measure outline (no stats/pill yet).
25105
+ if (infoPreview) {
25106
+ _roiDrawVisibleShape(roiOverlayCtx, infoPreview, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25107
+ }
25108
+ // Every committed quick-measure shape, each with its own pill.
25109
+ for (const q of _infoQuickRois) {
25110
+ _roiDrawVisibleShape(roiOverlayCtx, q, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25111
+ const a = _infoQuickPillAnchor(q, sx, sy);
25112
+ _infoQuickDrawPill(roiOverlayCtx, q.statsText, a.cx, a.y0, a.y1);
24458
25113
  }
24459
25114
  }
24460
25115
 
@@ -24497,45 +25152,54 @@
24497
25152
  // (rect/circle/freehand drawn on one pane) and only show on the
24498
25153
  // pane whose dims match.
24499
25154
  const preview3d = previewRoi && previewRoi.type === 'floodfill' && previewRoi.mask3d_b64 ? previewRoi : null;
24500
- const previewForView = preview3d ? preview3d : (_roiMatchesMvView(previewRoi, view) ? previewRoi : null);
25155
+ const infoPreview = previewRoi && previewRoi._infoQuick && _roiMatchesMvView(previewRoi, view) ? previewRoi : null;
25156
+ const realPreview = previewRoi && !previewRoi._infoQuick ? previewRoi : null;
25157
+ const previewForView = preview3d ? preview3d : (_roiMatchesMvView(realPreview, view) ? realPreview : null);
24501
25158
  const hasRoi = _rois.some(r => r && r.visible !== false && (
24502
25159
  (r.type === 'floodfill' && r.mask3d_b64) || _roiMatchesMvView(r, view)
24503
25160
  ));
24504
- if (!_roiVisible || (!hasRoi && !previewForView)) { ov.style.display = 'none'; continue; }
25161
+ // See _drawAllRois: quick-measure outlines bypass _rois[]/
25162
+ // _roiVisible so they survive redraws with no explicit previewRoi.
25163
+ const quickForView = _infoQuickRoisMv.filter(q => _roiMatchesMvView(q, view));
25164
+ const hasQuick = !!infoPreview || quickForView.length > 0;
25165
+ if (!_roiVisible && !hasQuick) { ov.style.display = 'none'; continue; }
25166
+ if (_roiVisible && !hasQuick && !hasRoi && !previewForView) { ov.style.display = 'none'; continue; }
24505
25167
  ov.style.display = 'block';
24506
25168
  const sx = dW / view.lastW, sy = dH / view.lastH;
24507
25169
  const vfx = !!flipDims[view.dimX], vfy = !!flipDims[view.dimY];
24508
25170
  const replaceIdx = previewForView && Number.isInteger(previewForView.replaceIdx) ? previewForView.replaceIdx : -1;
24509
- for (let i = 0; i < _rois.length; i++) {
24510
- const r = _rois[i];
24511
- if (i === replaceIdx) continue;
24512
- if (!r || r.visible === false) continue;
24513
- const is3d = r.type === 'floodfill' && r.mask3d_b64;
24514
- const is2dFf = r.type === 'floodfill' && r.mask_b64 && !is3d;
24515
- if (is3d) {
24516
- const slice = _roiFloodFillSliceForView(r, view);
24517
- if (!slice) continue;
24518
- r._paneSlice = slice;
24519
- } else if (!_roiMatchesMvView(r, view)) {
24520
- continue;
24521
- } else if (r.type === 'floodfill' && !_roiFloodFillActiveSlice(r)) {
24522
- continue;
24523
- }
24524
- const c = _roiColors[i % _roiColors.length];
24525
- const ffDs = is3d || is2dFf;
24526
- rctx.save();
24527
- if (ffDs && vfx) { rctx.translate(dW, 0); rctx.scale(-1, 1); }
24528
- if (ffDs && vfy) { rctx.translate(0, dH); rctx.scale(1, -1); }
24529
- _roiDrawVisibleShape(rctx, r, sx, sy, c, i === _selectedRoiIdx);
24530
- rctx.restore();
24531
- r._paneSlice = null;
24532
- const lbl = _roiCanvasLabel(i);
24533
- let [lx, ly] = _roiLabelPoint(r, sx, sy);
24534
- if (ffDs) {
24535
- if (vfx) lx = dW - lx;
24536
- if (vfy) ly = dH - ly;
25171
+ if (_roiVisible) {
25172
+ for (let i = 0; i < _rois.length; i++) {
25173
+ const r = _rois[i];
25174
+ if (i === replaceIdx) continue;
25175
+ if (!r || r.visible === false) continue;
25176
+ const is3d = r.type === 'floodfill' && r.mask3d_b64;
25177
+ const is2dFf = r.type === 'floodfill' && r.mask_b64 && !is3d;
25178
+ if (is3d) {
25179
+ const slice = _roiFloodFillSliceForView(r, view);
25180
+ if (!slice) continue;
25181
+ r._paneSlice = slice;
25182
+ } else if (!_roiMatchesMvView(r, view)) {
25183
+ continue;
25184
+ } else if (r.type === 'floodfill' && !_roiFloodFillActiveSlice(r)) {
25185
+ continue;
25186
+ }
25187
+ const c = _roiColors[i % _roiColors.length];
25188
+ const ffDs = is3d || is2dFf;
25189
+ rctx.save();
25190
+ if (ffDs && vfx) { rctx.translate(dW, 0); rctx.scale(-1, 1); }
25191
+ if (ffDs && vfy) { rctx.translate(0, dH); rctx.scale(1, -1); }
25192
+ _roiDrawVisibleShape(rctx, r, sx, sy, c, i === _selectedRoiIdx);
25193
+ rctx.restore();
25194
+ r._paneSlice = null;
25195
+ const lbl = _roiCanvasLabel(i);
25196
+ let [lx, ly] = _roiLabelPoint(r, sx, sy);
25197
+ if (ffDs) {
25198
+ if (vfx) lx = dW - lx;
25199
+ if (vfy) ly = dH - ly;
25200
+ }
25201
+ _roiDrawLabel(rctx, lbl, lx, ly, c.stroke);
24537
25202
  }
24538
- _roiDrawLabel(rctx, lbl, lx, ly, c.stroke);
24539
25203
  }
24540
25204
  if (previewForView) {
24541
25205
  const is3dPreview = previewForView.mask3d_b64;
@@ -24549,6 +25213,16 @@
24549
25213
  _roiDrawVisibleShape(rctx, previewForView, sx, sy, c, replaceIdx === _selectedRoiIdx);
24550
25214
  previewForView._paneSlice = null;
24551
25215
  }
25216
+ // Still-dragging quick-measure outline for this pane (no pill yet).
25217
+ if (infoPreview) {
25218
+ _roiDrawVisibleShape(rctx, infoPreview, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25219
+ }
25220
+ // Every committed quick-measure shape tagged to this pane, each with its own pill.
25221
+ for (const q of quickForView) {
25222
+ _roiDrawVisibleShape(rctx, q, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25223
+ const a = _infoQuickPillAnchor(q, sx, sy);
25224
+ _infoQuickDrawPill(rctx, q.statsText, a.cx, a.y0, a.y1);
25225
+ }
24552
25226
  }
24553
25227
  }
24554
25228
  function _clearMvRoiOverlays() {
@@ -24565,7 +25239,8 @@
24565
25239
  if (!view.roiOverlay && view._cvWrap) {
24566
25240
  const ov = document.createElement('canvas');
24567
25241
  ov.className = 'qv-roi-overlay';
24568
- view._cvWrap.appendChild(ov);
25242
+ const clip = view.canvas.closest('.qv-canvas-clip') || view._cvWrap;
25243
+ clip.appendChild(ov);
24569
25244
  view.roiOverlay = ov;
24570
25245
  view.roiCtx = ov.getContext('2d');
24571
25246
  }
@@ -24583,23 +25258,44 @@
24583
25258
  rctx.setTransform(dpr, 0, 0, dpr, 0, 0);
24584
25259
  rctx.clearRect(0, 0, dW, dH);
24585
25260
  const hasQvRoi = _rois.some(r => r && r.visible !== false && !_roiIsMvPaneRoi(r));
24586
- if (!_roiVisible || (!hasQvRoi && !previewRoi) || _roiIsMvPaneRoi(previewRoi)) { ov.style.display = 'none'; continue; }
25261
+ // See _drawAllRois: quick-measure outlines bypass _rois[]/
25262
+ // _roiVisible so they survive redraws with no explicit previewRoi.
25263
+ const infoPreview = previewRoi && previewRoi._infoQuick ? previewRoi : null;
25264
+ const realPreview = previewRoi && !previewRoi._infoQuick ? previewRoi : null;
25265
+ const hasQuick = !!infoPreview || _infoQuickRoisQv.length > 0;
25266
+ if (!_roiVisible && !hasQuick) { ov.style.display = 'none'; continue; }
25267
+ if (_roiVisible && !hasQuick && (!hasQvRoi && !realPreview)) { ov.style.display = 'none'; continue; }
25268
+ if (_roiIsMvPaneRoi(realPreview)) { ov.style.display = 'none'; continue; }
24587
25269
  ov.style.display = 'block';
24588
25270
  const sx = dW / view.lastW, sy = dH / view.lastH;
24589
- for (let i = 0; i < _rois.length; i++) {
24590
- const r = _rois[i];
24591
- if (_roiIsMvPaneRoi(r)) continue;
24592
- if (!_roiIsVisibleOnCurrentSlice(r)) continue;
24593
- const c = _roiColors[i % _roiColors.length];
24594
- _roiDrawVisibleShape(rctx, r, sx, sy, c, i === _selectedRoiIdx);
24595
- if (i === _selectedRoiIdx) _roiDrawHandles(rctx, r, sx, sy);
24596
- const lbl = _roiCanvasLabel(i);
24597
- const [lx, ly] = _roiLabelPoint(r, sx, sy);
24598
- _roiDrawLabel(rctx, lbl, lx, ly, c.stroke);
24599
- }
24600
- if (previewRoi) {
25271
+ if (_roiVisible) {
25272
+ for (let i = 0; i < _rois.length; i++) {
25273
+ const r = _rois[i];
25274
+ if (_roiIsMvPaneRoi(r)) continue;
25275
+ if (!_roiIsVisibleOnCurrentSlice(r)) continue;
25276
+ const c = _roiColors[i % _roiColors.length];
25277
+ _roiDrawVisibleShape(rctx, r, sx, sy, c, i === _selectedRoiIdx);
25278
+ if (i === _selectedRoiIdx) _roiDrawHandles(rctx, r, sx, sy);
25279
+ const lbl = _roiCanvasLabel(i);
25280
+ const [lx, ly] = _roiLabelPoint(r, sx, sy);
25281
+ _roiDrawLabel(rctx, lbl, lx, ly, c.stroke);
25282
+ }
25283
+ }
25284
+ if (realPreview) {
24601
25285
  const c = _roiColors[_rois.length % _roiColors.length];
24602
- _roiDrawVisibleShape(rctx, previewRoi, sx, sy, c, false);
25286
+ _roiDrawVisibleShape(rctx, realPreview, sx, sy, c, false);
25287
+ }
25288
+ // Still-dragging quick-measure outline (no pill yet).
25289
+ if (infoPreview) {
25290
+ _roiDrawVisibleShape(rctx, infoPreview, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25291
+ }
25292
+ // Every committed quick-measure shape — shared across every qMRI
25293
+ // pane, but each pane shows its own independently-fetched stat.
25294
+ for (const q of _infoQuickRoisQv) {
25295
+ _roiDrawVisibleShape(rctx, q, sx, sy, _INFO_QUICK_ROI_COLOR, false);
25296
+ const text = q.qvStatsByPaneIdx[view.qmriIdx] || null;
25297
+ const a = _infoQuickPillAnchor(q, sx, sy);
25298
+ _infoQuickDrawPill(rctx, text, a.cx, a.y0, a.y1);
24603
25299
  }
24604
25300
  }
24605
25301
  }
@@ -25262,6 +25958,19 @@
25262
25958
  });
25263
25959
  }
25264
25960
  canvas.addEventListener('mousemove', _updateMainPixelInfo);
25961
+ // Hover-info quick-measure: collect points while dragging and show a
25962
+ // live preview outline (same border treatment as the real ROI tool,
25963
+ // via _drawAllRois, but tagged _infoQuick so it never touches _rois[]).
25964
+ canvas.addEventListener('mousemove', e => {
25965
+ if (!_infoFreehandDragging) return;
25966
+ const rect = canvas.getBoundingClientRect();
25967
+ const x = (e.clientX - rect.left) * canvas.width / rect.width;
25968
+ const y = (e.clientY - rect.top) * canvas.height / rect.height;
25969
+ _infoFreehandPts.push([x, y]);
25970
+ if (_infoFreehandPts.length > 2) {
25971
+ _drawAllRois({ type: 'freehand', points: _infoFreehandPts, _infoQuick: true });
25972
+ }
25973
+ });
25265
25974
  _wireHoverInfoDblClick(canvas);
25266
25975
  canvas.addEventListener('mouseleave', () => {
25267
25976
  if (roiDragging) {
@@ -25284,6 +25993,35 @@
25284
25993
  }
25285
25994
  }
25286
25995
  });
25996
+ // Hover-info quick-measure: finish the drag. A trivial click (too few
25997
+ // points — same threshold as the real ROI tool's freehand minimum)
25998
+ // falls back to the plain instantaneous value the hold-readout above
25999
+ // already shows; a real drag gets a persistent outline + mean±std.
26000
+ // Guarded by _infoFreehandView being unset so this only finalizes a
26001
+ // main-canvas drag — mv/qv panes set it and finalize via their own
26002
+ // per-gesture window listener, which would otherwise lose the race
26003
+ // (document-level bubble listeners run before window-level ones).
26004
+ document.addEventListener('mouseup', () => {
26005
+ if (!_infoFreehandDragging || _infoFreehandView) return;
26006
+ _infoFreehandDragging = false;
26007
+ const pts = _infoFreehandPts.slice();
26008
+ _infoFreehandPts = [];
26009
+ if (pts.length < 5) { _drawAllRois(); return; }
26010
+ const entry = { type: 'freehand', points: pts, _infoQuick: true, statsText: null };
26011
+ _infoQuickRois.push(entry);
26012
+ _infoQuickRoiStack.push({
26013
+ remove: () => {
26014
+ const idx = _infoQuickRois.indexOf(entry);
26015
+ if (idx >= 0) _infoQuickRois.splice(idx, 1);
26016
+ _drawAllRois();
26017
+ },
26018
+ });
26019
+ _drawAllRois();
26020
+ _infoQuickFetchStats(pts).then(stats => {
26021
+ entry.statsText = stats ? _infoQuickStatsText(stats) : null;
26022
+ if (_infoQuickRois.includes(entry)) _drawAllRois();
26023
+ });
26024
+ });
25287
26025
 
25288
26026
  // Click-to-copy pixel value: shared helper
25289
26027
  function _fmtPixelForCopy(v) {
@@ -25958,12 +26696,22 @@
25958
26696
  title: 'Delete selected ROI',
25959
26697
  when: [],
25960
26698
  run: (ctx, e) => {
25961
- if (!rectRoiMode || _selectedRoiIdx < 0) return;
25962
- e.preventDefault();
25963
- const idx = _selectedRoiIdx;
25964
- _roiDelete(idx);
25965
- _hideRoiContextMenu();
25966
- showStatus(`ROI ${idx + 1} deleted`);
26699
+ if (rectRoiMode && _selectedRoiIdx >= 0) {
26700
+ e.preventDefault();
26701
+ const idx = _selectedRoiIdx;
26702
+ _roiDelete(idx);
26703
+ _hideRoiContextMenu();
26704
+ showStatus(`ROI ${idx + 1} deleted`);
26705
+ } else if (_pixelInfoVisible && _infoQuickRoiStack.length) {
26706
+ // Hover-info quick-measure shapes have no selection of
26707
+ // their own — Backspace/Delete just removes the single
26708
+ // most-recently-drawn one, across all 5 surfaces
26709
+ // combined (one shared creation-order stack; see
26710
+ // _infoQuickRoiStack).
26711
+ e.preventDefault();
26712
+ _infoQuickRoiStack.pop().remove();
26713
+ showStatus('quick-measure outline removed');
26714
+ }
25967
26715
  },
25968
26716
  },
25969
26717
  'compare.cycleTarget': {
@@ -26072,9 +26820,14 @@
26072
26820
  when: [],
26073
26821
  run: (ctx, e) => {
26074
26822
  _pixelInfoVisible = !_pixelInfoVisible;
26823
+ // Peek the tick row on every colorbar the instant this
26824
+ // toggles, rather than waiting for some unrelated
26825
+ // redraw — see _drawTicks.
26826
+ ColorBar.redrawAll();
26075
26827
  if (!_pixelInfoVisible) {
26076
26828
  _clearHoverPins();
26077
26829
  hideAllPixelInfo();
26830
+ _clearInfoQuickRoi();
26078
26831
  _dimbarFadeSwap(() => _resetDimbarHover());
26079
26832
  } else {
26080
26833
  // Pre-fetch raw slice(s) for instant local lookups
@@ -27317,6 +28070,14 @@
27317
28070
  }
27318
28071
  },
27319
28072
  },
28073
+ 'colorbar.cycleTicks': {
28074
+ title: 'Cycle colorbar tick marks',
28075
+ when: [],
28076
+ run: (ctx, e) => {
28077
+ e.preventDefault();
28078
+ _cycleCbTickLevel();
28079
+ },
28080
+ },
27320
28081
  'dimbar.toggleExtents': {
27321
28082
  title: 'Toggle dimbar max indices',
27322
28083
  when: [],
@@ -27408,11 +28169,15 @@
27408
28169
  }
27409
28170
  } else if (activeDim === dim_x) {
27410
28171
  flip_x = !flip_x;
27411
- if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
28172
+ if (qmriActive) qmriViews.forEach(v => qvDrawFrame(v));
28173
+ else if (compareQmriActive) compareQmriViews.forEach(v => { if (v.lastImageData) v.ctx.putImageData(applyFlips(v.lastImageData, v.lastW, v.lastH), 0, 0); });
28174
+ else if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
27412
28175
  renderInfo(); saveState();
27413
28176
  } else if (activeDim === dim_y) {
27414
28177
  flip_y = !flip_y;
27415
- if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
28178
+ if (qmriActive) qmriViews.forEach(v => qvDrawFrame(v));
28179
+ else if (compareQmriActive) compareQmriViews.forEach(v => { if (v.lastImageData) v.ctx.putImageData(applyFlips(v.lastImageData, v.lastW, v.lastH), 0, 0); });
28180
+ else if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
27416
28181
  renderInfo(); saveState();
27417
28182
  } else {
27418
28183
  lastDirection = 1;
@@ -27454,11 +28219,15 @@
27454
28219
  }
27455
28220
  } else if (activeDim === dim_x) {
27456
28221
  flip_x = !flip_x;
27457
- if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
28222
+ if (qmriActive) qmriViews.forEach(v => qvDrawFrame(v));
28223
+ else if (compareQmriActive) compareQmriViews.forEach(v => { if (v.lastImageData) v.ctx.putImageData(applyFlips(v.lastImageData, v.lastW, v.lastH), 0, 0); });
28224
+ else if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
27458
28225
  renderInfo(); saveState();
27459
28226
  } else if (activeDim === dim_y) {
27460
28227
  flip_y = !flip_y;
27461
- if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
28228
+ if (qmriActive) qmriViews.forEach(v => qvDrawFrame(v));
28229
+ else if (compareQmriActive) compareQmriViews.forEach(v => { if (v.lastImageData) v.ctx.putImageData(applyFlips(v.lastImageData, v.lastW, v.lastH), 0, 0); });
28230
+ else if (lastImageData) canvas.getContext('2d').putImageData(applyFlips(lastImageData, lastImgW, lastImgH), 0, 0);
27462
28231
  renderInfo(); saveState();
27463
28232
  } else {
27464
28233
  lastDirection = -1;
@@ -27746,6 +28515,7 @@
27746
28515
  { key: 'l', command: 'dim.next' },
27747
28516
  { key: 'ArrowRight', command: 'dim.next' },
27748
28517
  { key: 'D', shift: true, command: 'dimbar.toggleExtents' },
28518
+ { key: 'w', command: 'colorbar.cycleTicks' },
27749
28519
  { key: 'M', command: 'alpha.cycleLevel' },
27750
28520
  { key: 'L', command: 'logscale.toggle' },
27751
28521
  { key: 'p', command: 'projection.cycle' },
@@ -27843,7 +28613,7 @@
27843
28613
  {
27844
28614
  title: 'Info & Modes',
27845
28615
  entries: [
27846
- { key: 'i', action: 'Toggle hover pixel info', hint: 'or double-click a pane; shows value in the pane pill; hold Ctrl for the loupe; alt+click adds pins; Esc clears all pins' },
28616
+ { key: 'i', action: 'Toggle hover pixel info', hint: 'or double-click a pane; shows value in the pane pill; drag draws a quick outline (mean ± std), repeatable; Backspace removes the last one; hold Ctrl for the loupe; alt+click adds pins; Esc clears all pins' },
27847
28617
  { key: 'I', action: 'Info panel', hint: 'array shape, dtype, memory layout' },
27848
28618
  { key: '/', action: 'Tool launcher', hint: 'open the transient tool launcher; direct shortcuts still work' },
27849
28619
  ]
@@ -27894,6 +28664,7 @@
27894
28664
  { key: 'Drag bar', action: 'Shift the visible range up or down' },
27895
28665
  { key: 'Dbl-click', action: 'Expand histogram' },
27896
28666
  { key: 'Dbl-click label', action: 'Type an exact min or max value' },
28667
+ { key: 'w', action: 'Cycle tick marks', hint: 'off → few → many values between min and max' },
27897
28668
  ]
27898
28669
  },
27899
28670
  {
@@ -32510,6 +33281,57 @@
32510
33281
  }
32511
33282
  }
32512
33283
  }
33284
+ // Hover-info quick-measure: while hover info is on, a plain
33285
+ // click-drag always draws a disposable freehand outline on
33286
+ // this pane instead of navigating the crosshair — the same
33287
+ // trade-off ROI mode already makes for this gesture above.
33288
+ if (!rectRoiMode && _pixelInfoVisible && e.button === 0
33289
+ && !e.shiftKey && !e.metaKey && !e.altKey && !e.ctrlKey
33290
+ && !_cropActive && !_segMode && !_rulerMode) {
33291
+ e.preventDefault();
33292
+ e.stopImmediatePropagation();
33293
+ sink.focus();
33294
+ _infoFreehandDragging = true;
33295
+ _infoFreehandView = view;
33296
+ _infoFreehandPts = [[px, py]];
33297
+ const onInfoMove = (me) => {
33298
+ if (!_infoFreehandDragging || _infoFreehandView !== view) return;
33299
+ const mr = cv.getBoundingClientRect();
33300
+ const x = (me.clientX - mr.left) * view.lastW / mr.width;
33301
+ const y = (me.clientY - mr.top) * view.lastH / mr.height;
33302
+ _infoFreehandPts.push([x, y]);
33303
+ if (_infoFreehandPts.length > 2) {
33304
+ _drawAllMvRois({ type: 'freehand', points: _infoFreehandPts, mvDimX: view.dimX, mvDimY: view.dimY, _infoQuick: true });
33305
+ }
33306
+ };
33307
+ const onInfoUp = () => {
33308
+ window.removeEventListener('mousemove', onInfoMove);
33309
+ window.removeEventListener('mouseup', onInfoUp);
33310
+ if (!_infoFreehandDragging || _infoFreehandView !== view) return;
33311
+ _infoFreehandDragging = false;
33312
+ _infoFreehandView = null;
33313
+ const pts = _infoFreehandPts.slice();
33314
+ _infoFreehandPts = [];
33315
+ if (pts.length < 5) { _drawAllMvRois(); return; }
33316
+ const entry = { type: 'freehand', points: pts, mvDimX: view.dimX, mvDimY: view.dimY, _infoQuick: true, statsText: null };
33317
+ _infoQuickRoisMv.push(entry);
33318
+ _infoQuickRoiStack.push({
33319
+ remove: () => {
33320
+ const idx = _infoQuickRoisMv.indexOf(entry);
33321
+ if (idx >= 0) _infoQuickRoisMv.splice(idx, 1);
33322
+ _drawAllMvRois();
33323
+ },
33324
+ });
33325
+ _drawAllMvRois();
33326
+ _infoQuickFetchStats(pts).then(stats => {
33327
+ entry.statsText = stats ? _infoQuickStatsText(stats) : null;
33328
+ if (_infoQuickRoisMv.includes(entry)) _drawAllMvRois();
33329
+ });
33330
+ };
33331
+ window.addEventListener('mousemove', onInfoMove);
33332
+ window.addEventListener('mouseup', onInfoUp);
33333
+ return;
33334
+ }
32513
33335
  const hitJ = mvCrossHairHit(view, px, py);
32514
33336
  // Rotate only on explicit modifier gesture to avoid accidental
32515
33337
  // oblique jumps when the user simply drags the crosshair.
@@ -33763,6 +34585,8 @@
33763
34585
  if (multiViewActive) exitMultiView();
33764
34586
  if (stackMosaicActive) exitStackMosaic();
33765
34587
  _qmriPrevZoom = userZoom; // save prior zoom so exitQmri can restore (mirrors enterMultiView)
34588
+ userZoom = 1.0;
34589
+ qvPan.reset();
33766
34590
  qmriActive = true;
33767
34591
  qmriDim = targetDim;
33768
34592
  _ensureQmriActiveDimVisible();
@@ -33885,6 +34709,10 @@
33885
34709
  pane.addEventListener('mouseenter', () => { _hoveredQmriView = view; });
33886
34710
  pane.addEventListener('mouseleave', () => { if (_hoveredQmriView === view) _hoveredQmriView = null; });
33887
34711
  cv.addEventListener('wheel', (e) => {
34712
+ if ((e.metaKey || (e.ctrlKey && !_mainLoupeInspecting())) && !e.shiftKey && !e.altKey) {
34713
+ onSliceWheel(e);
34714
+ return;
34715
+ }
33888
34716
  e.preventDefault();
33889
34717
  const delta = _wheelIndexDelta(current_slice_dim, e);
33890
34718
  if (!delta) return;
@@ -33894,6 +34722,7 @@
33894
34722
  _debouncedHoverPrefetch();
33895
34723
  saveState();
33896
34724
  }, { passive: false });
34725
+ _attachPinchHandlers(cv);
33897
34726
  cv.addEventListener('mousemove', (e) => {
33898
34727
  _bcMaybePromotePending(e);
33899
34728
  if (!view.lastW || !view.lastH) return;
@@ -33969,13 +34798,77 @@
33969
34798
  }
33970
34799
  });
33971
34800
  cv.addEventListener('mousedown', (e) => {
34801
+ // Zoomed past the frame: plain left-drag pans, as in
34802
+ // normal mode, before any other left-click action.
34803
+ // Hover-info mode claims plain left-drag for the
34804
+ // quick-measure outline below, so it must not pan even
34805
+ // when the pane is zoomed past its frame.
34806
+ if (!rectRoiMode && !_pixelInfoVisible && e.button === 0 && !e.shiftKey && !e.metaKey && !e.altKey && !e.ctrlKey
34807
+ && !_cropActive && !_segMode && !_rulerMode
34808
+ && _usesQmriPaneZoom(cv) && qvPan.overflows) {
34809
+ e.preventDefault(); sink.focus();
34810
+ qvPan.startDrag(e);
34811
+ _reconcileQvZoomCursor();
34812
+ return;
34813
+ }
33972
34814
  if (!rectRoiMode && e.button === 0 && !e.shiftKey && !e.metaKey && !e.altKey
33973
34815
  && !_cropActive && !_segMode && !_rulerMode) {
33974
- if (!e.ctrlKey && _bcCanDrag()) _qmriArmWindowLevel(view, e);
34816
+ // Hover-info mode claims plain left-drag for the
34817
+ // quick-measure outline below, so it must not also
34818
+ // arm the window/level triangle (same rule normal
34819
+ // mode already applies to its own drag arm).
34820
+ if (!e.ctrlKey && !_pixelInfoVisible && _bcCanDrag()) _qmriArmWindowLevel(view, e);
33975
34821
  else {
33976
34822
  _pixelInfoHeld = true;
33977
34823
  cv.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY, buttons: 1, ctrlKey: e.ctrlKey, bubbles: true }));
33978
34824
  }
34825
+ // Hover-info quick-measure: a plain click-drag while
34826
+ // hover info is on always draws a disposable freehand
34827
+ // outline on this pane (shown on every qMRI pane via
34828
+ // _drawAllQvRois, stats fetched per-pane since each
34829
+ // pane is a different quantitative map).
34830
+ if (_pixelInfoVisible && !e.ctrlKey) {
34831
+ const qRect = cv.getBoundingClientRect();
34832
+ _infoFreehandDragging = true;
34833
+ _infoFreehandView = view;
34834
+ _infoFreehandPts = [[
34835
+ (e.clientX - qRect.left) * view.lastW / qRect.width,
34836
+ (e.clientY - qRect.top) * view.lastH / qRect.height,
34837
+ ]];
34838
+ const onInfoMove = (me) => {
34839
+ if (!_infoFreehandDragging || _infoFreehandView !== view) return;
34840
+ const mr = cv.getBoundingClientRect();
34841
+ const x = (me.clientX - mr.left) * view.lastW / mr.width;
34842
+ const y = (me.clientY - mr.top) * view.lastH / mr.height;
34843
+ _infoFreehandPts.push([x, y]);
34844
+ if (_infoFreehandPts.length > 2) {
34845
+ _drawAllQvRois({ type: 'freehand', points: _infoFreehandPts, _infoQuick: true });
34846
+ }
34847
+ };
34848
+ const onInfoUp = () => {
34849
+ window.removeEventListener('mousemove', onInfoMove);
34850
+ window.removeEventListener('mouseup', onInfoUp);
34851
+ if (!_infoFreehandDragging || _infoFreehandView !== view) return;
34852
+ _infoFreehandDragging = false;
34853
+ _infoFreehandView = null;
34854
+ const pts = _infoFreehandPts.slice();
34855
+ _infoFreehandPts = [];
34856
+ if (pts.length < 5) { _drawAllQvRois(); return; }
34857
+ const entry = { type: 'freehand', points: pts, _infoQuick: true, qvStatsByPaneIdx: {} };
34858
+ _infoQuickRoisQv.push(entry);
34859
+ _infoQuickRoiStack.push({
34860
+ remove: () => {
34861
+ const idx = _infoQuickRoisQv.indexOf(entry);
34862
+ if (idx >= 0) _infoQuickRoisQv.splice(idx, 1);
34863
+ _drawAllQvRois();
34864
+ },
34865
+ });
34866
+ _drawAllQvRois();
34867
+ _infoQuickFetchQvStats(entry);
34868
+ };
34869
+ window.addEventListener('mousemove', onInfoMove);
34870
+ window.addEventListener('mouseup', onInfoUp);
34871
+ }
33979
34872
  }
33980
34873
  if (!rectRoiMode || e.button !== 0 || e.shiftKey || e.metaKey) return;
33981
34874
  e.preventDefault(); sink.focus();
@@ -34122,6 +35015,10 @@
34122
35015
  pane.addEventListener('mouseenter', () => { _hoveredQmriView = view; });
34123
35016
  pane.addEventListener('mouseleave', () => { if (_hoveredQmriView === view) _hoveredQmriView = null; });
34124
35017
  cv.addEventListener('wheel', (e) => {
35018
+ if ((e.metaKey || (e.ctrlKey && !_mainLoupeInspecting())) && !e.shiftKey && !e.altKey) {
35019
+ onSliceWheel(e);
35020
+ return;
35021
+ }
34125
35022
  e.preventDefault();
34126
35023
  const delta = _wheelIndexDelta(current_slice_dim, e);
34127
35024
  if (!delta) return;
@@ -34130,6 +35027,7 @@
34130
35027
  qmriViews.forEach(v => qvRender(v));
34131
35028
  saveState();
34132
35029
  }, { passive: false });
35030
+ _attachPinchHandlers(cv);
34133
35031
  if (!httpTransport) {
34134
35032
  view.ws = createTransport(sid,
34135
35033
  (event) => qvOnMessage(view, event),
@@ -34196,6 +35094,7 @@
34196
35094
  qmriMosaicActive = false;
34197
35095
  _qmriMosaicZDim = -1;
34198
35096
  _mosaicResetPan();
35097
+ qvPan.reset();
34199
35098
  qmriViews.forEach(v => { if (v.ws) { v.ws.onclose = null; v.ws.close(); } if (v._colorBar) { v._colorBar.destroy(); v._colorBar = null; } if (v._mosaicColorBar) { v._mosaicColorBar.destroy(); v._mosaicColorBar = null; } });
34200
35099
  const _hadRois = rectRoiMode && _rois.length;
34201
35100
  qmriViews = [];
@@ -34369,6 +35268,7 @@
34369
35268
  stackMosaicViews.forEach(v2 => smRender(v2));
34370
35269
  saveState();
34371
35270
  }, { passive: false });
35271
+ _attachPinchHandlers(cv);
34372
35272
  if (!httpTransport) {
34373
35273
  view.ws = createTransport(sid,
34374
35274
  (event) => smOnMessage(view, event),
@@ -34921,6 +35821,7 @@
34921
35821
  _debouncedHoverPrefetch();
34922
35822
  saveState();
34923
35823
  }, { passive: false });
35824
+ _attachPinchHandlers(cv);
34924
35825
  cv.addEventListener('mousemove', (e) => {
34925
35826
  _bcMaybePromotePending(e);
34926
35827
  if (!view.lastW || !view.lastH) return;
@@ -35267,11 +36168,21 @@
35267
36168
  compareMvRender(v);
35268
36169
  }
35269
36170
 
36171
+ function _qmriMvRenderPaneOnly(v) {
36172
+ v.seq++;
36173
+ v.lastImageData = null;
36174
+ v.lastW = 0; v.lastH = 0;
36175
+ v.rendering = false; v.pending = false;
36176
+ qmriMvDrawFrame(v);
36177
+ qmriMvRender(v);
36178
+ }
36179
+
35270
36180
  // Dispatcher used by the transpose/rotate keyboard commands so they work
35271
- // identically whether ortho is showing one array (mvViews) or several
35272
- // (compareMvViews). Compare mode applies the change to the whole column
35273
- // (that plane, across every compared array) rather than just the hovered
35274
- // pane, since compare mode's point is viewing co-registered arrays from
36181
+ // identically whether ortho is showing one array (mvViews), several
36182
+ // (compareMvViews), or qMRI's own per-map 3-plane view (qmriMvViews).
36183
+ // Compare mode applies the change to the whole column (that plane,
36184
+ // across every compared array) rather than just the hovered pane,
36185
+ // since compare mode's point is viewing co-registered arrays from
35275
36186
  // the same perspective — matching how wheel-scroll already keeps rows
35276
36187
  // in sync (see compareMv pane 'wheel' listener).
35277
36188
  function _orthoTransposeTargets() {
@@ -35286,6 +36197,13 @@
35286
36197
  const views = compareMvViews.filter((_, idx) => idx % 3 === pi);
35287
36198
  return { views, renderOne: _cmvRenderPaneOnly };
35288
36199
  }
36200
+ if (qmriMvActive) {
36201
+ const v = _orthoCurrentPane(qmriMvViews, _hoveredQmriMvView);
36202
+ if (!v) return null;
36203
+ const pi = qmriMvViews.indexOf(v) % 3;
36204
+ const views = qmriMvViews.filter((_, idx) => idx % 3 === pi);
36205
+ return { views, renderOne: _qmriMvRenderPaneOnly };
36206
+ }
35289
36207
  return null;
35290
36208
  }
35291
36209
 
@@ -35402,6 +36320,7 @@
35402
36320
  _debouncedHoverPrefetch();
35403
36321
  saveState();
35404
36322
  }, { passive: false });
36323
+ _attachPinchHandlers(cv);
35405
36324
  cv.addEventListener('mousedown', (e) => {
35406
36325
  if (e.button !== 0) return;
35407
36326
  e.preventDefault();
@@ -35961,11 +36880,13 @@
35961
36880
  const delta = _wheelIndexDelta(view.sliceDir, e);
35962
36881
  if (!delta) return;
35963
36882
  indices[view.sliceDir] = Math.max(0, Math.min(shape[view.sliceDir] - 1, indices[view.sliceDir] + delta));
36883
+ _qmvScrollHint(view);
35964
36884
  renderInfo();
35965
36885
  qmriMvViews.forEach(v => qmriMvRender(v));
35966
36886
  _debouncedHoverPrefetch();
35967
36887
  saveState();
35968
36888
  }, { passive: false });
36889
+ _attachPinchHandlers(cv);
35969
36890
  cv.addEventListener('mousedown', (e) => {
35970
36891
  if (e.button !== 0) return;
35971
36892
  e.preventDefault();
@@ -35973,12 +36894,14 @@
35973
36894
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return;
35974
36895
  sink.focus();
35975
36896
  _qmriMvDragView = view;
36897
+ _qmvRecomputeCrosshairTarget();
35976
36898
  handleQmriMvPointer(view, e);
35977
36899
  const onMove = (me) => { if (_qmriMvDragView === view) handleQmriMvPointer(view, me); };
35978
36900
  const onUp = () => {
35979
36901
  window.removeEventListener('mousemove', onMove);
35980
36902
  window.removeEventListener('mouseup', onUp);
35981
36903
  _qmriMvDragView = null;
36904
+ _qmvRecomputeCrosshairTarget();
35982
36905
  };
35983
36906
  window.addEventListener('mousemove', onMove);
35984
36907
  window.addEventListener('mouseup', onUp);
@@ -36121,6 +37044,11 @@
36121
37044
  qmriMvActive = false;
36122
37045
  _qmriMvDragView = null;
36123
37046
  _hoveredQmriMvView = null;
37047
+ if (_qmvCrosshairAnim) { cancelAnimationFrame(_qmvCrosshairAnim); _qmvCrosshairAnim = null; }
37048
+ if (_qmvScrollHintTimer) { clearTimeout(_qmvScrollHintTimer); _qmvScrollHintTimer = null; }
37049
+ _qmvCrosshairAlpha = 0; _qmvCrosshairTarget = 0; _qmvScrollHintDir = -1;
37050
+ _reconcileOrthoCursorHidden();
37051
+ _reconcileMvOrientationVisibility();
36124
37052
  qmriMvViews.forEach(v => { if (v.ws) { v.ws.onclose = null; v.ws.close(); } });
36125
37053
  qmriMvViews = [];
36126
37054
  qmriMvDims = [];
@@ -36286,8 +37214,39 @@
36286
37214
  }
36287
37215
  v.ctx.putImageData(applyMvFlips(v, v.lastImageData, v.lastW, v.lastH), 0, 0);
36288
37216
  _patientFrameDisplayed(v._renderPatientIndex);
37217
+ _qmvDrawCrosshair(v);
37218
+ drawMvOrientation();
36289
37219
  _refreshLoupeFromLastMouse();
36290
37220
  }
37221
+ /** Position lines on the pane's overlay, like compareMvDrawFrame. */
37222
+ function _qmvDrawCrosshair(v) {
37223
+ const alpha = (_qmvScrollHintTimer && v.sliceDir === _qmvScrollHintDir) ? 0 : _qmvCrosshairAlpha;
37224
+ const ov = _getMvCrosshairOverlay(v);
37225
+ if (!ov) return;
37226
+ const metrics = _getMvCrosshairMetrics(v);
37227
+ if (!metrics) return;
37228
+ const { paneW, paneH, offsetX, offsetY, scaleX, scaleY } = metrics;
37229
+ const dpr = window.devicePixelRatio || 1;
37230
+ const ovW = Math.round(paneW * dpr), ovH = Math.round(paneH * dpr);
37231
+ if (ov.width !== ovW || ov.height !== ovH) {
37232
+ ov.width = ovW; ov.height = ovH;
37233
+ ov.style.width = paneW + 'px';
37234
+ ov.style.height = paneH + 'px';
37235
+ }
37236
+ const c = ov.getContext('2d');
37237
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
37238
+ c.clearRect(0, 0, paneW, paneH);
37239
+ if (alpha <= 0.005) return;
37240
+ c.globalAlpha = alpha;
37241
+ const fx = _mvFlipX(v), fy = _mvFlipY(v);
37242
+ const cx = fx ? (v.lastW - indices[v.dimX] - 0.5) : (indices[v.dimX] + 0.5);
37243
+ const cy = fy ? (v.lastH - indices[v.dimY] - 0.5) : (indices[v.dimY] + 0.5);
37244
+ const cssCx = offsetX + cx * scaleX, cssCy = offsetY + cy * scaleY;
37245
+ c.lineWidth = 1;
37246
+ c.strokeStyle = _mvCrosshairStroke();
37247
+ c.beginPath(); c.moveTo(cssCx, 0); c.lineTo(cssCx, paneH); c.stroke();
37248
+ c.beginPath(); c.moveTo(0, cssCy); c.lineTo(paneW, cssCy); c.stroke();
37249
+ }
36291
37250
 
36292
37251
  function qmriMvScaleAllCanvases() {
36293
37252
  const ready = qmriMvViews.filter(v => v.lastW && v.lastH);
@@ -36613,6 +37572,7 @@
36613
37572
  const n = ready.length;
36614
37573
 
36615
37574
  if (qmriMosaicActive) {
37575
+ qvPan.reset();
36616
37576
  // Mosaic mode: 1 row per parameter, each canvas is a wide horizontal strip.
36617
37577
  // Enforce minimum tile size — if tiles would be too small to read,
36618
37578
  // allow content to overflow the viewport and enable pan/drag.
@@ -36621,6 +37581,15 @@
36621
37581
  const vcbW = anyMosaicExpanded ? 30 : 8;
36622
37582
  const labelW = 28 + 8; // label width + margin
36623
37583
  const vcbTotalW = vcbW + 4 + 30; // vcb + margin + labels
37584
+ // .cb-col defaults to a 250px width sized for horizontal colorbars;
37585
+ // pin it to the actual vertical bar width so the mosaic row's
37586
+ // reserved footprint doesn't stay oversized once histogram mode
37587
+ // (the expand animation) grows the canvas past its collapsed size.
37588
+ for (const v of ready) {
37589
+ if (v._mosaicColorBar && v._mosaicColorBar.colWrap) {
37590
+ v._mosaicColorBar.colWrap.style.width = vcbW + 'px';
37591
+ }
37592
+ }
36624
37593
  const bottomPad = 24;
36625
37594
  const totalH = window.innerHeight - uiReserveV()
36626
37595
  - (n - 1) * rowGap - bottomPad * (1 - _crossfadeCollapseP);
@@ -36701,40 +37670,37 @@
36701
37670
  }));
36702
37671
  const absMaxScale = Math.min(...layoutSizes.map(({ w, h }) =>
36703
37672
  Math.min(absMaxScaleW / w, absMaxScaleH / h)));
36704
- const { capped: cappedScale, atMax } = _clampUniformZoom(absMaxScale);
36705
- // Per-pane geometry routed through the shared _scalePane primitive.
36706
- // qMRI non-mosaic has no per-pane pan or viewport clipping: each canvas
36707
- // is sized directly to fit. userZoom clamping + auto-fit are handled by
36708
- // _clampUniformZoom above, so updateAutoFit / allowOverflowRecalc are
36709
- // both false. baseScale is cappedScale/userZoom so _scalePane's
36710
- // finalScale = baseScale*userZoom reproduces cappedScale exactly.
36711
- // squareStretch is hardcoded false — qMRI panes are not square-stretched
36712
- // regardless of the global toggle. viewport:null and a stub pan skip the
36713
- // clip/pan machinery entirely.
36714
- const _qvPanStub = { x: 0, y: 0, clamp: () => {}, apply: () => {} };
36715
- const qvBaseScale = cappedScale / Math.max(0.0001, userZoom);
37673
+ // Keep the fitted pane frames stationary while the images zoom and
37674
+ // pan together behind them. This matches normal-mode zoom without
37675
+ // turning the qMRI grid itself into one large scrollable viewport.
37676
+ const qvBaseScale = absMaxScale;
36716
37677
  for (const { v, w, h } of layoutSizes) {
37678
+ const clip = v.canvas.closest('.qv-canvas-clip');
37679
+ const fittedViewport = _paneViewportAtBaseZoom(
37680
+ w, h, qvBaseScale, absMaxScaleW, absMaxScaleH, false,
37681
+ );
36717
37682
  _scalePane(
36718
- { canvas: v.canvas, w, h, pan: _qvPanStub },
36719
- null,
37683
+ { canvas: v.canvas, w, h, pan: qvPan },
37684
+ clip,
36720
37685
  {
36721
- maxW: Math.round(w * cappedScale),
36722
- maxH: Math.round(h * cappedScale),
37686
+ maxW: absMaxScaleW,
37687
+ maxH: absMaxScaleH,
36723
37688
  baseScale: qvBaseScale,
36724
37689
  squareStretch: false,
36725
37690
  updateAutoFit: false,
36726
37691
  allowOverflowRecalc: false,
37692
+ forceViewport: fittedViewport,
36727
37693
  }
36728
37694
  );
36729
37695
  // Keep colorbar island width clamped to target
36730
37696
  if (v._colorBar && v._colorBar.wrap) {
36731
- const qvPaneW = parseInt(v.canvas.style.width) || 0;
37697
+ const qvPaneW = fittedViewport.w;
36732
37698
  const qvCbW = cbTargetWidth(qvPaneW);
36733
37699
  v._colorBar.wrap.style.display = qvCbW == null ? 'none' : '';
36734
37700
  if (qvCbW != null) v._colorBar.wrap.style.width = qvCbW + 'px';
36735
37701
  }
36736
37702
  if (v.syntheticId && v.paramControls) {
36737
- const qvPaneW = parseInt(v.canvas.style.width) || 0;
37703
+ const qvPaneW = fittedViewport.w;
36738
37704
  const qvControlW = cbTargetWidth(qvPaneW);
36739
37705
  v.paramControls.style.display = qvControlW == null ? 'none' : 'grid';
36740
37706
  if (qvControlW != null) v.paramControls.style.width = qvControlW + 'px';
@@ -36743,20 +37709,24 @@
36743
37709
  // Size all invisible placeholders to a real pane width so partial
36744
37710
  // rows preserve the same left-aligned column grid as the rows above.
36745
37711
  if (ready.length > 0) {
36746
- const placeholderWidth = ready[0].canvas.style.width;
37712
+ const placeholderWidth = ready[0].canvas.closest('.qv-canvas-clip')?.style.width
37713
+ || ready[0].canvas.style.width;
36747
37714
  document.querySelectorAll('#qmri-view-wrap .qv-placeholder').forEach(ph => {
36748
37715
  ph.style.width = placeholderWidth;
36749
37716
  ph.style.height = '1px';
36750
37717
  });
36751
37718
  }
36752
37719
  fixWrapperAlignment();
37720
+ if (rectRoiMode) _drawAllQvRois();
37721
+ _reconcileQvZoomCursor();
37722
+ updateMiniMap();
36753
37723
  requestAnimationFrame(() => {
36754
37724
  if (qmriActive) {
36755
37725
  qmriViews.forEach(v2 => { if (v2._colorBar) { v2._colorBar.draw(); v2._colorBar.updateLabels(); } });
36756
37726
  positionEggs();
36757
37727
  }
36758
37728
  });
36759
- return atMax ? 'max' : null;
37729
+ return null;
36760
37730
  }
36761
37731
 
36762
37732
  function qvOnMessage(v, event) {
@@ -37026,7 +37996,9 @@
37026
37996
 
37027
37997
  function drawMvOrientation() {
37028
37998
  const cv = document.getElementById('mv-orientation');
37029
- const _oriDims = multiViewActive ? mvDims : (compareMvActive ? compareMvDims : []);
37999
+ const _oriDims = multiViewActive ? mvDims
38000
+ : compareMvActive ? compareMvDims
38001
+ : qmriMvActive ? qmriMvDims : [];
37030
38002
  if (!cv || _oriDims.length < 3) return;
37031
38003
  const dpr = window.devicePixelRatio || 1;
37032
38004
  const cssSize = 120;
@@ -37223,7 +38195,8 @@
37223
38195
  const mvOrientation = document.getElementById('mv-orientation');
37224
38196
  if (!mvOrientation) return;
37225
38197
  const show = !!((multiViewActive || compareMvActive)
37226
- && (mvDraggingView || cmvDraggingView || obliqueDrag || _obliqueAltHeld || _mvScrollHintActive));
38198
+ && (mvDraggingView || cmvDraggingView || obliqueDrag || _obliqueAltHeld || _mvScrollHintActive))
38199
+ || !!(qmriMvActive && (_qmriMvDragView || _qmvScrollHintTimer));
37227
38200
  mvOrientation.style.display = show ? 'block' : 'none';
37228
38201
  }
37229
38202
  function _mvCancelPressCrosshair() {
@@ -37274,6 +38247,45 @@
37274
38247
  _cmvCrosshairTarget = target;
37275
38248
  if (!_cmvCrosshairAnim) _cmvCrosshairAnim = requestAnimationFrame(_cmvAnimateCrosshairs);
37276
38249
  }
38250
+ // qMRI multiview: every row shares one position, so one alpha drives
38251
+ // the lines in all rows at once (same press / scroll triggers as the
38252
+ // plain ortho grid).
38253
+ function _qmvAnimateCrosshairs() {
38254
+ const speed = 0.12;
38255
+ const diff = _qmvCrosshairTarget - _qmvCrosshairAlpha;
38256
+ if (Math.abs(diff) < 0.01) {
38257
+ _qmvCrosshairAlpha = _qmvCrosshairTarget;
38258
+ _qmvCrosshairAnim = null;
38259
+ qmriMvViews.forEach(v => qmriMvDrawFrame(v));
38260
+ return;
38261
+ }
38262
+ _qmvCrosshairAlpha += diff > 0 ? Math.min(speed, diff) : Math.max(-speed, diff);
38263
+ qmriMvViews.forEach(v => qmriMvDrawFrame(v));
38264
+ _qmvCrosshairAnim = requestAnimationFrame(_qmvAnimateCrosshairs);
38265
+ }
38266
+ function _qmvSetCrosshairTarget(target) {
38267
+ _qmvCrosshairTarget = target;
38268
+ if (!_qmvCrosshairAnim) _qmvCrosshairAnim = requestAnimationFrame(_qmvAnimateCrosshairs);
38269
+ }
38270
+ function _qmvRecomputeCrosshairTarget() {
38271
+ if (!qmriMvActive) return;
38272
+ _reconcileOrthoCursorHidden();
38273
+ _reconcileMvOrientationVisibility();
38274
+ _qmvSetCrosshairTarget(_qmriMvDragView || _qmvScrollHintTimer ? 1 : 0);
38275
+ }
38276
+ /** Scrolling a plane moves the lines in the other two planes; the
38277
+ * scrolled plane's own lines stay put, so they are left hidden. */
38278
+ function _qmvScrollHint(view) {
38279
+ _qmvScrollHintDir = view ? view.sliceDir : -1;
38280
+ if (_qmvScrollHintTimer) clearTimeout(_qmvScrollHintTimer);
38281
+ _qmvScrollHintTimer = setTimeout(() => {
38282
+ _qmvScrollHintTimer = null;
38283
+ _qmvScrollHintDir = -1;
38284
+ _qmvRecomputeCrosshairTarget();
38285
+ }, 250);
38286
+ _reconcileMvOrientationVisibility();
38287
+ _qmvSetCrosshairTarget(1);
38288
+ }
37277
38289
 
37278
38290
  function _getMvCrosshairOverlay(v) {
37279
38291
  if (v._chOverlay) return v._chOverlay;
@@ -38172,7 +39184,9 @@
38172
39184
  // not auto-entered on zoom threshold crossings. Auto-entry was buggy during
38173
39185
  // animation and surprised users.
38174
39186
  if ((e.metaKey || (e.ctrlKey && !_mainLoupeInspecting())) && !e.shiftKey && !e.altKey) {
38175
- const anchor = _captureZoomAnchor(e.clientX, e.clientY);
39187
+ const zoomCanvas = e.currentTarget instanceof HTMLCanvasElement ? e.currentTarget : canvas;
39188
+ const qmriPaneZoom = _usesQmriPaneZoom(zoomCanvas);
39189
+ const anchor = _captureZoomAnchor(e.clientX, e.clientY, zoomCanvas);
38176
39190
  const clamped = Math.max(-10, Math.min(10, e.deltaY));
38177
39191
  const factor = Math.pow(2, -clamped * _zoomSensitivity);
38178
39192
  const zoomBase = (_scrubRequestedZoom !== null) ? _scrubRequestedZoom : userZoom;
@@ -38181,13 +39195,22 @@
38181
39195
  // settled immersive view via pinch (they must toggle with Shift+F).
38182
39196
  const _wZ = lastImgW || canvas.width;
38183
39197
  const _hZ = lastImgH || canvas.height;
38184
- const floorZ = _fullscreenActive && _wZ && _hZ
39198
+ const floorZ = qmriPaneZoom ? 1.0 : (_fullscreenActive && _wZ && _hZ
38185
39199
  ? _calcAutoFitZoom(_wZ, _hZ, true) * 1.005
38186
- : _normalFitZoom;
39200
+ : _normalFitZoom);
38187
39201
  const desiredZoom = Math.max(floorZ, Math.min(10.0, zoomBase * factor));
38188
39202
  _scrubRequestedZoom = null;
38189
39203
  userZoom = desiredZoom;
38190
- if (!_snapZoomToFit()) {
39204
+ let snapped = false;
39205
+ if (qmriPaneZoom && Math.abs(userZoom - 1.0) < 0.02) {
39206
+ userZoom = 1.0;
39207
+ _zoomAdjustedByUser = false;
39208
+ qvPan.reset();
39209
+ snapped = true;
39210
+ } else if (!qmriPaneZoom) {
39211
+ snapped = _snapZoomToFit();
39212
+ }
39213
+ if (!snapped) {
38191
39214
  _zoomAdjustedByUser = true;
38192
39215
  }
38193
39216
  ModeRegistry.scaleAll();
@@ -38300,9 +39323,77 @@
38300
39323
  });
38301
39324
  });
38302
39325
  // Compare pane drag-to-pan when zoomed past viewport
38303
- compareCanvases.forEach(cv => {
39326
+ compareCanvases.forEach((cv, cmpPaneIdx) => {
38304
39327
  cv.addEventListener('mousedown', (e) => {
38305
- if (!compareActive || !comparePan.overflows) return;
39328
+ if (!compareActive) return;
39329
+ // Hover-info quick-measure: a plain click-drag while hover info
39330
+ // is on always draws a disposable freehand outline on this pane
39331
+ // and shows mean±std in its own pixel-info pill, instead of
39332
+ // panning (when zoomed) or doing nothing (when not) — the same
39333
+ // trade-off this gesture already makes in normal/mv/qMRI. Unlike
39334
+ // those, compare mode already has this pane's 2D slice cached
39335
+ // client-side (see _getCachedHoverSlice), so the aggregation is
39336
+ // done entirely here rather than via /roi_stats — see
39337
+ // _infoQuickComparePaneStats. Runs regardless of comparePan.overflows
39338
+ // so it isn't limited to the zoomed-in case (unlike the pan below).
39339
+ if (e.button === 0 && _pixelInfoVisible && !e.shiftKey && !e.metaKey && !e.altKey && !e.ctrlKey) {
39340
+ e.preventDefault();
39341
+ const dragTag = `cmp:${cmpPaneIdx}`;
39342
+ _infoFreehandDragging = true;
39343
+ _infoFreehandView = dragTag;
39344
+ const rect0 = cv.getBoundingClientRect();
39345
+ _infoFreehandPts = [[
39346
+ (e.clientX - rect0.left) * cv.width / rect0.width,
39347
+ (e.clientY - rect0.top) * cv.height / rect0.height,
39348
+ ]];
39349
+ const onInfoMove = (me) => {
39350
+ if (!_infoFreehandDragging || _infoFreehandView !== dragTag) return;
39351
+ const mr = cv.getBoundingClientRect();
39352
+ _infoFreehandPts.push([
39353
+ (me.clientX - mr.left) * cv.width / mr.width,
39354
+ (me.clientY - mr.top) * cv.height / mr.height,
39355
+ ]);
39356
+ if (_infoFreehandPts.length > 2) {
39357
+ _infoRenderCompareOverlay(cv, _infoQuickRoisCompare[cmpPaneIdx], { type: 'freehand', points: _infoFreehandPts, _infoQuick: true });
39358
+ }
39359
+ };
39360
+ const onInfoUp = () => {
39361
+ window.removeEventListener('mousemove', onInfoMove);
39362
+ window.removeEventListener('mouseup', onInfoUp);
39363
+ if (!_infoFreehandDragging || _infoFreehandView !== dragTag) return;
39364
+ _infoFreehandDragging = false;
39365
+ _infoFreehandView = null;
39366
+ const pts = _infoFreehandPts.slice();
39367
+ _infoFreehandPts = [];
39368
+ // Trivial click: fall back to the plain hover value
39369
+ // (already shown continuously by this pane's mousemove
39370
+ // listener) — just redraw whatever outlines already
39371
+ // persisted here, same threshold as the rest of this feature.
39372
+ if (pts.length < 5) { _infoRenderCompareOverlay(cv, _infoQuickRoisCompare[cmpPaneIdx]); return; }
39373
+ const entry = { type: 'freehand', points: pts, _infoQuick: true, statsText: null };
39374
+ if (!_infoQuickRoisCompare[cmpPaneIdx]) _infoQuickRoisCompare[cmpPaneIdx] = [];
39375
+ _infoQuickRoisCompare[cmpPaneIdx].push(entry);
39376
+ _infoQuickRoiStack.push({
39377
+ remove: () => {
39378
+ const arr = _infoQuickRoisCompare[cmpPaneIdx];
39379
+ if (!arr) return;
39380
+ const idx = arr.indexOf(entry);
39381
+ if (idx >= 0) arr.splice(idx, 1);
39382
+ _infoRenderCompareOverlay(cv, arr);
39383
+ },
39384
+ });
39385
+ _infoRenderCompareOverlay(cv, _infoQuickRoisCompare[cmpPaneIdx]);
39386
+ _infoQuickComparePaneStats(cmpPaneIdx, entry);
39387
+ };
39388
+ window.addEventListener('mousemove', onInfoMove);
39389
+ window.addEventListener('mouseup', onInfoUp);
39390
+ return;
39391
+ }
39392
+ if (!comparePan.overflows) return;
39393
+ // Hover-info mode has no pan on this pane — handled above —
39394
+ // but keep this guard too as a defensive no-op for modified
39395
+ // clicks (shift/ctrl/meta/alt) that skip the branch above.
39396
+ if (e.button === 0 && _pixelInfoVisible) return;
38306
39397
  if (e.button !== 0 && e.button !== 2) return;
38307
39398
  e.preventDefault();
38308
39399
  comparePan.startDrag(e);
@@ -38313,13 +39404,28 @@
38313
39404
  if (comparePan.updateDrag(e)) {
38314
39405
  updateMiniMapViewport();
38315
39406
  }
39407
+ if (qvPan.updateDrag(e)) updateMiniMap();
38316
39408
  });
38317
39409
  window.addEventListener('mouseup', () => {
38318
39410
  if (comparePan.endDrag()) {
38319
39411
  const overflowing = comparePan.overflows;
38320
39412
  compareCanvases.forEach(cv => { if (cv) cv.style.cursor = overflowing ? 'grab' : 'crosshair'; });
38321
39413
  }
39414
+ if (qvPan.endDrag()) _reconcileQvZoomCursor();
38322
39415
  });
39416
+ /** Hand cursor on qMRI panes while they overflow their frames.
39417
+ * Only the grab/grabbing values are ever written or cleared here,
39418
+ * so ROI editing and the loupe keep their own cursors. */
39419
+ function _reconcileQvZoomCursor() {
39420
+ const zoomed = _usesQmriPaneZoom(qmriViews[0]?.canvas) && qvPan.overflows;
39421
+ for (const v of qmriViews) {
39422
+ const cv = v.canvas;
39423
+ if (!cv) continue;
39424
+ const want = zoomed && !rectRoiMode && !_pixelInfoVisible ? (qvPan.dragging ? 'grabbing' : 'grab') : '';
39425
+ if (want) cv.style.cursor = want;
39426
+ else if (cv.style.cursor === 'grab' || cv.style.cursor === 'grabbing') cv.style.cursor = '';
39427
+ }
39428
+ }
38323
39429
  // Dynamically create crosshair elements in compare panes (including diff canvas)
38324
39430
  const _allCrosshairCanvases = () => {
38325
39431
  const cvs = compareCanvases.filter(Boolean);
@@ -38506,6 +39612,63 @@
38506
39612
  hideCompareCrosshairs();
38507
39613
  _hideComparePaneInfo();
38508
39614
  });
39615
+ // Hover-info quick-measure on the centre diff pane. Unlike the
39616
+ // split panes above, this canvas has no pre-existing mousedown
39617
+ // wiring at all (no pan — comparePan only ever starts from a
39618
+ // split pane), so there's no gesture to guard against here, only
39619
+ // one to add. Only the three numeric diff modes have a per-pixel
39620
+ // value to aggregate (diffMode 1-3 — see _compareCenterSample);
39621
+ // overlay/wipe/flicker/checker (diffMode 0) are left alone, same
39622
+ // as today — no outline, no stats, no crash.
39623
+ _diffCvForXhair.addEventListener('mousedown', (e) => {
39624
+ if (!compareActive || !_pixelInfoVisible || e.button !== 0) return;
39625
+ if (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return;
39626
+ if (diffMode <= 0) return;
39627
+ e.preventDefault();
39628
+ const cv = _diffCvForXhair;
39629
+ const dragTag = 'cmp:diff';
39630
+ _infoFreehandDragging = true;
39631
+ _infoFreehandView = dragTag;
39632
+ const rect0 = cv.getBoundingClientRect();
39633
+ _infoFreehandPts = [[
39634
+ (e.clientX - rect0.left) * cv.width / rect0.width,
39635
+ (e.clientY - rect0.top) * cv.height / rect0.height,
39636
+ ]];
39637
+ const onInfoMove = (me) => {
39638
+ if (!_infoFreehandDragging || _infoFreehandView !== dragTag) return;
39639
+ const mr = cv.getBoundingClientRect();
39640
+ _infoFreehandPts.push([
39641
+ (me.clientX - mr.left) * cv.width / mr.width,
39642
+ (me.clientY - mr.top) * cv.height / mr.height,
39643
+ ]);
39644
+ if (_infoFreehandPts.length > 2) {
39645
+ _infoRenderCompareOverlay(cv, _infoQuickRoisCompareDiff, { type: 'freehand', points: _infoFreehandPts, _infoQuick: true });
39646
+ }
39647
+ };
39648
+ const onInfoUp = () => {
39649
+ window.removeEventListener('mousemove', onInfoMove);
39650
+ window.removeEventListener('mouseup', onInfoUp);
39651
+ if (!_infoFreehandDragging || _infoFreehandView !== dragTag) return;
39652
+ _infoFreehandDragging = false;
39653
+ _infoFreehandView = null;
39654
+ const pts = _infoFreehandPts.slice();
39655
+ _infoFreehandPts = [];
39656
+ if (pts.length < 5) { _infoRenderCompareOverlay(cv, _infoQuickRoisCompareDiff); return; }
39657
+ const entry = { type: 'freehand', points: pts, _infoQuick: true, statsText: null };
39658
+ _infoQuickRoisCompareDiff.push(entry);
39659
+ _infoQuickRoiStack.push({
39660
+ remove: () => {
39661
+ const idx = _infoQuickRoisCompareDiff.indexOf(entry);
39662
+ if (idx >= 0) _infoQuickRoisCompareDiff.splice(idx, 1);
39663
+ _infoRenderCompareOverlay(cv, _infoQuickRoisCompareDiff);
39664
+ },
39665
+ });
39666
+ _infoRenderCompareOverlay(cv, _infoQuickRoisCompareDiff);
39667
+ _infoQuickCompareDiffStats(entry);
39668
+ };
39669
+ window.addEventListener('mousemove', onInfoMove);
39670
+ window.addEventListener('mouseup', onInfoUp);
39671
+ });
38509
39672
  }
38510
39673
 
38511
39674
  function closeSocket() {
@@ -42507,11 +43670,13 @@
42507
43670
  const compareFrame = compareActive ? compareFrames.find(Boolean) : null;
42508
43671
  const compareShouldShow = compareActive && comparePan.overflows && compareFrame && !multiViewActive && !qmriActive;
42509
43672
  const normalShouldShow = mainPan.overflows && lastImageData && !compareActive && !multiViewActive && !qmriActive;
43673
+ const qmriShouldShow = _usesQmriPaneZoom(qmriViews[0]?.canvas) && qvPan.overflows;
42510
43674
 
42511
- // ── Per-pane minimaps in compare mode ──
42512
- if (compareShouldShow) {
43675
+ // ── Per-pane minimaps in compare / qMRI mode ──
43676
+ if (compareShouldShow || qmriShouldShow) {
42513
43677
  _miniMap.classList.remove('visible'); // hide global minimap
42514
- _updatePerPaneMinimaps();
43678
+ if (compareShouldShow) _updatePerPaneMinimaps();
43679
+ else _updateQvMinimaps();
42515
43680
  return;
42516
43681
  } else {
42517
43682
  _clearPerPaneMinimaps();
@@ -42574,55 +43739,81 @@
42574
43739
  continue;
42575
43740
  }
42576
43741
  }
42577
- // Find or create per-pane minimap
42578
- let mm = clip.querySelector('.cmp-mini-map');
42579
- if (!mm) {
42580
- mm = document.createElement('div');
42581
- mm.className = 'cmp-mini-map';
42582
- const mmCv = document.createElement('canvas');
42583
- mmCv.width = W; mmCv.height = H;
42584
- const mmVp = document.createElement('div');
42585
- mmVp.className = 'cmp-mini-vp';
42586
- mm.appendChild(mmCv);
42587
- mm.appendChild(mmVp);
42588
- clip.appendChild(mm);
42589
- }
42590
- // Position: NW for left panes, NE for right panes,
42591
- // SW for bottom-left, SE for bottom-right
42592
- mm.style.top = isBottom ? 'auto' : '6px';
42593
- mm.style.bottom = isBottom ? '6px' : 'auto';
42594
- mm.style.left = isLeft ? '6px' : 'auto';
42595
- mm.style.right = isRight ? '6px' : 'auto';
42596
- mm.classList.add('visible');
42597
- const mmCv = mm.querySelector('canvas');
42598
- const mmVp = mm.querySelector('.cmp-mini-vp');
42599
- // Draw thumbnail
42600
- mmCv.width = W; mmCv.height = H;
42601
- const ctx = mmCv.getContext('2d');
42602
- const tmp = document.createElement('canvas');
42603
- tmp.width = frame.width; tmp.height = frame.height;
42604
- tmp.getContext('2d').putImageData(applyFlips(frame.imageData, frame.width, frame.height), 0, 0);
42605
- ctx.drawImage(tmp, 0, 0, W, H);
42606
- // Draw viewport rectangle
42607
- const cssW = parseInt(cv.style.width) || cv.width;
42608
- const cssH = parseInt(cv.style.height) || cv.height;
42609
- const vpW = parseInt(inner.dataset.vpW) || inner.offsetWidth || cssW;
42610
- const vpH = parseInt(inner.dataset.vpH) || inner.offsetHeight || cssH;
42611
- const fx = Math.min(1, vpW / cssW);
42612
- const fy = Math.min(1, vpH / cssH);
42613
- const offFx = -comparePan.x / cssW;
42614
- const offFy = -comparePan.y / cssH;
42615
- const rW = Math.max(2, Math.round(fx * W));
42616
- const rH = Math.max(2, Math.round(fy * H));
42617
- const rX = Math.max(0, Math.min(W - rW, Math.round(offFx * W)));
42618
- const rY = Math.max(0, Math.min(H - rH, Math.round(offFy * H)));
42619
- mmVp.style.width = rW + 'px';
42620
- mmVp.style.height = rH + 'px';
42621
- mmVp.style.left = rX + 'px';
42622
- mmVp.style.top = rY + 'px';
43742
+ const vpW = parseInt(inner.dataset.vpW) || inner.offsetWidth;
43743
+ const vpH = parseInt(inner.dataset.vpH) || inner.offsetHeight;
43744
+ _drawPaneMinimap(clip, cv, frame, comparePan, { w: vpW, h: vpH },
43745
+ { left: isLeft, right: isRight, bottom: isBottom });
42623
43746
  }
42624
43747
  }
42625
43748
 
43749
+ /** Find or create the small map inside a pane's clip wrapper and
43750
+ * draw `frame` with the rectangle that `pan` currently shows.
43751
+ * Corner: NW for left panes, NE for right panes, SW/SE at the
43752
+ * bottom. Shared by compare and qMRI panes. */
43753
+ function _drawPaneMinimap(clip, cv, frame, pan, vp, corner) {
43754
+ const W = 80, H = 80;
43755
+ let mm = clip.querySelector('.cmp-mini-map');
43756
+ if (!mm) {
43757
+ mm = document.createElement('div');
43758
+ mm.className = 'cmp-mini-map';
43759
+ const mmCv = document.createElement('canvas');
43760
+ mmCv.width = W; mmCv.height = H;
43761
+ const mmVp = document.createElement('div');
43762
+ mmVp.className = 'cmp-mini-vp';
43763
+ mm.appendChild(mmCv);
43764
+ mm.appendChild(mmVp);
43765
+ clip.appendChild(mm);
43766
+ }
43767
+ mm.style.top = corner.bottom ? 'auto' : '6px';
43768
+ mm.style.bottom = corner.bottom ? '6px' : 'auto';
43769
+ mm.style.left = corner.left ? '6px' : 'auto';
43770
+ mm.style.right = corner.right ? '6px' : 'auto';
43771
+ mm.classList.add('visible');
43772
+ const mmCv = mm.querySelector('canvas');
43773
+ const mmVp = mm.querySelector('.cmp-mini-vp');
43774
+ // Draw thumbnail
43775
+ mmCv.width = W; mmCv.height = H;
43776
+ const ctx = mmCv.getContext('2d');
43777
+ const tmp = document.createElement('canvas');
43778
+ tmp.width = frame.width; tmp.height = frame.height;
43779
+ tmp.getContext('2d').putImageData(applyFlips(frame.imageData, frame.width, frame.height), 0, 0);
43780
+ ctx.drawImage(tmp, 0, 0, W, H);
43781
+ // Draw viewport rectangle
43782
+ const cssW = parseInt(cv.style.width) || cv.width;
43783
+ const cssH = parseInt(cv.style.height) || cv.height;
43784
+ const vpW = vp.w || cssW;
43785
+ const vpH = vp.h || cssH;
43786
+ const fx = Math.min(1, vpW / cssW);
43787
+ const fy = Math.min(1, vpH / cssH);
43788
+ const offFx = -pan.x / cssW;
43789
+ const offFy = -pan.y / cssH;
43790
+ const rW = Math.max(2, Math.round(fx * W));
43791
+ const rH = Math.max(2, Math.round(fy * H));
43792
+ const rX = Math.max(0, Math.min(W - rW, Math.round(offFx * W)));
43793
+ const rY = Math.max(0, Math.min(H - rH, Math.round(offFy * H)));
43794
+ mmVp.style.width = rW + 'px';
43795
+ mmVp.style.height = rH + 'px';
43796
+ mmVp.style.left = rX + 'px';
43797
+ mmVp.style.top = rY + 'px';
43798
+ }
43799
+
43800
+ /** qMRI panes zoom and pan together, but each pane shows its own
43801
+ * map (top-right, like normal mode's) so every pane reads alone. */
43802
+ function _updateQvMinimaps() {
43803
+ const keep = new Set();
43804
+ for (const v of qmriViews) {
43805
+ const clip = v.canvas && v.canvas.closest('.qv-canvas-clip');
43806
+ if (!clip || !v.lastImageData) continue;
43807
+ _drawPaneMinimap(clip, v.canvas,
43808
+ { imageData: v.lastImageData, width: v.lastW, height: v.lastH },
43809
+ qvPan, { w: clip.offsetWidth, h: clip.offsetHeight }, { right: true });
43810
+ keep.add(clip.querySelector('.cmp-mini-map'));
43811
+ }
43812
+ document.querySelectorAll('#qmri-view-wrap .cmp-mini-map').forEach(el => {
43813
+ if (!keep.has(el)) el.classList.remove('visible');
43814
+ });
43815
+ }
43816
+
42626
43817
  function _clearPerPaneMinimaps() {
42627
43818
  document.querySelectorAll('.cmp-mini-map').forEach(el => el.classList.remove('visible'));
42628
43819
  }
@@ -42853,13 +44044,22 @@
42853
44044
  * getBoundingClientRect so CSS centering + existing pan translate
42854
44045
  * are both captured without having to reason about them manually.
42855
44046
  * Returns null when the canvas hasn't laid out yet. */
42856
- function _captureZoomAnchor(clientX, clientY) {
42857
- const r = canvas.getBoundingClientRect();
44047
+ function _usesQmriPaneZoom(cvs) {
44048
+ return !!cvs && qmriActive && !qmriMosaicActive && !qmriMvActive
44049
+ && qmriViews.some(v => v.canvas === cvs);
44050
+ }
44051
+ function _zoomPanForCanvas(cvs) {
44052
+ return _usesQmriPaneZoom(cvs) ? qvPan : mainPan;
44053
+ }
44054
+ function _captureZoomAnchor(clientX, clientY, targetCanvas = canvas) {
44055
+ const r = targetCanvas.getBoundingClientRect();
42858
44056
  if (!r.width || !r.height) return null;
42859
44057
  return {
42860
44058
  clientX, clientY,
42861
44059
  fracX: Math.max(0, Math.min(1, (clientX - r.left) / r.width)),
42862
44060
  fracY: Math.max(0, Math.min(1, (clientY - r.top) / r.height)),
44061
+ canvas: targetCanvas,
44062
+ pan: _zoomPanForCanvas(targetCanvas),
42863
44063
  };
42864
44064
  }
42865
44065
  /** After userZoom has been applied (scaleAll), shift pan so the
@@ -42868,14 +44068,14 @@
42868
44068
  * pan translates in the same coordinate system. */
42869
44069
  function _applyZoomAnchor(anchor) {
42870
44070
  if (!anchor) return;
42871
- const r = canvas.getBoundingClientRect();
44071
+ const r = anchor.canvas.getBoundingClientRect();
42872
44072
  if (!r.width || !r.height) return;
42873
44073
  const currentX = r.left + anchor.fracX * r.width;
42874
44074
  const currentY = r.top + anchor.fracY * r.height;
42875
- mainPan.x += anchor.clientX - currentX;
42876
- mainPan.y += anchor.clientY - currentY;
42877
- mainPan.clamp();
42878
- mainPan.apply();
44075
+ anchor.pan.x += anchor.clientX - currentX;
44076
+ anchor.pan.y += anchor.clientY - currentY;
44077
+ anchor.pan.clamp();
44078
+ anchor.pan.apply();
42879
44079
  }
42880
44080
  function _reconcileZoomCursor() {
42881
44081
  const normalNavigationOwnsCursor = !compareActive && !multiViewActive && !qmriActive
@@ -42884,7 +44084,7 @@
42884
44084
  && !document.body.classList.contains('ruler-mode')
42885
44085
  && !mainPan.dragging;
42886
44086
  if (normalNavigationOwnsCursor) {
42887
- canvas.style.cursor = mainPan.overflows ? 'grab' : '';
44087
+ canvas.style.cursor = _pixelInfoVisible ? 'crosshair' : (mainPan.overflows ? 'grab' : '');
42888
44088
  }
42889
44089
  _reconcileInfoCursor();
42890
44090
  }
@@ -42893,7 +44093,7 @@
42893
44093
  _pinchDist0 = _pinchDist(e.touches);
42894
44094
  _pinchZoom0 = userZoom;
42895
44095
  const c = _pinchCentroid(e.touches);
42896
- _pinchAnchor = _captureZoomAnchor(c.x, c.y);
44096
+ _pinchAnchor = _captureZoomAnchor(c.x, c.y, e.currentTarget);
42897
44097
  e.preventDefault();
42898
44098
  }
42899
44099
  }
@@ -42902,7 +44102,8 @@
42902
44102
  e.preventDefault();
42903
44103
  const d = _pinchDist(e.touches);
42904
44104
  // Direct ratio — touch events are smoothly sampled, no dampening needed
42905
- userZoom = Math.max(0.1, Math.min(10, _pinchZoom0 * (d / _pinchDist0)));
44105
+ const floorZoom = _usesQmriPaneZoom(e.currentTarget) ? 1.0 : 0.1;
44106
+ userZoom = Math.max(floorZoom, Math.min(10, _pinchZoom0 * (d / _pinchDist0)));
42906
44107
  _zoomAdjustedByUser = true;
42907
44108
  ModeRegistry.scaleAll();
42908
44109
  _applyZoomAnchor(_pinchAnchor);
@@ -42914,11 +44115,15 @@
42914
44115
  saveState();
42915
44116
  }
42916
44117
  }
42917
- [canvas, ...compareCanvases].forEach(el => {
44118
+ function _attachPinchHandlers(el) {
42918
44119
  el.addEventListener('touchstart', _onTouchStart, { passive: false });
42919
44120
  el.addEventListener('touchmove', _onTouchMove, { passive: false });
42920
44121
  el.addEventListener('touchend', _onTouchEnd, { passive: false });
42921
- });
44122
+ el.addEventListener('gesturestart', _onGestureStart, { passive: false });
44123
+ el.addEventListener('gesturechange', _onGestureChange, { passive: false });
44124
+ el.addEventListener('gestureend', _onGestureEnd, { passive: false });
44125
+ }
44126
+ [canvas, ...compareCanvases].forEach(_attachPinchHandlers);
42922
44127
 
42923
44128
  // ── 9b. Safari pinch-to-zoom (GestureEvent) ─────────────────────
42924
44129
  let _gestureStartZoom = null;
@@ -42926,12 +44131,13 @@
42926
44131
  function _onGestureStart(e) {
42927
44132
  e.preventDefault();
42928
44133
  _gestureStartZoom = userZoom;
42929
- _gestureAnchor = _captureZoomAnchor(e.clientX, e.clientY);
44134
+ _gestureAnchor = _captureZoomAnchor(e.clientX, e.clientY, e.currentTarget);
42930
44135
  }
42931
44136
  function _onGestureChange(e) {
42932
44137
  e.preventDefault();
42933
44138
  // e.scale is cumulative from gesturestart — multiply against cached start
42934
- userZoom = Math.max(_normalFitZoom, Math.min(10.0, _gestureStartZoom * e.scale));
44139
+ const floorZoom = _usesQmriPaneZoom(e.currentTarget) ? 1.0 : _normalFitZoom;
44140
+ userZoom = Math.max(floorZoom, Math.min(10.0, _gestureStartZoom * e.scale));
42935
44141
  _zoomAdjustedByUser = true;
42936
44142
  ModeRegistry.scaleAll();
42937
44143
  _applyZoomAnchor(_gestureAnchor);
@@ -42942,12 +44148,6 @@
42942
44148
  _gestureAnchor = null;
42943
44149
  saveState();
42944
44150
  }
42945
- [canvas, ...compareCanvases].forEach(el => {
42946
- el.addEventListener('gesturestart', _onGestureStart, { passive: false });
42947
- el.addEventListener('gesturechange', _onGestureChange, { passive: false });
42948
- el.addEventListener('gestureend', _onGestureEnd, { passive: false });
42949
- });
42950
-
42951
44151
  // Track mouse-over center pane (diff / wipe) for 'c' colormap cycling
42952
44152
  ['compare-diff-pane', 'compare-wipe-pane'].forEach(id => {
42953
44153
  const el = document.getElementById(id);