seqeyes-python 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/resources/pulseq-bundle.js +198 -29
  5. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/resources/webview-bundle.js +352 -11
  6. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/.gitignore +0 -0
  7. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/README.md +0 -0
  8. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/examples/demo.ipynb +0 -0
  9. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/examples/demo.py +0 -0
  10. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/_plot.py +0 -0
  11. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/_renderer.py +0 -0
  12. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/src/seqeyes/resources/viewer.html +0 -0
  13. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/conftest.py +0 -0
  14. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/debug_full.html +0 -0
  15. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/debug_minimal.html +0 -0
  16. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/debug_viewer.html +0 -0
  17. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/debug_viewer.py +0 -0
  18. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/integration_test.py +0 -0
  19. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/test_plot.py +0 -0
  20. {seqeyes_python-0.3.2 → seqeyes_python-0.3.4}/tests/test_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: seqeyes-python
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq.
5
5
  Project-URL: Homepage, https://github.com/bughht/seqeyes_plugin
6
6
  Project-URL: Repository, https://github.com/bughht/seqeyes_plugin
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "seqeyes-python"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -29,4 +29,4 @@ from seqeyes._plot import set, reset
29
29
  from seqeyes._renderer import SeqEyesViewer
30
30
 
31
31
  __all__ = ["set", "reset", "SeqEyesViewer"]
