seqeyes-python 0.3.1__tar.gz → 0.3.2__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.1 → seqeyes_python-0.3.2}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/resources/pulseq-bundle.js +369 -134
  5. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/resources/viewer.html +56 -1
  6. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/resources/webview-bundle.js +49 -1
  7. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/.gitignore +0 -0
  8. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/README.md +0 -0
  9. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/examples/demo.ipynb +0 -0
  10. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/examples/demo.py +0 -0
  11. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/_plot.py +0 -0
  12. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/src/seqeyes/_renderer.py +0 -0
  13. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/conftest.py +0 -0
  14. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/debug_full.html +0 -0
  15. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/debug_minimal.html +0 -0
  16. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/debug_viewer.html +0 -0
  17. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/debug_viewer.py +0 -0
  18. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/integration_test.py +0 -0
  19. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/tests/test_plot.py +0 -0
  20. {seqeyes_python-0.3.1 → seqeyes_python-0.3.2}/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.1
3
+ Version: 0.3.2
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.1"
7
+ version = "0.3.2"
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.1"
32
+ __version__ = "0.3.2"
@@ -24,7 +24,11 @@ var Pulseq = (() => {
24
24
  var pulseq_browser_exports = {};
25
25
  __export(pulseq_browser_exports, {
26
26
  INTERACTIVE_COMPUTE_LIMITS: () => INTERACTIVE_COMPUTE_LIMITS,
27
+ MAX_RF_RESPONSE_BANDS: () => MAX_RF_RESPONSE_BANDS,
28
+ MAX_RF_RESPONSE_FFT_POINTS: () => MAX_RF_RESPONSE_FFT_POINTS,
29
+ MAX_RF_RESPONSE_SAMPLES: () => MAX_RF_RESPONSE_SAMPLES,
27
30
  PACKAGE_VERSION: () => PACKAGE_VERSION,
31
+ analyzeRfResponse: () => analyzeRfResponse,
28
32
  audioBudgetRefusal: () => audioBudgetRefusal,
29
33
  calculateKspace: () => calculateKspace,
30
34
  calculateM1: () => calculateM1,
@@ -47,6 +51,7 @@ var Pulseq = (() => {
47
51
  estimateDerivedCost: () => estimateDerivedCost,
48
52
  estimateKspaceCost: () => estimateKspaceCost,
49
53
  estimateKspacePeakMemoryBytes: () => estimateKspacePeakMemoryBytes,
54
+ estimateRfCarrierAreaDeg: () => estimateRfCarrierAreaDeg,
50
55
  estimateSequenceKspaceCost: () => estimateSequenceKspaceCost,
51
56
  estimateSpectrogramCost: () => estimateSpectrogramCost,
52
57
  exportKspaceArtifacts: () => exportKspaceArtifacts,
@@ -81,7 +86,7 @@ var Pulseq = (() => {
81
86
  });
82
87
 
83
88
  // package.json
84
- var version = "0.3.1";
89
+ var version = "0.3.2";
85
90
 
86
91
  // src/pulseq/decompressor.ts
87
92
  function decompressShape(compressed, numSamples) {
@@ -1522,6 +1527,334 @@ var Pulseq = (() => {
1522
1527
  return DEFAULT_B0_T;
1523
1528
  }
1524
1529
 
1530
+ // src/pulseq/fft.ts
1531
+ var twiddleCache = /* @__PURE__ */ new Map();
1532
+ var hannCache = /* @__PURE__ */ new Map();
1533
+ function isPowerOfTwo(n) {
1534
+ return n > 0 && (n & n - 1) === 0;
1535
+ }
1536
+ function nextPowerOfTwo(n) {
1537
+ if (n <= 1) return 1;
1538
+ let p = 1;
1539
+ while (p < n) p *= 2;
1540
+ return p;
1541
+ }
1542
+ function previousPowerOfTwo(n) {
1543
+ if (n <= 1) return 1;
1544
+ let p = 1;
1545
+ while (p * 2 <= n) p *= 2;
1546
+ return p;
1547
+ }
1548
+ function getTwiddles(n) {
1549
+ const cached = twiddleCache.get(n);
1550
+ if (cached) return cached;
1551
+ if (!isPowerOfTwo(n)) throw new Error(`FFT size must be a power of two, got ${n}`);
1552
+ const cos = new Float64Array(n / 2);
1553
+ const sin = new Float64Array(n / 2);
1554
+ for (let i = 0; i < n / 2; i++) {
1555
+ const angle = -2 * Math.PI * i / n;
1556
+ cos[i] = Math.cos(angle);
1557
+ sin[i] = Math.sin(angle);
1558
+ }
1559
+ const bits = Math.round(Math.log2(n));
1560
+ const reverse = new Uint32Array(n);
1561
+ for (let i = 0; i < n; i++) {
1562
+ let r = 0;
1563
+ for (let b = 0; b < bits; b++) if (i & 1 << b) r |= 1 << bits - 1 - b;
1564
+ reverse[i] = r;
1565
+ }
1566
+ const table = { cos, sin, reverse };
1567
+ twiddleCache.set(n, table);
1568
+ return table;
1569
+ }
1570
+ function fftInPlace(re, im, n) {
1571
+ const { cos, sin, reverse } = getTwiddles(n);
1572
+ for (let i = 0; i < n; i++) {
1573
+ const j = reverse[i];
1574
+ if (j > i) {
1575
+ let tmp = re[i];
1576
+ re[i] = re[j];
1577
+ re[j] = tmp;
1578
+ tmp = im[i];
1579
+ im[i] = im[j];
1580
+ im[j] = tmp;
1581
+ }
1582
+ }
1583
+ for (let size = 2; size <= n; size *= 2) {
1584
+ const half = size / 2;
1585
+ const step = n / size;
1586
+ for (let start = 0; start < n; start += size) {
1587
+ for (let k = 0; k < half; k++) {
1588
+ const twiddleIndex = k * step;
1589
+ const wr = cos[twiddleIndex];
1590
+ const wi = sin[twiddleIndex];
1591
+ const a = start + k;
1592
+ const b = a + half;
1593
+ const xr = re[b] * wr - im[b] * wi;
1594
+ const xi = re[b] * wi + im[b] * wr;
1595
+ re[b] = re[a] - xr;
1596
+ im[b] = im[a] - xi;
1597
+ re[a] += xr;
1598
+ im[a] += xi;
1599
+ }
1600
+ }
1601
+ }
1602
+ }
1603
+ function realFFTPairMagnitude(a, b, n, scratchRe, scratchIm, outA, outB) {
1604
+ for (let i = 0; i < n; i++) {
1605
+ scratchRe[i] = a[i];
1606
+ scratchIm[i] = b[i];
1607
+ }
1608
+ fftInPlace(scratchRe, scratchIm, n);
1609
+ const half = n >> 1;
1610
+ for (let k = 0; k <= half; k++) {
1611
+ const j = (n - k) % n;
1612
+ const zr = scratchRe[k], zi = scratchIm[k];
1613
+ const cr = scratchRe[j], ci = -scratchIm[j];
1614
+ const ar = 0.5 * (zr + cr);
1615
+ const ai = 0.5 * (zi + ci);
1616
+ const br = 0.5 * (zi - ci);
1617
+ const bi = -0.5 * (zr - cr);
1618
+ outA[k] = Math.sqrt(ar * ar + ai * ai);
1619
+ outB[k] = Math.sqrt(br * br + bi * bi);
1620
+ }
1621
+ }
1622
+ function realFFTMagnitude(a, n, scratchRe, scratchIm, out) {
1623
+ for (let i = 0; i < n; i++) {
1624
+ scratchRe[i] = a[i];
1625
+ scratchIm[i] = 0;
1626
+ }
1627
+ fftInPlace(scratchRe, scratchIm, n);
1628
+ const half = n >> 1;
1629
+ for (let k = 0; k <= half; k++) {
1630
+ const re = scratchRe[k], im = scratchIm[k];
1631
+ out[k] = Math.sqrt(re * re + im * im);
1632
+ }
1633
+ }
1634
+ function hannWindow(n) {
1635
+ const cached = hannCache.get(n);
1636
+ if (cached) return cached;
1637
+ const w = new Float64Array(n);
1638
+ for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * (i + 1) / n));
1639
+ hannCache.set(n, w);
1640
+ return w;
1641
+ }
1642
+ function windowCoherentGain(w) {
1643
+ let sum = 0;
1644
+ for (let i = 0; i < w.length; i++) sum += w[i];
1645
+ return sum;
1646
+ }
1647
+
1648
+ // src/pulseq/rfResponse.ts
1649
+ var MAX_RF_RESPONSE_FFT_POINTS = 131072;
1650
+ var MAX_RF_RESPONSE_SAMPLES = 131072;
1651
+ var MAX_RF_RESPONSE_BANDS = 8;
1652
+ var MIN_FFT_POINTS = 64;
1653
+ var ZERO_PAD_FACTOR = 4;
1654
+ var DOMINANT_BAND_FRACTION = 0.5;
1655
+ var MIN_DOMINANT_AREA_DEG = 0.01;
1656
+ var DEG_PER_CYCLE = 360;
1657
+ var TAU = 2 * Math.PI;
1658
+ function analyzeRfResponse(rf, seq, classifiedUse = rf.use) {
1659
+ if (rfSampleCount(rf, seq) > MAX_RF_RESPONSE_SAMPLES) {
1660
+ return {
1661
+ carrierAreaDeg: estimateRfCarrierAreaDeg(rf, seq),
1662
+ bands: [],
1663
+ spectrumAnalyzed: false,
1664
+ limited: true
1665
+ };
1666
+ }
1667
+ const samples = buildComplexRfSamples(rf, seq);
1668
+ const carrierAreaDeg = frequencyResolvedAreaDeg(samples, 0);
1669
+ const normalizedUse = classifiedUse.toLowerCase();
1670
+ const inversion = normalizedUse === "i" || normalizedUse === "inversion";
1671
+ let offsets;
1672
+ let spectrumAnalyzed = false;
1673
+ let limited = false;
1674
+ if (inversion) {
1675
+ offsets = [0];
1676
+ } else if (samples.uniform && samples.real.length <= MAX_RF_RESPONSE_FFT_POINTS) {
1677
+ offsets = dominantBandOffsets(samples);
1678
+ spectrumAnalyzed = true;
1679
+ } else {
1680
+ offsets = [0];
1681
+ limited = true;
1682
+ }
1683
+ const bands = offsets.map((frequencyOffsetHz) => {
1684
+ const spectralAreaDeg = frequencyResolvedAreaDeg(samples, frequencyOffsetHz);
1685
+ const spinor = propagateSpinor(samples, frequencyOffsetHz);
1686
+ return {
1687
+ frequencyOffsetHz,
1688
+ spectralAreaDeg,
1689
+ polarFlipDeg: spinor.polarFlipDeg,
1690
+ mz: spinor.mz
1691
+ };
1692
+ });
1693
+ return { carrierAreaDeg, bands, spectrumAnalyzed, limited };
1694
+ }
1695
+ function estimateRfCarrierAreaDeg(rf, seq) {
1696
+ const magnitude = seq.shapes.get(rf.magShapeId);
1697
+ if (!magnitude || magnitude.numSamples < 1) return 0;
1698
+ const phase = seq.shapes.get(rf.phaseShapeId);
1699
+ const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
1700
+ const count = rfSampleCount(rf, seq);
1701
+ const raster = seq.rasterTimes.rfRaster;
1702
+ let realArea = 0;
1703
+ let imaginaryArea = 0;
1704
+ for (let index = 0; index < count; index++) {
1705
+ const sampleTime = time ? time.samples[index] * raster : (index + 0.5) * raster;
1706
+ const nextTime = time && index + 1 < count ? time.samples[index + 1] * raster : sampleTime + raster;
1707
+ const width = nextTime - sampleTime;
1708
+ const amplitude = rf.amplitude * magnitude.samples[index];
1709
+ const phaseRad = TAU * (phase?.samples[index] ?? 0);
1710
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(amplitude) || !Number.isFinite(phaseRad)) continue;
1711
+ realArea += amplitude * Math.cos(phaseRad) * width;
1712
+ imaginaryArea += amplitude * Math.sin(phaseRad) * width;
1713
+ }
1714
+ return DEG_PER_CYCLE * Math.hypot(realArea, imaginaryArea);
1715
+ }
1716
+ function rfSampleCount(rf, seq) {
1717
+ const magnitude = seq.shapes.get(rf.magShapeId);
1718
+ if (!magnitude) return 0;
1719
+ const phase = seq.shapes.get(rf.phaseShapeId);
1720
+ const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
1721
+ return Math.min(
1722
+ magnitude.numSamples,
1723
+ phase?.numSamples ?? magnitude.numSamples,
1724
+ time?.numSamples ?? magnitude.numSamples
1725
+ );
1726
+ }
1727
+ function buildComplexRfSamples(rf, seq) {
1728
+ const magnitude = seq.shapes.get(rf.magShapeId);
1729
+ if (!magnitude || magnitude.numSamples < 1) return emptySamples(seq.rasterTimes.rfRaster);
1730
+ const phase = seq.shapes.get(rf.phaseShapeId);
1731
+ const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
1732
+ const count = Math.min(
1733
+ magnitude.numSamples,
1734
+ phase?.numSamples ?? magnitude.numSamples,
1735
+ time?.numSamples ?? magnitude.numSamples
1736
+ );
1737
+ if (count < 1) return emptySamples(seq.rasterTimes.rfRaster);
1738
+ const raster = seq.rasterTimes.rfRaster;
1739
+ const real = new Float64Array(count);
1740
+ const imaginary = new Float64Array(count);
1741
+ const times = new Float64Array(count);
1742
+ const widths = new Float64Array(count);
1743
+ let uniform = !time;
1744
+ for (let index = 0; index < count; index++) {
1745
+ const sampleTime = time ? time.samples[index] * raster : (index + 0.5) * raster;
1746
+ const nextTime = time && index + 1 < count ? time.samples[index + 1] * raster : sampleTime + raster;
1747
+ const width = nextTime - sampleTime;
1748
+ const amplitude = rf.amplitude * magnitude.samples[index];
1749
+ const phaseRad = TAU * (phase?.samples[index] ?? 0);
1750
+ times[index] = Number.isFinite(sampleTime) ? sampleTime : 0;
1751
+ widths[index] = Number.isFinite(width) && width > 0 ? width : 0;
1752
+ real[index] = Number.isFinite(amplitude) && Number.isFinite(phaseRad) ? amplitude * Math.cos(phaseRad) : 0;
1753
+ imaginary[index] = Number.isFinite(amplitude) && Number.isFinite(phaseRad) ? amplitude * Math.sin(phaseRad) : 0;
1754
+ if (Math.abs(widths[index] - raster) > Math.max(1e-12, raster * 1e-6)) uniform = false;
1755
+ }
1756
+ return { real, imaginary, times, widths, uniform, dwell: raster };
1757
+ }
1758
+ function emptySamples(raster) {
1759
+ return {
1760
+ real: new Float64Array(0),
1761
+ imaginary: new Float64Array(0),
1762
+ times: new Float64Array(0),
1763
+ widths: new Float64Array(0),
1764
+ uniform: true,
1765
+ dwell: raster
1766
+ };
1767
+ }
1768
+ function frequencyResolvedAreaDeg(samples, frequencyOffsetHz) {
1769
+ let realArea = 0;
1770
+ let imaginaryArea = 0;
1771
+ for (let index = 0; index < samples.real.length; index++) {
1772
+ const angle = -TAU * frequencyOffsetHz * samples.times[index];
1773
+ const cosine = Math.cos(angle);
1774
+ const sine = Math.sin(angle);
1775
+ const width = samples.widths[index];
1776
+ realArea += (samples.real[index] * cosine - samples.imaginary[index] * sine) * width;
1777
+ imaginaryArea += (samples.real[index] * sine + samples.imaginary[index] * cosine) * width;
1778
+ }
1779
+ return DEG_PER_CYCLE * Math.hypot(realArea, imaginaryArea);
1780
+ }
1781
+ function dominantBandOffsets(samples) {
1782
+ const sampleCount = samples.real.length;
1783
+ if (sampleCount === 0 || !Number.isFinite(samples.dwell) || samples.dwell <= 0) return [0];
1784
+ const paddedTarget = sampleCount <= Math.floor(MAX_RF_RESPONSE_FFT_POINTS / ZERO_PAD_FACTOR) ? sampleCount * ZERO_PAD_FACTOR : sampleCount;
1785
+ const fftPoints = nextPowerOfTwo(Math.max(MIN_FFT_POINTS, paddedTarget));
1786
+ if (fftPoints > MAX_RF_RESPONSE_FFT_POINTS) return [0];
1787
+ const real = new Float64Array(fftPoints);
1788
+ const imaginary = new Float64Array(fftPoints);
1789
+ real.set(samples.real);
1790
+ imaginary.set(samples.imaginary);
1791
+ fftInPlace(real, imaginary, fftPoints);
1792
+ let peakAreaDeg = 0;
1793
+ for (let bin = 0; bin < fftPoints; bin++) {
1794
+ peakAreaDeg = Math.max(
1795
+ peakAreaDeg,
1796
+ DEG_PER_CYCLE * samples.dwell * Math.hypot(real[bin], imaginary[bin])
1797
+ );
1798
+ }
1799
+ if (!Number.isFinite(peakAreaDeg) || peakAreaDeg < MIN_DOMINANT_AREA_DEG) return [0];
1800
+ const threshold = Math.max(MIN_DOMINANT_AREA_DEG, peakAreaDeg * DOMINANT_BAND_FRACTION);
1801
+ const clusters = [];
1802
+ let active = null;
1803
+ const half = fftPoints / 2;
1804
+ const frequencyStep = 1 / (fftPoints * samples.dwell);
1805
+ for (let signedBin = -half; signedBin < half; signedBin++) {
1806
+ const bin = signedBin < 0 ? signedBin + fftPoints : signedBin;
1807
+ const areaDeg = DEG_PER_CYCLE * samples.dwell * Math.hypot(real[bin], imaginary[bin]);
1808
+ if (areaDeg >= threshold) {
1809
+ const weight = areaDeg * areaDeg;
1810
+ if (!active) active = { frequencySum: 0, weightSum: 0, peakAreaDeg: 0 };
1811
+ active.frequencySum += signedBin * frequencyStep * weight;
1812
+ active.weightSum += weight;
1813
+ active.peakAreaDeg = Math.max(active.peakAreaDeg, areaDeg);
1814
+ } else if (active) {
1815
+ clusters.push(active);
1816
+ active = null;
1817
+ }
1818
+ }
1819
+ if (active) clusters.push(active);
1820
+ const offsets = clusters.filter((cluster) => cluster.weightSum > 0).sort((left, right) => right.peakAreaDeg - left.peakAreaDeg).slice(0, MAX_RF_RESPONSE_BANDS).map((cluster) => cluster.frequencySum / cluster.weightSum).sort((left, right) => left - right);
1821
+ return offsets.length > 0 ? offsets : [0];
1822
+ }
1823
+ function propagateSpinor(samples, frequencyOffsetHz) {
1824
+ let stateAReal = 1;
1825
+ let stateAImaginary = 0;
1826
+ let stateBReal = 0;
1827
+ let stateBImaginary = 0;
1828
+ for (let index = 0; index < samples.real.length; index++) {
1829
+ const bx = samples.real[index];
1830
+ const by = samples.imaginary[index];
1831
+ const norm = Math.hypot(bx, by, frequencyOffsetHz);
1832
+ const width = samples.widths[index];
1833
+ if (!(norm > 0) || !(width > 0)) continue;
1834
+ const sine = Math.sin(Math.PI * norm * width);
1835
+ const localAReal = Math.cos(Math.PI * norm * width);
1836
+ const localAImaginary = -frequencyOffsetHz / norm * sine;
1837
+ const localBReal = by / norm * sine;
1838
+ const localBImaginary = -bx / norm * sine;
1839
+ const nextAReal = localAReal * stateAReal - localAImaginary * stateAImaginary - localBReal * stateBReal - localBImaginary * stateBImaginary;
1840
+ const nextAImaginary = localAReal * stateAImaginary + localAImaginary * stateAReal - localBReal * stateBImaginary + localBImaginary * stateBReal;
1841
+ const nextBReal = localBReal * stateAReal - localBImaginary * stateAImaginary + localAReal * stateBReal + localAImaginary * stateBImaginary;
1842
+ const nextBImaginary = localBReal * stateAImaginary + localBImaginary * stateAReal + localAReal * stateBImaginary - localAImaginary * stateBReal;
1843
+ stateAReal = nextAReal;
1844
+ stateAImaginary = nextAImaginary;
1845
+ stateBReal = nextBReal;
1846
+ stateBImaginary = nextBImaginary;
1847
+ }
1848
+ const aMagnitudeSquared = stateAReal * stateAReal + stateAImaginary * stateAImaginary;
1849
+ const bMagnitudeSquared = stateBReal * stateBReal + stateBImaginary * stateBImaginary;
1850
+ const normalization = aMagnitudeSquared + bMagnitudeSquared;
1851
+ const mz = normalization > 0 ? clamp((aMagnitudeSquared - bMagnitudeSquared) / normalization, -1, 1) : 1;
1852
+ return { mz, polarFlipDeg: Math.acos(mz) * 180 / Math.PI };
1853
+ }
1854
+ function clamp(value, minimum, maximum) {
1855
+ return Math.max(minimum, Math.min(maximum, value));
1856
+ }
1857
+
1525
1858
  // src/pulseq/decoder.ts
1526
1859
  var GAMMA_HZ_T2 = 42576e3;
1527
1860
  var DEFAULT_B0_T2 = 3;
@@ -1550,6 +1883,7 @@ var Pulseq = (() => {
1550
1883
  sequence: seq,
1551
1884
  blockStartTimes,
1552
1885
  classifiedRfUses: classifyRfUses(seq),
1886
+ rfResponseCache: /* @__PURE__ */ new Map(),
1553
1887
  triggerCache: /* @__PURE__ */ new Map(),
1554
1888
  ncoCache: /* @__PURE__ */ new Map()
1555
1889
  };
@@ -1568,7 +1902,15 @@ var Pulseq = (() => {
1568
1902
  const db = { index: block.num, duration: dur, startTime: cumulative };
1569
1903
  if (block.rfId > 0) {
1570
1904
  const rf = seq.rfs.get(block.rfId);
1571
- if (rf) db.rf = decodeRF(seq, rf, cumulative, dur, context.classifiedRfUses[i]);
1905
+ if (rf) {
1906
+ const use = context.classifiedRfUses[i];
1907
+ let response = context.rfResponseCache.get(rf.id);
1908
+ if (!response) {
1909
+ response = analyzeRfResponse(rf, seq, use);
1910
+ context.rfResponseCache.set(rf.id, response);
1911
+ }
1912
+ db.rf = decodeRF(seq, rf, cumulative, dur, use, response);
1913
+ }
1572
1914
  }
1573
1915
  db.gx = decodeGradient(seq, block.gxId, cumulative, dur, "gx");
1574
1916
  db.gy = decodeGradient(seq, block.gyId, cumulative, dur, "gy");
@@ -1597,7 +1939,7 @@ var Pulseq = (() => {
1597
1939
  if (seq.versionCombined < VER_PRE_14) return block.dur * 1e-6;
1598
1940
  return block.dur * seq.rasterTimes.blockDurationRaster;
1599
1941
  }
1600
- function decodeRF(seq, rf, blockStart, _blockDur, classifiedUse) {
1942
+ function decodeRF(seq, rf, blockStart, _blockDur, classifiedUse, response) {
1601
1943
  const raster = seq.rasterTimes.rfRaster;
1602
1944
  const rfDelay = rf.delay * 1e-6;
1603
1945
  const rfStart = blockStart + rfDelay;
@@ -1632,6 +1974,7 @@ var Pulseq = (() => {
1632
1974
  magnitude: amp,
1633
1975
  phase,
1634
1976
  amplitude: rf.amplitude,
1977
+ response,
1635
1978
  freqOffset: freqFull,
1636
1979
  phaseOffset: phaseFull,
1637
1980
  use
@@ -2143,7 +2486,7 @@ var Pulseq = (() => {
2143
2486
  // src/editor/blockTransport.ts
2144
2487
  var MAX_DISPLAY_PTS = 500;
2145
2488
  var MIN_DISPLAY_PTS = 8;
2146
- var TAU = 2 * Math.PI;
2489
+ var TAU2 = 2 * Math.PI;
2147
2490
  var EMPTY_PAIR = { o: 0, n: 0 };
2148
2491
  var CountingSink = class {
2149
2492
  constructor(cap) {
@@ -2167,7 +2510,7 @@ var Pulseq = (() => {
2167
2510
  /** Hoisted so the hot path allocates one closure, not one per waveform. */
2168
2511
  __publicField(this, "emit", (time, value) => {
2169
2512
  this.times[this.cursor] = time;
2170
- this.values[this.cursor] = this.wrapPhase ? (value % TAU + TAU) % TAU : value;
2513
+ this.values[this.cursor] = this.wrapPhase ? (value % TAU2 + TAU2) % TAU2 : value;
2171
2514
  this.cursor++;
2172
2515
  });
2173
2516
  }
@@ -2309,6 +2652,12 @@ var Pulseq = (() => {
2309
2652
  }
2310
2653
  function packRf(rf, magnitude, phase) {
2311
2654
  const metrics = waveformMagnitudeMetrics(rf.timePoints, rf.magnitude);
2655
+ const responseBands = rf.response.bands.map((band) => [
2656
+ band.frequencyOffsetHz,
2657
+ band.spectralAreaDeg,
2658
+ band.polarFlipDeg,
2659
+ band.mz
2660
+ ]);
2312
2661
  return {
2313
2662
  s: rf.startTime,
2314
2663
  d: rf.duration,
@@ -2321,6 +2670,10 @@ var Pulseq = (() => {
2321
2670
  ar: metrics.area,
2322
2671
  bp: metrics.blockPulse,
2323
2672
  a: rf.amplitude,
2673
+ a0: rf.response.carrierAreaDeg,
2674
+ rb: responseBands,
2675
+ rs: rf.response.spectrumAnalyzed ? 1 : 0,
2676
+ rl: rf.response.limited ? 1 : 0,
2324
2677
  fo: rf.freqOffset,
2325
2678
  po: rf.phaseOffset,
2326
2679
  u: rf.use || "u"
@@ -3919,124 +4272,6 @@ var Pulseq = (() => {
3919
4272
  return outside;
3920
4273
  }
3921
4274
 
3922
- // src/pulseq/fft.ts
3923
- var twiddleCache = /* @__PURE__ */ new Map();
3924
- var hannCache = /* @__PURE__ */ new Map();
3925
- function isPowerOfTwo(n) {
3926
- return n > 0 && (n & n - 1) === 0;
3927
- }
3928
- function nextPowerOfTwo(n) {
3929
- if (n <= 1) return 1;
3930
- let p = 1;
3931
- while (p < n) p *= 2;
3932
- return p;
3933
- }
3934
- function previousPowerOfTwo(n) {
3935
- if (n <= 1) return 1;
3936
- let p = 1;
3937
- while (p * 2 <= n) p *= 2;
3938
- return p;
3939
- }
3940
- function getTwiddles(n) {
3941
- const cached = twiddleCache.get(n);
3942
- if (cached) return cached;
3943
- if (!isPowerOfTwo(n)) throw new Error(`FFT size must be a power of two, got ${n}`);
3944
- const cos = new Float64Array(n / 2);
3945
- const sin = new Float64Array(n / 2);
3946
- for (let i = 0; i < n / 2; i++) {
3947
- const angle = -2 * Math.PI * i / n;
3948
- cos[i] = Math.cos(angle);
3949
- sin[i] = Math.sin(angle);
3950
- }
3951
- const bits = Math.round(Math.log2(n));
3952
- const reverse = new Uint32Array(n);
3953
- for (let i = 0; i < n; i++) {
3954
- let r = 0;
3955
- for (let b = 0; b < bits; b++) if (i & 1 << b) r |= 1 << bits - 1 - b;
3956
- reverse[i] = r;
3957
- }
3958
- const table = { cos, sin, reverse };
3959
- twiddleCache.set(n, table);
3960
- return table;
3961
- }
3962
- function fftInPlace(re, im, n) {
3963
- const { cos, sin, reverse } = getTwiddles(n);
3964
- for (let i = 0; i < n; i++) {
3965
- const j = reverse[i];
3966
- if (j > i) {
3967
- let tmp = re[i];
3968
- re[i] = re[j];
3969
- re[j] = tmp;
3970
- tmp = im[i];
3971
- im[i] = im[j];
3972
- im[j] = tmp;
3973
- }
3974
- }
3975
- for (let size = 2; size <= n; size *= 2) {
3976
- const half = size / 2;
3977
- const step = n / size;
3978
- for (let start = 0; start < n; start += size) {
3979
- for (let k = 0; k < half; k++) {
3980
- const twiddleIndex = k * step;
3981
- const wr = cos[twiddleIndex];
3982
- const wi = sin[twiddleIndex];
3983
- const a = start + k;
3984
- const b = a + half;
3985
- const xr = re[b] * wr - im[b] * wi;
3986
- const xi = re[b] * wi + im[b] * wr;
3987
- re[b] = re[a] - xr;
3988
- im[b] = im[a] - xi;
3989
- re[a] += xr;
3990
- im[a] += xi;
3991
- }
3992
- }
3993
- }
3994
- }
3995
- function realFFTPairMagnitude(a, b, n, scratchRe, scratchIm, outA, outB) {
3996
- for (let i = 0; i < n; i++) {
3997
- scratchRe[i] = a[i];
3998
- scratchIm[i] = b[i];
3999
- }
4000
- fftInPlace(scratchRe, scratchIm, n);
4001
- const half = n >> 1;
4002
- for (let k = 0; k <= half; k++) {
4003
- const j = (n - k) % n;
4004
- const zr = scratchRe[k], zi = scratchIm[k];
4005
- const cr = scratchRe[j], ci = -scratchIm[j];
4006
- const ar = 0.5 * (zr + cr);
4007
- const ai = 0.5 * (zi + ci);
4008
- const br = 0.5 * (zi - ci);
4009
- const bi = -0.5 * (zr - cr);
4010
- outA[k] = Math.sqrt(ar * ar + ai * ai);
4011
- outB[k] = Math.sqrt(br * br + bi * bi);
4012
- }
4013
- }
4014
- function realFFTMagnitude(a, n, scratchRe, scratchIm, out) {
4015
- for (let i = 0; i < n; i++) {
4016
- scratchRe[i] = a[i];
4017
- scratchIm[i] = 0;
4018
- }
4019
- fftInPlace(scratchRe, scratchIm, n);
4020
- const half = n >> 1;
4021
- for (let k = 0; k <= half; k++) {
4022
- const re = scratchRe[k], im = scratchIm[k];
4023
- out[k] = Math.sqrt(re * re + im * im);
4024
- }
4025
- }
4026
- function hannWindow(n) {
4027
- const cached = hannCache.get(n);
4028
- if (cached) return cached;
4029
- const w = new Float64Array(n);
4030
- for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * (i + 1) / n));
4031
- hannCache.set(n, w);
4032
- return w;
4033
- }
4034
- function windowCoherentGain(w) {
4035
- let sum = 0;
4036
- for (let i = 0; i < w.length; i++) sum += w[i];
4037
- return sum;
4038
- }
4039
-
4040
4275
  // src/pulseq/decimator.ts
4041
4276
  var DECIMATION_STOPBAND_DB = 80;
4042
4277
  var planCache = /* @__PURE__ */ new Map();
@@ -4151,24 +4386,24 @@ var Pulseq = (() => {
4151
4386
  });
4152
4387
  function resolveSpectrogramParams(params) {
4153
4388
  const merged = { ...DEFAULT_SPECTROGRAM_PARAMS, ...params ?? {} };
4154
- const fMaxHz = clamp(finite(merged.fMaxHz, 3e3), 1, 5e6);
4389
+ const fMaxHz = clamp2(finite(merged.fMaxHz, 3e3), 1, 5e6);
4155
4390
  return {
4156
4391
  source: merged.source === "dGdt" ? "dGdt" : "G",
4157
- fMinHz: clamp(finite(merged.fMinHz, 0), 0, fMaxHz - 1),
4392
+ fMinHz: clamp2(finite(merged.fMinHz, 0), 0, fMaxHz - 1),
4158
4393
  fMaxHz,
4159
4394
  windowSamples: Math.max(0, Math.floor(finite(merged.windowSamples, 0))),
4160
- overlap: clamp(finite(merged.overlap, 0.75), 0, 0.9375),
4161
- oversample: clamp(Math.round(finite(merged.oversample, 3)), 1, 4),
4162
- targetColumns: clamp(Math.round(finite(merged.targetColumns, 256)), 64, 512),
4395
+ overlap: clamp2(finite(merged.overlap, 0.75), 0, 0.9375),
4396
+ oversample: clamp2(Math.round(finite(merged.oversample, 3)), 1, 4),
4397
+ targetColumns: clamp2(Math.round(finite(merged.targetColumns, 256)), 64, 512),
4163
4398
  normalize: merged.normalize !== false
4164
4399
  };
4165
4400
  }
4166
4401
  function chooseWindowSamples(params, decimatedDt, viewDurationSec, warnings) {
4167
- if (params.windowSamples > 0) return clamp(params.windowSamples, MIN_WINDOW_SAMPLES, 4096);
4402
+ if (params.windowSamples > 0) return clamp2(params.windowSamples, MIN_WINDOW_SAMPLES, 4096);
4168
4403
  const dfTarget = Math.max(20, (params.fMaxHz - params.fMinHz) / 64);
4169
- let nwin = clamp(nextPowerOfTwo(Math.round(1 / (dfTarget * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
4404
+ let nwin = clamp2(nextPowerOfTwo(Math.round(1 / (dfTarget * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
4170
4405
  if (nwin * decimatedDt > viewDurationSec / 3) {
4171
- const shrunk = clamp(previousPowerOfTwo(Math.floor(viewDurationSec / (3 * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
4406
+ const shrunk = clamp2(previousPowerOfTwo(Math.floor(viewDurationSec / (3 * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
4172
4407
  if (shrunk < nwin) {
4173
4408
  nwin = shrunk;
4174
4409
  const achievedDf = 1 / (nwin * decimatedDt);
@@ -4201,7 +4436,7 @@ var Pulseq = (() => {
4201
4436
  let windowSamples = chooseWindowSamples(params, decimatedDt, viewDuration, warnings);
4202
4437
  const tooShort = nDecimated < MIN_WINDOW_SAMPLES;
4203
4438
  if (windowSamples > nDecimated) {
4204
- windowSamples = clamp(previousPowerOfTwo(nDecimated), MIN_WINDOW_SAMPLES, 4096);
4439
+ windowSamples = clamp2(previousPowerOfTwo(nDecimated), MIN_WINDOW_SAMPLES, 4096);
4205
4440
  }
4206
4441
  let hop = Math.max(1, Math.round(windowSamples * (1 - params.overlap)));
4207
4442
  let columns = nDecimated >= windowSamples ? Math.floor((nDecimated - windowSamples) / hop) + 1 : 0;
@@ -4400,7 +4635,7 @@ var Pulseq = (() => {
4400
4635
  if (spec.nTime <= 0) return -1;
4401
4636
  if (!(spec.tStepSec > 0)) return 0;
4402
4637
  const raw = Math.round((timeSec - spec.tStartSec) / spec.tStepSec);
4403
- return clamp(raw, 0, spec.nTime - 1);
4638
+ return clamp2(raw, 0, spec.nTime - 1);
4404
4639
  }
4405
4640
  function computeGradientSpectrumSlice(spec, timeSec) {
4406
4641
  const col = spectrogramColumnAt(spec, timeSec);
@@ -4513,7 +4748,7 @@ var Pulseq = (() => {
4513
4748
  }
4514
4749
  return { frequencyHz, gx: accX, gy: accY, gz: accZ, rss, segments };
4515
4750
  }
4516
- function clamp(value, lo, hi) {
4751
+ function clamp2(value, lo, hi) {
4517
4752
  return Math.max(lo, Math.min(hi, value));
4518
4753
  }
4519
4754
  function finite(value, fallback) {
@@ -1127,6 +1127,55 @@ function appendDerivedTooltipLines(lines,ct){
1127
1127
  }
1128
1128
  }
1129
1129
 
1130
+ function fmtFlipAngle(value){
1131
+ if(!isFinite(value))return null;
1132
+ var absolute=Math.abs(value),digits=absolute<10?2:(absolute<100?1:0);
1133
+ return value.toFixed(digits)+'°';
1134
+ }
1135
+ function fmtRfFrequency(value){
1136
+ var absolute=Math.abs(value),sign=value<0?'−':'+';
1137
+ if(absolute>=1000)return sign+(absolute/1000).toFixed(absolute<10000?2:1)+' kHz';
1138
+ return sign+absolute.toFixed(absolute<100?1:0)+' Hz';
1139
+ }
1140
+ function rfBandMetric(bands,index){
1141
+ if(!bands.length)return null;
1142
+ var min=Infinity,max=-Infinity,total=0;
1143
+ for(var i=0;i<bands.length;i++){var value=bands[i][index];if(!isFinite(value))return null;min=Math.min(min,value);max=Math.max(max,value);total+=value;}
1144
+ var mean=total/bands.length;
1145
+ if(max-min<=Math.max(.25,Math.abs(mean)*.01))return fmtFlipAngle(mean);
1146
+ return fmtFlipAngle(min)+'–'+fmtFlipAngle(max);
1147
+ }
1148
+ function rfBandOffsets(bands){
1149
+ if(!bands.length)return '';
1150
+ if(bands.length===2){
1151
+ var left=bands[0][0],right=bands[1][0],scale=Math.max(1,Math.abs(left),Math.abs(right));
1152
+ if(Math.abs(left+right)<=.02*scale)return '±'+fmtRfFrequency(Math.abs(right)).slice(1);
1153
+ }
1154
+ if(bands.length<=3)return bands.map(function(v){return fmtRfFrequency(v[0]);}).join(',');
1155
+ return fmtRfFrequency(bands[0][0])+'…'+fmtRfFrequency(bands[bands.length-1][0]);
1156
+ }
1157
+ function rfResponseSummary(rf){
1158
+ var bands=(rf.rb||[]).filter(function(v){return v&&v.length>=4&&isFinite(v[0])&&isFinite(v[1])&&isFinite(v[2])&&isFinite(v[3]);});
1159
+ var area0=fmtFlipAngle(rf.a0),use=(rf.u||'u').toLowerCase();
1160
+ if(!bands.length)return area0?' area₀='+area0:'';
1161
+ var polar=rfBandMetric(bands,2),spectral=rfBandMetric(bands,1);
1162
+ if(use==='i'||use==='inversion'){
1163
+ var center=bands[0];for(var i=1;i<bands.length;i++)if(Math.abs(bands[i][0])<Math.abs(center[0]))center=bands[i];
1164
+ var efficiency=Math.max(0,Math.min(1,-center[3]))*100;
1165
+ return ' Inv≈'+efficiency.toFixed(1)+'% θz≈'+fmtFlipAngle(center[2])+(area0?' area₀='+area0:'');
1166
+ }
1167
+ if(bands.length>1){
1168
+ var prefix=(use==='e'||use==='excitation')?'FA≈':'θz≈';
1169
+ var summary=' MB'+bands.length+' '+prefix+polar;
1170
+ var meanPolar=0,meanArea=0;for(var j=0;j<bands.length;j++){meanPolar+=bands[j][2];meanArea+=bands[j][1];}
1171
+ if(Math.abs(meanArea-meanPolar)/bands.length>.5)summary+=' (area≈'+spectral+')';
1172
+ return summary+' Δf='+rfBandOffsets(bands);
1173
+ }
1174
+ var single=' '+((use==='e'||use==='excitation')?'FA≈':'θz≈')+polar;
1175
+ if(Math.abs(bands[0][0])>1)single+=' Δf='+fmtRfFrequency(bands[0][0]);
1176
+ return single;
1177
+ }
1178
+
1130
1179
  // Tooltip
1131
1180
  function showTooltipAt(cx,cy,ct){
1132
1181
  var ch=cH(),vc=visChannels(),vi2=Math.floor((cy-mc.getBoundingClientRect().top-M.t)/ch);
@@ -1136,7 +1185,7 @@ function showTooltipAt(cx,cy,ct){
1136
1185
  if(found){
1137
1186
  var blockDt=Math.max(0,Math.min(found.d,ct-found.s));
1138
1187
  lines=['Block #'+found.i,'Time: '+fmtT(timeConv(ct))+' '+timeUnitStr()+' (Δ '+fmtT(timeConv(blockDt))+' / dur: '+fmtT(timeConv(found.d))+' '+timeUnitStr()+')'];
1139
- if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz fo='+(found.rf.fo||0).toFixed(0)+' Hz φ₀='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
1188
+ if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz'+rfResponseSummary(found.rf)+' fo='+(found.rf.fo||0).toFixed(0)+' Hz φ₀='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
1140
1189
  if(found.gx&&found.gx.ty!=='none')lines.push('Gx: '+fmtG(found.gx,ct));
1141
1190
  if(found.gy&&found.gy.ty!=='none')lines.push('Gy: '+fmtG(found.gy,ct));
1142
1191
  if(found.gz&&found.gz.ty!=='none')lines.push('Gz: '+fmtG(found.gz,ct));
@@ -1345,6 +1394,12 @@ function convertBlock(blk) {
1345
1394
  s: blk.rf.startTime || 0,
1346
1395
  d: blk.rf.duration || 0,
1347
1396
  a: blk.rf.amplitude || 0,
1397
+ a0: blk.rf.response ? blk.rf.response.carrierAreaDeg : 0,
1398
+ rb: blk.rf.response ? blk.rf.response.bands.map(function(v) {
1399
+ return [v.frequencyOffsetHz, v.spectralAreaDeg, v.polarFlipDeg, v.mz];
1400
+ }) : [],
1401
+ rs: blk.rf.response && blk.rf.response.spectrumAnalyzed ? 1 : 0,
1402
+ rl: blk.rf.response && blk.rf.response.limited ? 1 : 0,
1348
1403
  fo: blk.rf.freqOffset || 0,
1349
1404
  po: ((blk.rf.phaseOffset || 0) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI),
1350
1405
  t: blk.rf.timePoints || [],
@@ -5566,6 +5566,54 @@ function placeTooltip(cx,cy){
5566
5566
  var top=cy+12;if(top+r.height+pad>vh)top=cy-r.height-12;if(top<pad)top=pad;
5567
5567
  tt.style.left=left+'px';tt.style.top=top+'px';
5568
5568
  }
5569
+ function fmtFlipAngle(value){
5570
+ if(!isFinite(value))return null;
5571
+ var absolute=Math.abs(value),digits=absolute<10?2:(absolute<100?1:0);
5572
+ return value.toFixed(digits)+'\u00b0';
5573
+ }
5574
+ function fmtRfFrequency(value){
5575
+ var absolute=Math.abs(value),sign=value<0?'\u2212':'+';
5576
+ if(absolute>=1000)return sign+(absolute/1000).toFixed(absolute<10000?2:1)+' kHz';
5577
+ return sign+absolute.toFixed(absolute<100?1:0)+' Hz';
5578
+ }
5579
+ function rfBandMetric(bands,index){
5580
+ if(!bands.length)return null;
5581
+ var min=Infinity,max=-Infinity,total=0;
5582
+ for(var i=0;i<bands.length;i++){var value=bands[i][index];if(!isFinite(value))return null;min=Math.min(min,value);max=Math.max(max,value);total+=value;}
5583
+ var mean=total/bands.length;
5584
+ if(max-min<=Math.max(.25,Math.abs(mean)*.01))return fmtFlipAngle(mean);
5585
+ return fmtFlipAngle(min)+'\u2013'+fmtFlipAngle(max);
5586
+ }
5587
+ function rfBandOffsets(bands){
5588
+ if(!bands.length)return '';
5589
+ if(bands.length===2){
5590
+ var left=bands[0][0],right=bands[1][0],scale=Math.max(1,Math.abs(left),Math.abs(right));
5591
+ if(Math.abs(left+right)<=.02*scale)return '\u00b1'+fmtRfFrequency(Math.abs(right)).slice(1);
5592
+ }
5593
+ if(bands.length<=3)return bands.map(function(v){return fmtRfFrequency(v[0]);}).join(',');
5594
+ return fmtRfFrequency(bands[0][0])+'\u2026'+fmtRfFrequency(bands[bands.length-1][0]);
5595
+ }
5596
+ function rfResponseSummary(rf){
5597
+ var bands=(rf.rb||[]).filter(function(v){return v&&v.length>=4&&isFinite(v[0])&&isFinite(v[1])&&isFinite(v[2])&&isFinite(v[3]);});
5598
+ var area0=fmtFlipAngle(rf.a0),use=(rf.u||'u').toLowerCase();
5599
+ if(!bands.length)return area0?' area\u2080='+area0:'';
5600
+ var polar=rfBandMetric(bands,2),spectral=rfBandMetric(bands,1);
5601
+ if(use==='i'||use==='inversion'){
5602
+ var center=bands[0];for(var i=1;i<bands.length;i++)if(Math.abs(bands[i][0])<Math.abs(center[0]))center=bands[i];
5603
+ var efficiency=Math.max(0,Math.min(1,-center[3]))*100;
5604
+ return ' Inv\u2248'+efficiency.toFixed(1)+'% \u03b8z\u2248'+fmtFlipAngle(center[2])+(area0?' area\u2080='+area0:'');
5605
+ }
5606
+ if(bands.length>1){
5607
+ var prefix=(use==='e'||use==='excitation')?'FA\u2248':'\u03b8z\u2248';
5608
+ var summary=' MB'+bands.length+' '+prefix+polar;
5609
+ var meanPolar=0,meanArea=0;for(var j=0;j<bands.length;j++){meanPolar+=bands[j][2];meanArea+=bands[j][1];}
5610
+ if(Math.abs(meanArea-meanPolar)/bands.length>.5)summary+=' (area\u2248'+spectral+')';
5611
+ return summary+' \u0394f='+rfBandOffsets(bands);
5612
+ }
5613
+ var single=' '+((use==='e'||use==='excitation')?'FA\u2248':'\u03b8z\u2248')+polar;
5614
+ if(Math.abs(bands[0][0])>1)single+=' \u0394f='+fmtRfFrequency(bands[0][0]);
5615
+ return single;
5616
+ }
5569
5617
  function showTooltipAt(cx,cy,ct){
5570
5618
  var ch=cH(),vc=visChannels(),vi2=Math.floor((cy-mc.getBoundingClientRect().top-M.t)/ch);
5571
5619
  if(vi2>=0&&vi2<vc.length){
@@ -5574,7 +5622,7 @@ function showTooltipAt(cx,cy,ct){
5574
5622
  if(found){
5575
5623
  var blockDt=Math.max(0,Math.min(found.d,ct-found.s));
5576
5624
  lines=['Block #'+found.i,'Time: '+fmtT(timeConv(ct))+' '+timeUnitStr()+' (\u0394 '+fmtT(timeConv(blockDt))+' / dur: '+fmtT(timeConv(found.d))+' '+timeUnitStr()+')'];
5577
- if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz fo='+(found.rf.fo||0).toFixed(0)+' Hz \u03c6\u2080='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
5625
+ if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz'+rfResponseSummary(found.rf)+' fo='+(found.rf.fo||0).toFixed(0)+' Hz \u03c6\u2080='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
5578
5626
  if(found.gx&&found.gx.ty!=='none')lines.push('Gx: '+fmtG(found.gx,ct));
5579
5627
  if(found.gy&&found.gy.ty!=='none')lines.push('Gy: '+fmtG(found.gy,ct));
5580
5628
  if(found.gz&&found.gz.ty!=='none')lines.push('Gz: '+fmtG(found.gz,ct));
File without changes