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 +1430 -230
- arrayview/_vscode_extension.py +1 -1
- arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.45.1.dist-info → arrayview-0.46.0.dist-info}/METADATA +1 -1
- {arrayview-0.45.1.dist-info → arrayview-0.46.0.dist-info}/RECORD +8 -8
- {arrayview-0.45.1.dist-info → arrayview-0.46.0.dist-info}/WHEEL +1 -1
- {arrayview-0.45.1.dist-info → arrayview-0.46.0.dist-info}/entry_points.txt +0 -0
- {arrayview-0.45.1.dist-info → arrayview-0.46.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
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
|
-
|
|
10999
|
-
|
|
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
|
-
|
|
14505
|
-
|
|
14506
|
-
|
|
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
|
-
|
|
21260
|
-
|
|
21261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
24429
|
-
|
|
24430
|
-
|
|
24431
|
-
|
|
24432
|
-
|
|
24433
|
-
|
|
24434
|
-
|
|
24435
|
-
|
|
24436
|
-
|
|
24437
|
-
|
|
24438
|
-
|
|
24439
|
-
|
|
24440
|
-
|
|
24441
|
-
|
|
24442
|
-
|
|
24443
|
-
|
|
24444
|
-
|
|
24445
|
-
|
|
24446
|
-
|
|
24447
|
-
if (
|
|
24448
|
-
|
|
24449
|
-
|
|
24450
|
-
|
|
24451
|
-
|
|
24452
|
-
|
|
24453
|
-
|
|
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,
|
|
24457
|
-
if (replaceIdx === _selectedRoiIdx) _roiDrawHandles(roiOverlayCtx,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24510
|
-
|
|
24511
|
-
|
|
24512
|
-
|
|
24513
|
-
|
|
24514
|
-
|
|
24515
|
-
|
|
24516
|
-
|
|
24517
|
-
|
|
24518
|
-
|
|
24519
|
-
|
|
24520
|
-
|
|
24521
|
-
|
|
24522
|
-
|
|
24523
|
-
|
|
24524
|
-
|
|
24525
|
-
|
|
24526
|
-
|
|
24527
|
-
|
|
24528
|
-
|
|
24529
|
-
|
|
24530
|
-
|
|
24531
|
-
|
|
24532
|
-
|
|
24533
|
-
|
|
24534
|
-
|
|
24535
|
-
if (
|
|
24536
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
24590
|
-
|
|
24591
|
-
|
|
24592
|
-
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
|
|
24596
|
-
|
|
24597
|
-
|
|
24598
|
-
|
|
24599
|
-
|
|
24600
|
-
|
|
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,
|
|
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 (
|
|
25962
|
-
|
|
25963
|
-
|
|
25964
|
-
|
|
25965
|
-
|
|
25966
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
35272
|
-
// (compareMvViews)
|
|
35273
|
-
//
|
|
35274
|
-
//
|
|
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
|
-
|
|
36705
|
-
//
|
|
36706
|
-
// qMRI
|
|
36707
|
-
|
|
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:
|
|
36719
|
-
|
|
37683
|
+
{ canvas: v.canvas, w, h, pan: qvPan },
|
|
37684
|
+
clip,
|
|
36720
37685
|
{
|
|
36721
|
-
maxW:
|
|
36722
|
-
maxH:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42578
|
-
|
|
42579
|
-
|
|
42580
|
-
|
|
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
|
|
42857
|
-
|
|
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
|
-
|
|
42876
|
-
|
|
42877
|
-
|
|
42878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|