32
- __version__ = "0.3.2"
32
+ __version__ = "0.3.4"
@@ -23,7 +23,12 @@ var Pulseq = (() => {
23
23
  // web/pulseq-browser.ts
24
24
  var pulseq_browser_exports = {};
25
25
  __export(pulseq_browser_exports, {
26
+ BAND_DETAIL_SAMPLES: () => BAND_DETAIL_SAMPLES,
27
+ EXACT_DETAIL_SAMPLES: () => EXACT_DETAIL_SAMPLES,
26
28
  INTERACTIVE_COMPUTE_LIMITS: () => INTERACTIVE_COMPUTE_LIMITS,
29
+ MAX_DETAIL_PTS: () => MAX_DETAIL_PTS,
30
+ MAX_DISPLAY_PTS: () => MAX_DISPLAY_PTS,
31
+ MAX_ENVELOPE_COLUMNS: () => MAX_ENVELOPE_COLUMNS,
27
32
  MAX_RF_RESPONSE_BANDS: () => MAX_RF_RESPONSE_BANDS,
28
33
  MAX_RF_RESPONSE_FFT_POINTS: () => MAX_RF_RESPONSE_FFT_POINTS,
29
34
  MAX_RF_RESPONSE_SAMPLES: () => MAX_RF_RESPONSE_SAMPLES,
@@ -36,10 +41,13 @@ var Pulseq = (() => {
36
41
  calculatePns: () => calculatePns,
37
42
  calculatePnsCoarse: () => calculatePnsCoarse,
38
43
  computeGradSpectrumParity: () => computeGradSpectrumParity,
44
+ computeGradientEnvelope: () => computeGradientEnvelope,
39
45
  computeGradientSpectrogram: () => computeGradientSpectrogram,
40
46
  computeGradientSpectrumAverage: () => computeGradientSpectrumAverage,
41
47
  computeGradientSpectrumSlice: () => computeGradientSpectrumSlice,
42
48
  countBandsOutsideRange: () => countBandsOutsideRange,
49
+ countExactDetailSamples: () => countExactDetailSamples,
50
+ countGradientSamples: () => countGradientSamples,
43
51
  createSequenceDecodeContext: () => createSequenceDecodeContext,
44
52
  decodeAllBlocks: () => decodeAllBlocks,
45
53
  decodeBlockRange: () => decodeBlockRange,
@@ -64,6 +72,8 @@ var Pulseq = (() => {
64
72
  hasPulseqBinaryMagic: () => hasPulseqBinaryMagic,
65
73
  isEmptyAscProfile: () => isEmptyAscProfile,
66
74
  kspaceExceedsInteractiveBudget: () => kspaceExceedsInteractiveBudget,
75
+ packGradientEnvelope: () => packGradientEnvelope,
76
+ packSequenceBlockRange: () => packSequenceBlockRange,
67
77
  packSequenceBlocks: () => packSequenceBlocks,
68
78
  parseAcousticResonancesAsc: () => parseAcousticResonancesAsc,
69
79
  parseAscProfile: () => parseAscProfile,
@@ -74,6 +84,7 @@ var Pulseq = (() => {
74
84
  parseSequenceText: () => parseSequenceText,
75
85
  physicalGradientValueAt: () => physicalGradientValueAt,
76
86
  resamplePhysicalGradients: () => resamplePhysicalGradients,
87
+ resolveDetailBlockRange: () => resolveDetailBlockRange,
77
88
  resolveSpectrogramParams: () => resolveSpectrogramParams,
78
89
  rotateGradient: () => rotateGradient,
79
90
  safePnsModel: () => safePnsModel,
@@ -86,7 +97,7 @@ var Pulseq = (() => {
86
97
  });
87
98
 
88
99
  // package.json
89
- var version = "0.3.2";
100
+ var version = "0.3.4";
90
101
 
91
102
  // src/pulseq/decompressor.ts
92
103
  function decompressShape(compressed, numSamples) {
@@ -2426,40 +2437,55 @@ var Pulseq = (() => {
2426
2437
  }
2427
2438
 
2428
2439
  // src/pulseq/displayDownsampling.ts
2429
- function reduceM4(time, values, maxPoints, emit) {
2430
- const n = Math.min(time.length, values.length);
2440
+ function reduceM4Range(time, values, start, end, maxPoints, emit) {
2441
+ const n = Math.max(0, end - start);
2431
2442
  if (n === 0 || maxPoints <= 0) return 0;
2432
2443
  if (n <= maxPoints) {
2433
- let kept2 = 0;
2434
- for (let index = 0; index < n; index++) {
2435
- emit(time[index], values[index]);
2436
- kept2++;
2437
- }
2438
- return kept2;
2444
+ for (let index = start; index < end; index++) emit(time[index], values[index]);
2445
+ return n;
2439
2446
  }
2440
2447
  const bucketCount = Math.max(1, Math.floor(maxPoints / 4));
2441
2448
  let kept = 0;
2442
2449
  for (let bucket = 0; bucket < bucketCount; bucket++) {
2443
- const start = Math.floor(bucket * n / bucketCount);
2444
- const end = Math.max(start + 1, Math.floor((bucket + 1) * n / bucketCount));
2445
- kept += emitBucket(time, values, start, Math.min(n, end), emit);
2450
+ const bucketStart = start + Math.floor(bucket * n / bucketCount);
2451
+ const bucketEnd = Math.max(bucketStart + 1, start + Math.floor((bucket + 1) * n / bucketCount));
2452
+ kept += emitBucket(time, values, bucketStart, Math.min(end, bucketEnd), emit);
2446
2453
  }
2447
2454
  return kept;
2448
2455
  }
2449
- function reduceUniform(time, values, maxPoints, emit) {
2450
- const n = Math.min(time.length, values.length);
2456
+ function reduceUniformRange(time, values, start, end, maxPoints, emit) {
2457
+ const n = Math.max(0, end - start);
2451
2458
  if (n === 0 || maxPoints <= 0) return 0;
2452
2459
  if (n <= maxPoints) {
2453
- for (let index = 0; index < n; index++) emit(time[index], values[index]);
2460
+ for (let index = start; index < end; index++) emit(time[index], values[index]);
2454
2461
  return n;
2455
2462
  }
2456
2463
  const step = n / maxPoints;
2457
2464
  for (let index = 0; index < maxPoints; index++) {
2458
- const source = Math.floor(index * step);
2459
- emit(time[source], values[source]);
2465
+ emit(time[start + Math.floor(index * step)], values[start + Math.floor(index * step)]);
2460
2466
  }
2461
2467
  return maxPoints;
2462
2468
  }
2469
+ function clipIndexRange(time, startSec, endSec, length) {
2470
+ const n = Math.max(0, Math.min(length, time.length));
2471
+ if (n === 0) return { start: 0, end: 0 };
2472
+ let lo = 0;
2473
+ let hi = n;
2474
+ while (lo < hi) {
2475
+ const mid = lo + hi >> 1;
2476
+ if (time[mid] < startSec) lo = mid + 1;
2477
+ else hi = mid;
2478
+ }
2479
+ const start = Math.max(0, lo - 1);
2480
+ lo = start;
2481
+ hi = n;
2482
+ while (lo < hi) {
2483
+ const mid = lo + hi >> 1;
2484
+ if (time[mid] <= endSec) lo = mid + 1;
2485
+ else hi = mid;
2486
+ }
2487
+ return { start, end: Math.min(n, lo + 1) };
2488
+ }
2463
2489
  function emitBucket(time, values, start, end, emit) {
2464
2490
  let minIndex = start;
2465
2491
  let maxIndex = start;
@@ -2486,24 +2512,36 @@ var Pulseq = (() => {
2486
2512
  // src/editor/blockTransport.ts
2487
2513
  var MAX_DISPLAY_PTS = 500;
2488
2514
  var MIN_DISPLAY_PTS = 8;
2515
+ var MAX_DETAIL_PTS = 4096;
2516
+ var EXACT_DETAIL_SAMPLES = 5e4;
2517
+ var BAND_DETAIL_SAMPLES = INTERACTIVE_COMPUTE_LIMITS.derivedRasterSamples;
2518
+ var WINDOW_DETAIL_SAMPLE_LIMIT = 2e6;
2489
2519
  var TAU2 = 2 * Math.PI;
2490
2520
  var EMPTY_PAIR = { o: 0, n: 0 };
2491
2521
  var CountingSink = class {
2492
- constructor(cap) {
2522
+ constructor(cap, window = null) {
2493
2523
  __publicField(this, "cap", cap);
2524
+ __publicField(this, "window", window);
2494
2525
  __publicField(this, "buildEnvelope", false);
2495
2526
  __publicField(this, "total", 0);
2496
2527
  }
2497
2528
  pair(time, values, useM4) {
2498
- this.total += useM4 ? reduceM4(time, values, this.cap, discard) : reduceUniform(time, values, this.cap, discard);
2529
+ const { start, end } = clipPair(time, values, this.window);
2530
+ this.total += useM4 ? reduceM4Range(time, values, start, end, this.cap, discard) : reduceUniformRange(time, values, start, end, this.cap, discard);
2499
2531
  return EMPTY_PAIR;
2500
2532
  }
2501
2533
  };
2534
+ function clipPair(time, values, window) {
2535
+ const n = Math.min(time.length, values.length);
2536
+ if (!window) return { start: 0, end: n };
2537
+ return clipIndexRange(time, window.startSec, window.endSec, n);
2538
+ }
2502
2539
  var WritingSink = class {
2503
- constructor(cap, times, values) {
2540
+ constructor(cap, times, values, window = null) {
2504
2541
  __publicField(this, "cap", cap);
2505
2542
  __publicField(this, "times", times);
2506
2543
  __publicField(this, "values", values);
2544
+ __publicField(this, "window", window);
2507
2545
  __publicField(this, "buildEnvelope", true);
2508
2546
  __publicField(this, "cursor", 0);
2509
2547
  __publicField(this, "wrapPhase", false);
@@ -2518,12 +2556,13 @@ var Pulseq = (() => {
2518
2556
  return this.cursor;
2519
2557
  }
2520
2558
  pair(time, values, useM4, wrapPhase) {
2521
- const start = this.cursor;
2559
+ const origin = this.cursor;
2560
+ const clipped = clipPair(time, values, this.window);
2522
2561
  this.wrapPhase = wrapPhase;
2523
- if (useM4) reduceM4(time, values, this.cap, this.emit);
2524
- else reduceUniform(time, values, this.cap, this.emit);
2562
+ if (useM4) reduceM4Range(time, values, clipped.start, clipped.end, this.cap, this.emit);
2563
+ else reduceUniformRange(time, values, clipped.start, clipped.end, this.cap, this.emit);
2525
2564
  this.wrapPhase = false;
2526
- return { o: start, n: this.cursor - start };
2565
+ return { o: origin, n: this.cursor - origin };
2527
2566
  }
2528
2567
  };
2529
2568
  function discard() {
@@ -2563,11 +2602,52 @@ var Pulseq = (() => {
2563
2602
  notice: cap < MAX_DISPLAY_PTS ? `Large sequence: waveform detail was reduced to ${cap} points per event (normally ${MAX_DISPLAY_PTS}) to stay inside the display transfer budget.` : null
2564
2603
  };
2565
2604
  }
2566
- function packBlocksAtCap(blocks, cap, knownTotal) {
2567
- const total = knownTotal ?? countSamples(blocks, cap);
2605
+ function packSequenceBlockRange(seq, startBlock, endBlock, context = createSequenceDecodeContext(seq), requestedCap = MAX_DISPLAY_PTS, sampleLimit = WINDOW_DETAIL_SAMPLE_LIMIT, window = null) {
2606
+ const start = Math.max(0, Math.min(seq.blocks.length, Math.floor(startBlock)));
2607
+ const end = Math.max(start, Math.min(seq.blocks.length, Math.ceil(endBlock)));
2608
+ const seriesCount = countSequenceWaveformSeriesRange(seq, start, end);
2609
+ const safeSampleLimit = Number.isFinite(sampleLimit) && sampleLimit >= 0 ? Math.floor(sampleLimit) : WINDOW_DETAIL_SAMPLE_LIMIT;
2610
+ const safeRequestedCap = Number.isFinite(requestedCap) && requestedCap > 0 ? Math.floor(requestedCap) : MAX_DISPLAY_PTS;
2611
+ if (seriesCount * MIN_DISPLAY_PTS > safeSampleLimit) {
2612
+ throw new Error(
2613
+ `The waveform detail window needs at least ${seriesCount * MIN_DISPLAY_PTS} samples; zoom in further.`
2614
+ );
2615
+ }
2616
+ const cap = seriesCount > 0 ? Math.max(
2617
+ MIN_DISPLAY_PTS,
2618
+ Math.min(safeRequestedCap, Math.floor(safeSampleLimit / seriesCount))
2619
+ ) : safeRequestedCap;
2620
+ const decoded = decodeBlockRange(seq, start, end, context);
2621
+ const clip = window && window.endSec > window.startSec ? window : null;
2622
+ const exactTotal = countSamples(decoded, Number.MAX_SAFE_INTEGER, clip);
2623
+ if (exactTotal <= Math.min(EXACT_DETAIL_SAMPLES, safeSampleLimit)) {
2624
+ return packBlocksAtCap(decoded, Number.MAX_SAFE_INTEGER, exactTotal, clip);
2625
+ }
2626
+ return packBlocksAtCap(decoded, cap, void 0, clip);
2627
+ }
2628
+ function resolveDetailBlockRange(blockStartTimes, blockCount, startSec, endSec) {
2629
+ const lowerBound = (target) => {
2630
+ let lo = 0;
2631
+ let hi = blockStartTimes.length;
2632
+ while (lo < hi) {
2633
+ const mid = lo + hi >> 1;
2634
+ if (blockStartTimes[mid] < target) lo = mid + 1;
2635
+ else hi = mid;
2636
+ }
2637
+ return lo;
2638
+ };
2639
+ const start = Math.max(0, Math.min(blockCount, lowerBound(startSec) - 1));
2640
+ const end = Math.max(start, Math.min(blockCount, lowerBound(endSec) + 1));
2641
+ return { start, end };
2642
+ }
2643
+ function countExactDetailSamples(blocks, window = null) {
2644
+ return countSamples(blocks, Number.MAX_SAFE_INTEGER, window);
2645
+ }
2646
+ function packBlocksAtCap(blocks, cap, knownTotal, window = null) {
2647
+ const total = knownTotal ?? countSamples(blocks, cap, window);
2568
2648
  const times = new Float64Array(total);
2569
2649
  const values = new Float32Array(total);
2570
- const sink = new WritingSink(cap, times, values);
2650
+ const sink = new WritingSink(cap, times, values, window);
2571
2651
  const envelope = new Array(blocks.length);
2572
2652
  for (let index = 0; index < blocks.length; index++) {
2573
2653
  envelope[index] = walkBlock(blocks[index], sink);
@@ -2611,8 +2691,8 @@ var Pulseq = (() => {
2611
2691
  if (gradient) gradient.o += delta;
2612
2692
  }
2613
2693
  }
2614
- function countSamples(blocks, cap) {
2615
- const sink = new CountingSink(cap);
2694
+ function countSamples(blocks, cap, window = null) {
2695
+ const sink = new CountingSink(cap, window);
2616
2696
  for (const block of blocks) walkBlock(block, sink);
2617
2697
  return sink.total;
2618
2698
  }
@@ -2717,6 +2797,95 @@ var Pulseq = (() => {
2717
2797
  return { peak, area, blockPulse };
2718
2798
  }
2719
2799
 
2800
+ // src/pulseq/gradientEnvelope.ts
2801
+ var GRADIENT_CHANNELS = ["gx", "gy", "gz"];
2802
+ var MAX_ENVELOPE_COLUMNS = 8192;
2803
+ function emptyChannel(columns) {
2804
+ const min = new Float32Array(columns).fill(Infinity);
2805
+ const max = new Float32Array(columns).fill(-Infinity);
2806
+ return { min, max, filled: new Uint8Array(columns) };
2807
+ }
2808
+ function accumulate(channel, grad, startSec, endSec, columns) {
2809
+ const time = grad.timePoints;
2810
+ const values = grad.waveform;
2811
+ const n = Math.min(time.length, values.length);
2812
+ if (n === 0) return;
2813
+ const span = endSec - startSec;
2814
+ if (!(span > 0)) return;
2815
+ const toColumn = (t) => (t - startSec) / span * columns;
2816
+ const put = (column, value) => {
2817
+ if (column < 0 || column >= columns || !Number.isFinite(value)) return;
2818
+ if (value < channel.min[column]) channel.min[column] = value;
2819
+ if (value > channel.max[column]) channel.max[column] = value;
2820
+ channel.filled[column] = 1;
2821
+ };
2822
+ if (n === 1) {
2823
+ put(Math.floor(toColumn(time[0])), values[0]);
2824
+ return;
2825
+ }
2826
+ for (let i = 1; i < n; i++) {
2827
+ const t0 = time[i - 1];
2828
+ const t1 = time[i];
2829
+ if (t1 < startSec || t0 > endSec) continue;
2830
+ const c0 = toColumn(t0);
2831
+ const c1 = toColumn(t1);
2832
+ const lo = Math.max(0, Math.floor(Math.min(c0, c1)));
2833
+ const hi = Math.min(columns - 1, Math.floor(Math.max(c0, c1)));
2834
+ const dc = c1 - c0;
2835
+ const valueAt = (column) => {
2836
+ if (dc === 0) return values[i];
2837
+ const f = (column - c0) / dc;
2838
+ return values[i - 1] + f * (values[i] - values[i - 1]);
2839
+ };
2840
+ for (let column = lo; column <= hi; column++) {
2841
+ const from = Math.max(Math.min(c0, c1), column);
2842
+ const to = Math.min(Math.max(c0, c1), column + 1);
2843
+ if (to < from) continue;
2844
+ put(column, valueAt(from));
2845
+ put(column, valueAt(to));
2846
+ }
2847
+ }
2848
+ }
2849
+ function computeGradientEnvelope(blocks, startSec, endSec, columns) {
2850
+ const width = Math.max(1, Math.min(MAX_ENVELOPE_COLUMNS, Math.floor(columns)));
2851
+ const channels = {
2852
+ gx: emptyChannel(width),
2853
+ gy: emptyChannel(width),
2854
+ gz: emptyChannel(width)
2855
+ };
2856
+ for (const block of blocks) {
2857
+ for (const key of GRADIENT_CHANNELS) {
2858
+ const grad = block[key];
2859
+ if (!grad || grad.type === "none") continue;
2860
+ accumulate(channels[key], grad, startSec, endSec, width);
2861
+ }
2862
+ }
2863
+ return { startSec, endSec, columns: width, channels };
2864
+ }
2865
+ function countGradientSamples(blocks) {
2866
+ let total = 0;
2867
+ for (const block of blocks) {
2868
+ for (const key of GRADIENT_CHANNELS) {
2869
+ const grad = block[key];
2870
+ if (grad && grad.type !== "none") total += grad.timePoints.length;
2871
+ }
2872
+ }
2873
+ return total;
2874
+ }
2875
+ function packGradientEnvelope(envelope) {
2876
+ const { columns } = envelope;
2877
+ const out = new Float32Array(columns * 6);
2878
+ let cursor = 0;
2879
+ for (const key of GRADIENT_CHANNELS) {
2880
+ const channel = envelope.channels[key];
2881
+ for (let c = 0; c < columns; c++) out[cursor + c] = channel.filled[c] ? channel.min[c] : NaN;
2882
+ cursor += columns;
2883
+ for (let c = 0; c < columns; c++) out[cursor + c] = channel.filled[c] ? channel.max[c] : NaN;
2884
+ cursor += columns;
2885
+ }
2886
+ return { startSec: envelope.startSec, endSec: envelope.endSec, columns, values: out.buffer };
2887
+ }
2888
+
2720
2889
  // src/pulseq/physicalGradients.ts
2721
2890
  var GRADIENT_ENDPOINT_TOLERANCE_SEC = 1e-12;
2722
2891
  function rotateGradient(block, gx, gy, gz) {
@@ -113,6 +113,243 @@ function deserializeGradientSound(payload){
113
113
  };
114
114
  }
115
115
 
116
+ /* ── Viewport waveform detail ─────────────────────────────────────────────
117
+ The initial payload is one bounded overview of the whole sequence: every
118
+ waveform is reduced to at most a few hundred points so a large sequence can
119
+ cross the transport at all. That is the right trade for the first paint and
120
+ for wide views, but it is irreversible — zooming into 0.5 ms of a 48 ms
121
+ readout leaves the renderer connecting a handful of surviving extrema, which
122
+ draws as straight polygonal segments instead of the real waveform.
123
+
124
+ So when the view is zoomed far enough that it holds fewer transported
125
+ samples than it has pixels, the host is asked to decode that block range
126
+ again and reduce it against the visible interval instead of the whole event.
127
+ The reply replaces only the blocks it covers, and only while it covers the
128
+ view; everything outside keeps the overview. Requests are debounced so a
129
+ drag issues one decode at rest rather than one per frame, and each reply
130
+ carries the sequence generation it was packed from so a load that lands
131
+ mid-flight cannot paint the previous sequence's samples. */
132
+
133
+ /** Detail currently held: {blocks,startBlock,endBlock,startSec,endSec,generation}. */
134
+ var waveformDetail = null;
135
+ /**
136
+ * Band held for windows carrying more samples than can be drawn one segment
137
+ * each: {startSec,endSec,columns,values,generation}. `values` is six runs of
138
+ * `columns` floats — gxMin, gxMax, gyMin, gyMax, gzMin, gzMax — NaN where a
139
+ * column holds no gradient, so a gap cannot be read as a zero.
140
+ */
141
+ var waveformBand = null;
142
+ /** Set for one drawBlocks pass when the band, not samples, describes the view. */
143
+ var activeWaveformBand = null;
144
+ /**
145
+ * Narrowest window the host refused as beyond its decode budget. Anything at
146
+ * least this wide would be refused too, so it is not asked for again; zooming
147
+ * in narrows the request and lifts the block on its own.
148
+ */
149
+ var waveformDetailRefusedSec = Infinity;
150
+ /** Set for the duration of one drawBlocks pass when detail covers the view. */
151
+ var activeWaveformDetail = null;
152
+ var waveformDetailPending = null;
153
+ var waveformDetailTimer = 0;
154
+ var waveformDetailRequestId = 0;
155
+ /** Bumped by the host on every sequence load; stale replies are dropped. */
156
+ var waveformDetailGeneration = 0;
157
+ /**
158
+ * Installed by each host: VS Code posts a message to the extension, the
159
+ * standalone web app packs the range in this same heap. Receives
160
+ * {requestId, generation, startSec, endSec, columns}.
161
+ */
162
+ var requestWaveformDetailWindow = null;
163
+ /**
164
+ * Installed by each host to surface a detail failure. This bundle's notice
165
+ * state is not what the standalone web app renders, so the message has to go
166
+ * back out to whichever viewer is actually on screen.
167
+ */
168
+ var waveformDetailNotice = null;
169
+
170
+ function reportWaveformDetail(message){
171
+ if(typeof waveformDetailNotice==='function')waveformDetailNotice(message);
172
+ }
173
+
174
+ var WAVEFORM_DETAIL_DEBOUNCE_MS = 160;
175
+ /**
176
+ * Refetch once the held window is this many times wider than the view.
177
+ *
178
+ * Time coverage alone is not enough: a window fetched for a 5 ms view still
179
+ * covers a 0.5 ms view, so without this the detail would freeze at whatever
180
+ * zoom first requested it and never sharpen as the user keeps zooming in. A
181
+ * fresh request pads to 1.5x the view, comfortably inside this bound, so
182
+ * refetching settles in one step instead of oscillating.
183
+ */
184
+ var WAVEFORM_DETAIL_REFINE_FACTOR = 2;
185
+ /** Mirrors the host's block ceiling so a hopeless request is never sent. */
186
+ var WAVEFORM_DETAIL_BLOCK_LIMIT = 20000;
187
+
188
+ /**
189
+ * Discard detail and in-flight requests; call whenever BL is replaced.
190
+ *
191
+ * VS Code passes the extension host's own sequence generation so both sides
192
+ * agree on which load a reply belongs to; the standalone web app owns the
193
+ * counter itself and just advances it.
194
+ */
195
+ function resetWaveformDetail(generation){
196
+ waveformDetail=null;activeWaveformDetail=null;waveformDetailPending=null;
197
+ waveformBand=null;activeWaveformBand=null;waveformDetailRefusedSec=Infinity;
198
+ clearTimeout(waveformDetailTimer);waveformDetailTimer=0;
199
+ waveformDetailGeneration=(typeof generation==='number'&&isFinite(generation))
200
+ ?generation:waveformDetailGeneration+1;
201
+ }
202
+
203
+ function waveformDetailCovers(detail,vs,ve){
204
+ return !!(detail&&detail.generation===waveformDetailGeneration
205
+ &&detail.startSec<=vs+1e-12&&detail.endSec>=ve-1e-12);
206
+ }
207
+
208
+ /**
209
+ * Whether a window is not just valid for this view but sharp enough for it.
210
+ * A window far wider than the view spent most of its point budget off screen.
211
+ */
212
+ function waveformWindowServes(win,vs,ve){
213
+ if(!waveformDetailCovers(win,vs,ve))return false;
214
+ return (win.endSec-win.startSec)<=(ve-vs)*WAVEFORM_DETAIL_REFINE_FACTOR;
215
+ }
216
+
217
+ /**
218
+ * The block the renderer should draw for index `bi`. Detail blocks carry the
219
+ * same identity and timing as their overview counterparts and differ only in
220
+ * waveform resolution, so substituting one is transparent to every caller.
221
+ *
222
+ * `blocks` is passed in rather than read from a global because the standalone
223
+ * web app runs its renderer inside an IIFE with its own `BL`; every function
224
+ * this bundle shares with it has to take the state it operates on.
225
+ */
226
+ function blockAt(blocks,bi){
227
+ var detail=activeWaveformDetail;
228
+ if(detail&&bi>=detail.startBlock&&bi<detail.endBlock){
229
+ var block=detail.blocks[bi-detail.startBlock];
230
+ if(block)return block;
231
+ }
232
+ return blocks[bi];
233
+ }
234
+
235
+ /**
236
+ * Decide whether detail applies to this view and, if it would help, ask for it.
237
+ * Returns the detail to draw from, or null to keep the overview.
238
+ */
239
+ function waveformDetailForView(vs,ve,pixelBudget,startBlock,endBlock){
240
+ // Draw from any detail that covers the view, even while a sharper window is
241
+ // in flight: coarse detail still beats the whole-sequence overview.
242
+ activeWaveformDetail=waveformDetailCovers(waveformDetail,vs,ve)?waveformDetail:null;
243
+ // Anything not already served by exact samples or a band is being drawn from
244
+ // the reduced transport, which is the representation to get away from — so
245
+ // ask, whatever the point count. Held data is refetched once its window is
246
+ // wider than the view deserves. Loops are prevented by the coverage test and
247
+ // by remembering a refusal, not by withholding the request.
248
+ var wants=activeWaveformDetail
249
+ ? !waveformWindowServes(waveformDetail,vs,ve)
250
+ : (waveformDetailCovers(waveformBand,vs,ve)
251
+ ? !waveformWindowServes(waveformBand,vs,ve)
252
+ : true);
253
+ if(wants&&endBlock-startBlock<=WAVEFORM_DETAIL_BLOCK_LIMIT)
254
+ scheduleWaveformDetail(vs,ve,pixelBudget);
255
+ return activeWaveformDetail;
256
+ }
257
+
258
+ /**
259
+ * The band gradients should be drawn from, or null when exact samples are
260
+ * available or the view is beyond what the host will decode. Kept separate
261
+ * from the sample path so a stale band is never drawn beside fresher samples.
262
+ */
263
+ function waveformBandForView(vs,ve){
264
+ activeWaveformBand=(!activeWaveformDetail&&waveformDetailCovers(waveformBand,vs,ve))
265
+ ?waveformBand:null;
266
+ return activeWaveformBand;
267
+ }
268
+
269
+ /** One column's [min,max] for a channel, or null where it holds nothing. */
270
+ function bandColumn(band,channelIndex,column){
271
+ if(!band||column<0||column>=band.columns)return null;
272
+ var base=channelIndex*2*band.columns;
273
+ var lo=band.values[base+column],hi=band.values[base+band.columns+column];
274
+ if(!(lo===lo)||!(hi===hi))return null; // NaN marks an empty column
275
+ return{lo:lo,hi:hi};
276
+ }
277
+
278
+ function scheduleWaveformDetail(vs,ve,pixelBudget){
279
+ if(typeof requestWaveformDetailWindow!=='function'||!(ve>vs))return;
280
+ var pad=(ve-vs)*0.25,start=Math.max(0,vs-pad),end=ve+pad;
281
+ if(end-start>=waveformDetailRefusedSec)return;
282
+ // Enough columns that the part inside the view still gets about one per
283
+ // pixel once the padding either side is discounted.
284
+ var columns=Math.max(1,Math.ceil((pixelBudget||1)*((end-start)/(ve-vs))));
285
+ // Same adequacy test as the held detail, so an in-flight wide request cannot
286
+ // suppress the narrower one a deeper zoom now needs.
287
+ if(waveformWindowServes(waveformDetailPending,vs,ve))return;
288
+ clearTimeout(waveformDetailTimer);
289
+ waveformDetailTimer=setTimeout(function(){
290
+ waveformDetailPending={startSec:start,endSec:end,generation:waveformDetailGeneration};
291
+ waveformDetailRequestId++;
292
+ try{
293
+ requestWaveformDetailWindow({
294
+ requestId:waveformDetailRequestId,
295
+ generation:waveformDetailGeneration,
296
+ startSec:start,endSec:end,
297
+ columns:columns
298
+ });
299
+ }catch(err){waveformDetailPending=null;
300
+ reportWaveformDetail('Waveform detail was not calculated: '+(err&&err.message||String(err))+'.');}
301
+ },WAVEFORM_DETAIL_DEBOUNCE_MS);
302
+ }
303
+
304
+ /** Accept a detail reply. Ignores stale generations and superseded requests. */
305
+ function applyWaveformDetail(payload){
306
+ if(!payload||payload.generation!==waveformDetailGeneration)return false;
307
+ if(payload.requestId!==waveformDetailRequestId)return false;
308
+ waveformDetailPending=null;
309
+ waveformDetail={
310
+ blocks:payload.blocks,startBlock:payload.startBlock,endBlock:payload.endBlock,
311
+ startSec:payload.startSec,endSec:payload.endSec,generation:payload.generation
312
+ };
313
+ reportWaveformDetail(null);
314
+ return true;
315
+ }
316
+
317
+ /** Accept a band for a window too dense to draw sample by sample. */
318
+ function applyWaveformBand(payload){
319
+ if(!payload||payload.generation!==waveformDetailGeneration)return false;
320
+ if(payload.requestId!==waveformDetailRequestId)return false;
321
+ waveformDetailPending=null;
322
+ waveformDetail=null;activeWaveformDetail=null;
323
+ waveformBand={
324
+ startSec:payload.startSec,endSec:payload.endSec,columns:payload.columns,
325
+ values:payload.values,generation:payload.generation
326
+ };
327
+ reportWaveformDetail(null);
328
+ return true;
329
+ }
330
+
331
+ /** Remember that this window exceeds the decode budget and stop asking. */
332
+ function refuseWaveformDetail(payload){
333
+ if(!payload||payload.generation!==waveformDetailGeneration)return false;
334
+ if(payload.requestId!==waveformDetailRequestId)return false;
335
+ waveformDetailPending=null;
336
+ var width=payload.endSec-payload.startSec;
337
+ if(width>0&&width<waveformDetailRefusedSec)waveformDetailRefusedSec=width;
338
+ return true;
339
+ }
340
+
341
+ /**
342
+ * Record a failed detail request without discarding the overview. Checks the
343
+ * request id as well as the generation so a superseded failure cannot clear a
344
+ * newer in-flight request or leave a notice about a window nobody is viewing.
345
+ */
346
+ function failWaveformDetail(payload){
347
+ if(!payload||payload.generation!==waveformDetailGeneration)return;
348
+ if(payload.requestId!==waveformDetailRequestId)return;
349
+ waveformDetailPending=null;
350
+ reportWaveformDetail('Waveform detail was not calculated: '+(payload.message||'unknown error')+'.');
351
+ }
352
+
116
353
 
117
354
  /* ══ state.js ══ */
118
355
  /* ═══════════════════════════════════════════════════════════════════════
@@ -144,7 +381,7 @@ var kTraj=null,kAdc=null,kTime=null,kAdcTime=null;
144
381
  var m1Data=null,m1WindowData=null,m1WindowPending=null,m1WindowRequestId=0,pnsData=null,pnsWindowData=null,pnsWindowPending=null,pnsWindowRequestId=0,pnsBusy=false,m1Busy=false,m1RequestedChannel=8,m1ReferenceMode=readM1ReferenceMode(),m1RestoreChannels=null;
145
382
  var viewerNotices={},viewerNoticesCollapsed=readViewerNoticesCollapsed();
146
383
  var kspaceSafetyWarning=null,kspaceSafetyBusy=false,kspaceSafetyPopupTimer=0;
147
- var derivedRenderPointCount=0,derivedEnvelopeCurveCount=0,derivedRawCurveCount=0,waveformOverviewActive=false,rfRenderPointCount=0,rfRawCurveCount=0,rfReducedCurveCount=0,rfOverviewBucketCount=0,lastDrawDurationMs=0,viewerDrawCount=0,viewerCursorDrawCount=0;
384
+ var derivedRenderPointCount=0,derivedEnvelopeCurveCount=0,derivedRawCurveCount=0,waveformOverviewActive=false,rfRenderPointCount=0,rfRawCurveCount=0,rfReducedCurveCount=0,rfOverviewBucketCount=0,gradViewPointCount=0,lastDrawDurationMs=0,viewerDrawCount=0,viewerCursorDrawCount=0;
148
385
  var viewerDrawFrame=0,viewerDrawMinimap=false;
149
386
  function isMobileSafetyLayout(){return !!(window.matchMedia&&window.matchMedia('(max-width: 768px), (pointer: coarse)').matches);}
150
387
  function viewerNoticeMessages(value){
@@ -245,6 +482,19 @@ var blockPos=[];
245
482
  /* VS Code API — acquired once, used for postMessage to extension host */
246
483
  var vscApi=(typeof acquireVsCodeApi!=='undefined')?acquireVsCodeApi():null;
247
484
 
485
+ /* Viewport waveform detail crosses the extension boundary; the standalone web
486
+ app installs its own in-heap implementation instead. */
487
+ waveformDetailNotice=function(message){setViewerNotice('waveformDetail',message);};
488
+ if(vscApi)requestWaveformDetailWindow=function(request){
489
+ vscApi.postMessage({
490
+ command:'requestWaveformDetail',
491
+ requestId:request.requestId,
492
+ sequenceGeneration:request.generation,
493
+ startSec:request.startSec,endSec:request.endSec,
494
+ columns:request.columns
495
+ });
496
+ };
497
+
248
498
  function normalizeM1ReferenceMode(mode){return mode==='observationTime'?'observationTime':'rfCenter';}
249
499
  function readM1ReferenceMode(){
250
500
  try{localStorage.removeItem('seqeyes.m1ReferenceMode');}catch(_){}
@@ -402,7 +652,7 @@ function applySerializedKspace(payload){
402
652
  much as the message: the progress overlay reaches "Ready" either way, so
403
653
  leaving stale blocks on screen would read as a successful load. */
404
654
  function showSequenceLoadFailure(message){
405
- BL=[];waveformOverview=null;blockPos=[];mmCache=null;
655
+ BL=[];waveformOverview=null;blockPos=[];mmCache=null;resetWaveformDetail();
406
656
  setViewerNotice('sequence',message);
407
657
  setExportButtonEnabled(false);
408
658
  draw();drawMinimap();
@@ -430,6 +680,7 @@ window.addEventListener('message',function(e){
430
680
  return;
431
681
  }
432
682
  if(m.type==='sequenceData'){
683
+ resetWaveformDetail(m.sequenceGeneration);
433
684
  try{
434
685
  BL=unpackSequenceBlocks(m.blocks,m.sampleTimes,m.sampleValues,m.sampleCount||0);
435
686
  }catch(err){
@@ -456,6 +707,35 @@ window.addEventListener('message',function(e){
456
707
  setExportButtonEnabled(true);
457
708
  SeqEyesPanel.onSequenceLoaded();
458
709
  requestAnimationFrame(function(){refreshLayout();draw();drawKs();drawMinimap();});
710
+ }else if(m.type==='waveformDetailData'){
711
+ var detailBlocks;
712
+ try{
713
+ detailBlocks=unpackSequenceBlocks(m.blocks,m.sampleTimes,m.sampleValues,m.sampleCount||0);
714
+ }catch(err){
715
+ failWaveformDetail({requestId:m.requestId,generation:m.sequenceGeneration,message:(err&&err.message||String(err))});
716
+ return;
717
+ }
718
+ if(applyWaveformDetail({
719
+ requestId:m.requestId,generation:m.sequenceGeneration,
720
+ startBlock:m.startBlock,endBlock:m.endBlock,
721
+ startSec:m.startSec,endSec:m.endSec,blocks:detailBlocks
722
+ }))draw();
723
+ }else if(m.type==='waveformBandData'){
724
+ var bandValues=asTypedView(m.values,Float32Array);
725
+ if(!bandValues){
726
+ failWaveformDetail({requestId:m.requestId,generation:m.sequenceGeneration,
727
+ message:'the waveform band did not arrive as binary data'});
728
+ return;
729
+ }
730
+ if(applyWaveformBand({
731
+ requestId:m.requestId,generation:m.sequenceGeneration,
732
+ startSec:m.startSec,endSec:m.endSec,columns:m.columns,values:bandValues
733
+ }))draw();
734
+ }else if(m.type==='waveformDetailUnavailable'){
735
+ if(refuseWaveformDetail({requestId:m.requestId,generation:m.sequenceGeneration,
736
+ startSec:m.startSec,endSec:m.endSec}))draw();
737
+ }else if(m.type==='waveformDetailError'){
738
+ failWaveformDetail({requestId:m.requestId,generation:m.sequenceGeneration,message:m.message});
459
739
  }else if(m.type==='loadError'){
460
740
  showSequenceLoadFailure(m.message||'The sequence could not be loaded.');
461
741
  }else if(m.type==='kspaceData'){
@@ -1424,7 +1704,7 @@ var panelMarkerTimeSec=NaN;
1424
1704
  Main draw loop
1425
1705
  ═══════════════════════════════════════════════════════════════════════ */
1426
1706
  function draw(){
1427
- var drawStarted=performance.now();derivedRenderPointCount=0;derivedEnvelopeCurveCount=0;derivedRawCurveCount=0;rfRenderPointCount=0;rfRawCurveCount=0;rfReducedCurveCount=0;rfOverviewBucketCount=0;viewerDrawCount++;
1707
+ var drawStarted=performance.now();derivedRenderPointCount=0;derivedEnvelopeCurveCount=0;derivedRawCurveCount=0;rfRenderPointCount=0;rfRawCurveCount=0;rfReducedCurveCount=0;rfOverviewBucketCount=0;gradViewPointCount=0;viewerDrawCount++;
1428
1708
  var w=mc.width/(window.devicePixelRatio||1),h=mc.height/(window.devicePixelRatio||1);
1429
1709
  var s=getComputedStyle(document.body);
1430
1710
  ctx.clearRect(0,0,w,h);
@@ -1714,18 +1994,37 @@ function drawBlocks(vs,ve,s){
1714
1994
  var pixelBudget=Math.max(1,Math.floor(plotWidth()));
1715
1995
  var overview=selectWaveformOverview(waveformOverview,range.start,range.end,pixelBudget);
1716
1996
  function useOverview(key){return !!overview&&waveformVisiblePointCount(waveformOverview,key,range.start,range.end)>pixelBudget;}
1717
- function useGradientOverview(key){return !!overview&&waveformVisibleGradientPointCount(BL,key,range.start,range.end,vs,ve)>pixelBudget*8;}
1997
+ var gradVisible={gx:waveformVisibleGradientPointCount(BL,'gx',range.start,range.end,vs,ve),
1998
+ gy:waveformVisibleGradientPointCount(BL,'gy',range.start,range.end,vs,ve),
1999
+ gz:waveformVisibleGradientPointCount(BL,'gz',range.start,range.end,vs,ve)};
2000
+ function useGradientOverview(key){return !!overview&&gradVisible[key]>pixelBudget*8;}
1718
2001
  var rfPoints=waveformVisiblePointCount(waveformOverview,'rf',range.start,range.end),rfEvents=waveformVisiblePointCount(waveformOverview,'rfEvents',range.start,range.end);
1719
2002
  var aggregateRf=!!overview&&rfEvents>pixelBudget*2,reduceRf=aggregateRf||rfPoints>pixelBudget*8;
1720
2003
  var overviewUse={rf:reduceRf,phase:useOverview('phase'),gx:useGradientOverview('gx'),gy:useGradientOverview('gy'),gz:useGradientOverview('gz'),adc:useOverview('adc')};
1721
2004
  var dense=overviewUse.rf||overviewUse.phase||overviewUse.gx||overviewUse.gy||overviewUse.gz||overviewUse.adc;
1722
2005
  waveformOverviewActive=dense;
1723
2006
  setViewerNotice('dense',dense?'Dense overview mode is active. Zoom in for full waveform detail.':null);
2007
+ // Raw level draws whatever the transport delivered, so this is where an
2008
+ // over-reduced view has to be refilled with samples clipped to the viewport.
2009
+ // Keyed on the waveform rows specifically: phase or ADC switching to the
2010
+ // overview says nothing about the gradients, which may still be drawing
2011
+ // transported samples and still be the thing that looks wrong.
2012
+ var rawWaveformRows=!(overviewUse.gx&&overviewUse.gy&&overviewUse.gz&&reduceRf);
2013
+ if(rawWaveformRows)waveformDetailForView(vs,ve,pixelBudget,range.start,range.end);
2014
+ else activeWaveformDetail=null;
1724
2015
  if(rows[0]>=0){if(aggregateRf)drawRfOverview(overview,rows[0],ch,colors,vs,ve);else drawRfBlocks(range.start,range.end,rows[0],ch,colors,vs,ve,pixelBudget*8);}
1725
2016
  if(rows[1]>=0){if(overviewUse.phase)drawPhaseSampled(range.start,range.end,rows[1],ch,colors,vs,ve,pixelBudget);else drawPhaseBlocks(range.start,range.end,rows[1],ch,colors,vs,ve);}
1726
- if(rows[2]>=0){if(overviewUse.gx)drawGradientOverview(overview,'gx',rows[2],2,ch,colors.gx,vs,ve);else drawGradientBlocks(range.start,range.end,'gx',rows[2],2,ch,colors.gx,vs,ve);}
1727
- if(rows[3]>=0){if(overviewUse.gy)drawGradientOverview(overview,'gy',rows[3],3,ch,colors.gy,vs,ve);else drawGradientBlocks(range.start,range.end,'gy',rows[3],3,ch,colors.gy,vs,ve);}
1728
- if(rows[4]>=0){if(overviewUse.gz)drawGradientOverview(overview,'gz',rows[4],4,ch,colors.gz,vs,ve);else drawGradientBlocks(range.start,range.end,'gz',rows[4],4,ch,colors.gz,vs,ve);}
2017
+ // Band first: where it covers the view it is the accurate representation, and
2018
+ // the block polyline would be drawing an already-reduced transport instead.
2019
+ var band=waveformBandForView(vs,ve);
2020
+ var gradKeys=['gx','gy','gz'];
2021
+ for(var gi=0;gi<3;gi++){
2022
+ var row=rows[2+gi],key=gradKeys[gi];
2023
+ if(row<0)continue;
2024
+ if(band)drawGradientBand(band,gi,key,row,2+gi,ch,colors[key],vs,ve);
2025
+ else if(overviewUse[key])drawGradientOverview(overview,key,row,2+gi,ch,colors[key],vs,ve);
2026
+ else drawGradientBlocks(range.start,range.end,key,row,2+gi,ch,colors[key],vs,ve);
2027
+ }
1729
2028
  if(rows[5]>=0){if(overviewUse.adc)drawAdcOverview(overview,rows[5],ch,colors,vs,ve);else drawAdcBlocks(range.start,range.end,rows[5],ch,colors,vs,ve);}
1730
2029
  if(rows[6]>=0&&range.end-range.start<=pixelBudget)drawTriggerBlocks(range.start,range.end,rows[6],ch,colors,vs,ve);
1731
2030
  }
@@ -1830,7 +2129,7 @@ function drawRfBlocks(start,end,vi,ch,colors,vs,ve,maxPoints){
1830
2129
  rowClip(vi,ch,function(){
1831
2130
  var visibleEvents=[];
1832
2131
  for(var bi=start;bi<end;bi++){
1833
- var rf=BL[bi].rf;if(!rf||rf.s+rf.d<vs||rf.s>ve)continue;
2132
+ var rf=blockAt(BL,bi).rf;if(!rf||rf.s+rf.d<vs||rf.s>ve)continue;
1834
2133
  visibleEvents.push(rf);
1835
2134
  }
1836
2135
  var pointBudget=Math.max(4,Math.floor((maxPoints||Infinity)/Math.max(1,visibleEvents.length)));
@@ -1855,7 +2154,7 @@ function drawPhaseBlocks(start,end,vi,ch,colors,vs,ve){
1855
2154
  rowClip(vi,ch,function(){
1856
2155
  ctx.strokeStyle=colors.rf;ctx.lineWidth=.8;ctx.beginPath();var hasRf=false;
1857
2156
  for(var bi=start;bi<end;bi++){
1858
- var rf=BL[bi].rf,phaseTime=rf&&(rf.pt||rf.t);if(!rf||!rf.p||!phaseTime||rf.s+rf.d<vs||rf.s>ve)continue;
2157
+ var rf=blockAt(BL,bi).rf,phaseTime=rf&&(rf.pt||rf.t);if(!rf||!rf.p||!phaseTime||rf.s+rf.d<vs||rf.s>ve)continue;
1859
2158
  var n=Math.min(phaseTime.length,rf.p.length);
1860
2159
  for(var i=0;i<n;i++){
1861
2160
  var sx=t2x(phaseTime[i]),sy=y+ch*.45-rf.p[i]*scale;
@@ -1885,18 +2184,51 @@ function drawPhaseBlocks(start,end,vi,ch,colors,vs,ve){
1885
2184
  });
1886
2185
  }
1887
2186
 
2187
+ /**
2188
+ * Draw one gradient channel as a min/max band.
2189
+ *
2190
+ * Used where the view holds more samples than pixels: each column is filled
2191
+ * between the extremes the waveform actually reached while crossing it, so the
2192
+ * shape stays truthful without asserting a path between samples that are not
2193
+ * adjacent. Empty columns break the shape rather than being drawn at zero.
2194
+ */
2195
+ function drawGradientBand(band,channelIndex,key,vi,ci,ch,color,vs,ve){
2196
+ var y=cy(vi),scale=ch*.4/channelRange(ci);
2197
+ var span=band.endSec-band.startSec;if(!(span>0))return;
2198
+ var perColumn=span/band.columns;
2199
+ var first=Math.max(0,Math.floor((vs-band.startSec)/perColumn));
2200
+ var last=Math.min(band.columns-1,Math.ceil((ve-band.startSec)/perColumn));
2201
+ rowClip(vi,ch,function(){
2202
+ ctx.fillStyle=color;ctx.globalAlpha=.55;ctx.beginPath();var drew=false;
2203
+ for(var c=first;c<=last;c++){
2204
+ var col=bandColumn(band,channelIndex,c);if(!col)continue;
2205
+ var x0=t2x(band.startSec+c*perColumn),x1=t2x(band.startSec+(c+1)*perColumn);
2206
+ var w=Math.max(x1-x0,.75);
2207
+ var yTop=y-col.hi*scale,yBottom=y-col.lo*scale;
2208
+ // A flat run would be an invisible zero-height rectangle.
2209
+ ctx.rect(x0,yTop,w,Math.max(yBottom-yTop,.75));drew=true;
2210
+ gradViewPointCount++;
2211
+ }
2212
+ if(drew)ctx.fill();
2213
+ ctx.globalAlpha=1;
2214
+ });
2215
+ }
2216
+
1888
2217
  function drawGradientBlocks(start,end,key,vi,ci,ch,color,vs,ve){
1889
2218
  var y=cy(vi),scale=ch*.4/channelRange(ci);
1890
2219
  rowClip(vi,ch,function(){
1891
2220
  ctx.strokeStyle=color;ctx.lineWidth=1;ctx.beginPath();var hasPath=false;
1892
2221
  for(var bi=start;bi<end;bi++){
1893
- var g=BL[bi][key];if(!g||g.ty==='none'||!g.t||!g.w||g.t.length<2)continue;
2222
+ var g=blockAt(BL,bi)[key];if(!g||g.ty==='none'||!g.t||!g.w||g.t.length<2)continue;
1894
2223
  var n=Math.min(g.t.length,g.w.length);
1895
2224
  if(n<2)continue;
1896
2225
  if(g.t[n-1]<vs||g.t[0]>ve)continue;
1897
2226
  for(var i=0;i<n;i++){
1898
2227
  var sx=t2x(g.t[i]),sy=y-g.w[i]*scale;
1899
2228
  if(i===0)ctx.moveTo(sx,sy);else ctx.lineTo(sx,sy);
2229
+ // Counted per visible point, not per event: an over-reduced waveform
2230
+ // still draws a whole long event, it just has almost nothing in view.
2231
+ if(g.t[i]>=vs&&g.t[i]<=ve)gradViewPointCount++;
1900
2232
  }
1901
2233
  hasPath=true;
1902
2234
  }
@@ -4823,7 +5155,16 @@ var SeqEyesPanel = (function () {
4823
5155
  if (e.key === 'Escape' && isFinite(markerTimeSec)) { setMarkerTime(NaN); syncControls(); }
4824
5156
  });
4825
5157
  document.addEventListener('visibilitychange', function () {
4826
- if (document.hidden) stopPlayback();
5158
+ if (document.hidden) {
5159
+ // Web Audio can keep rendering in a background tab, but animation frames
5160
+ // cannot. Preserve the audition and suspend only its visual updates.
5161
+ stopPlayheadLoop();
5162
+ return;
5163
+ }
5164
+ if (panelMode !== 'spectrogram') return;
5165
+ if (SeqEyesAudio.isPlaying()) startPlayheadLoop();
5166
+ syncTransport();
5167
+ render();
4827
5168
  });
4828
5169
  window.addEventListener('pagehide', function () { SeqEyesAudio.dispose(); });
4829
5170
  window.addEventListener('resize', function () { if (panelMode === 'spectrogram') resize(); });
File without changes