seqeyes-python 0.3.9__tar.gz → 0.3.10__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 (21) hide show
  1. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/resources/pulseq-bundle.js +1 -1
  5. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/resources/seqeyes-labels.js +20 -4
  6. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/resources/webview-bundle.js +490 -54
  7. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/.gitignore +0 -0
  8. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/README.md +0 -0
  9. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/examples/demo.ipynb +0 -0
  10. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/examples/demo.py +0 -0
  11. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/_plot.py +0 -0
  12. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/_renderer.py +0 -0
  13. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/src/seqeyes/resources/viewer.html +0 -0
  14. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/conftest.py +0 -0
  15. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/debug_full.html +0 -0
  16. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/debug_minimal.html +0 -0
  17. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/debug_viewer.html +0 -0
  18. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/debug_viewer.py +0 -0
  19. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/integration_test.py +0 -0
  20. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/tests/test_plot.py +0 -0
  21. {seqeyes_python-0.3.9 → seqeyes_python-0.3.10}/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.9
3
+ Version: 0.3.10
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.9"
7
+ version = "0.3.10"
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.9"
32
+ __version__ = "0.3.10"
@@ -89,7 +89,7 @@ var Pulseq = (() => {
89
89
  });
90
90
 
91
91
  // package.json
92
- var version = "0.3.9";
92
+ var version = "0.3.10";
93
93
 
94
94
  // src/pulseq/decompressor.ts
95
95
  function decompressShape(compressed, numSamples) {
@@ -7,13 +7,29 @@
7
7
  the 2-D context, the time→x mapping and the row geometry. The Python
8
8
  viewer loads this file on its own, without the rest of the bundle.
9
9
 
10
- The DOM and localStorage are only touched inside functions, so the tests
11
- can run the shipped file in a bare VM context.
10
+ The DOM and storage are only touched inside functions, so the tests can
11
+ run the shipped file in a bare VM context.
12
12
 
13
13
  A "labels" argument is anything with `names` and `kinds`: the popup and the
14
14
  styles need nothing more, so ⚙ works before the per-ADC values arrive. */
15
15
  var SeqEyesLabels = (function () {
16
16
  var STORAGE_KEY = 'seqeyes.labelStyles.v1';
17
+
18
+ /* The Python viewer inlines this file on its own, so the bundle's
19
+ preferences gateway may not be there. Go through it when it is, so the
20
+ settings popover's master switch and Forget button cover marker styles
21
+ too, and fall back to raw storage when it is not. */
22
+ function prefsApi() { return (typeof SeqEyesPrefs !== 'undefined') ? SeqEyesPrefs : null; }
23
+ function readStored(key) {
24
+ var api = prefsApi();
25
+ if (api) return api.get(key);
26
+ try { return localStorage.getItem(key); } catch (_) { return null; }
27
+ }
28
+ function writeStored(key, value) {
29
+ var api = prefsApi();
30
+ if (api) { api.set(key, value); return; }
31
+ try { localStorage.setItem(key, value); } catch (_) {}
32
+ }
17
33
  // Tableau 10: distinct hues that stay readable on light and dark themes.
18
34
  var PALETTE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
19
35
  var SHAPES = ['circle', 'square', 'triangle', 'triangleDown', 'diamond', 'cross', 'plus'];
@@ -74,14 +90,14 @@ var SeqEyesLabels = (function () {
74
90
  if (overrides) return overrides;
75
91
  overrides = {};
76
92
  try {
77
- var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
93
+ var saved = JSON.parse(readStored(STORAGE_KEY) || '{}');
78
94
  if (saved && typeof saved === 'object') overrides = saved;
79
95
  } catch (_) {}
80
96
  return overrides;
81
97
  }
82
98
 
83
99
  function writeOverrides() {
84
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); } catch (_) {}
100
+ writeStored(STORAGE_KEY, JSON.stringify(overrides));
85
101
  }
86
102
 
87
103
  /* Defaults follow the label's position in its sequence, so one sequence's
@@ -1,5 +1,205 @@
1
1
  /* SeqEyes WebView Bundle — auto-generated, do not edit */
2
2
  "use strict";
3
+ /* ══ prefs.js ══ */
4
+ /* ═══════════════════════════════════════════════════════════════════════
5
+ Viewer preferences — one consent-gated gateway to localStorage
6
+
7
+ Every host (VS Code webview, standalone web page, MATLAB) keeps its
8
+ remembered settings under the `seqeyes.` prefix. Before this module there
9
+ were three near-identical copies of the get/set pair — kspace.js, panel.js
10
+ and the inline script in web/index.html — and none of them asked whether
11
+ the user wanted anything written down at all.
12
+
13
+ Two rules shape the API:
14
+
15
+ - Persistence is opt-out, not unconditional. `seqeyes.rememberSettings`
16
+ is the master switch. With it off, `set()` keeps the value in memory
17
+ so the control still behaves for the rest of the session and nothing
18
+ reaches the disk; turning it off purges every key this module owns.
19
+
20
+ - localStorage is only ever touched inside a function, so the file loads
21
+ in a bare `node:vm` context for the unit tests the same way labels.js
22
+ does. Nothing here runs at load time.
23
+ ═══════════════════════════════════════════════════════════════════════ */
24
+
25
+ var SeqEyesPrefs = (function () {
26
+ var PREFIX = 'seqeyes.';
27
+
28
+ /* Named so the standalone page's inline fork spells the keys the same way
29
+ the bundle does; a typo there would look like "my settings reset again". */
30
+ var KEYS = {
31
+ remember: 'seqeyes.rememberSettings',
32
+ theme: 'seqeyes.theme',
33
+ timeUnit: 'seqeyes.timeUnit',
34
+ gradUnit: 'seqeyes.gradUnit',
35
+ showBlocks: 'seqeyes.showBlocks',
36
+ panelWidth: 'seqeyes.panelWidth',
37
+ panelHeight: 'seqeyes.panelHeight',
38
+ noticesCollapsed: 'seqeyes.viewerNoticesCollapsed',
39
+ kspaceUnit: 'seqeyes.kspace.unit',
40
+ kspaceDotSize: 'seqeyes.kspace.dotSize',
41
+ kspaceProjection: 'seqeyes.kspace.projection',
42
+ ascName: 'seqeyes.asc.name',
43
+ ascText: 'seqeyes.asc.text'
44
+ };
45
+
46
+ /* A profile bigger than this is not worth fighting the quota over: the
47
+ whole origin gets about 5 MB, and a real Siemens gradient ASC is tens of
48
+ kilobytes. Anything this large means the picker found the wrong file. */
49
+ var ASC_MAX_CHARS = 2 * 1024 * 1024;
50
+
51
+ /* Values written while persistence is off, or refused by a full quota.
52
+ Keeping them is what lets someone switch units, decide they do want the
53
+ choice kept, and tick the box without redoing the work. */
54
+ var session = {};
55
+
56
+ function store() {
57
+ try { return (typeof localStorage !== 'undefined') ? localStorage : null; } catch (e) { return null; }
58
+ }
59
+ function rawGet(key) {
60
+ var s = store(); if (!s) return null;
61
+ try { return s.getItem(key); } catch (e) { return null; }
62
+ }
63
+ function rawSet(key, value) {
64
+ var s = store(); if (!s) return false;
65
+ try { s.setItem(key, value); return true; } catch (e) { return false; }
66
+ }
67
+ function rawRemove(key) {
68
+ var s = store(); if (!s) return;
69
+ try { s.removeItem(key); } catch (e) { /* private mode */ }
70
+ }
71
+
72
+ /* Default on. localStorage was already load-bearing here — theme, panel
73
+ size and every spectrogram parameter — so defaulting off would quietly
74
+ take away persistence people are already relying on. */
75
+ function enabled() { return rawGet(KEYS.remember) !== '0'; }
76
+
77
+ function get(key) {
78
+ if (enabled()) {
79
+ var stored = rawGet(key);
80
+ if (stored !== null) return stored;
81
+ }
82
+ return Object.prototype.hasOwnProperty.call(session, key) ? session[key] : null;
83
+ }
84
+
85
+ /* Returns whether the value reached the disk, which setAsc uses to decide
86
+ whether a restore will actually be possible next time.
87
+
88
+ A successful write drops any session copy instead of mirroring it: the
89
+ disk is then the single source of truth, so a key that later goes missing
90
+ reads as missing rather than being answered from a stale shadow. */
91
+ function set(key, value) {
92
+ var text = String(value);
93
+ if (enabled() && rawSet(key, text)) { delete session[key]; return true; }
94
+ session[key] = text;
95
+ return false;
96
+ }
97
+
98
+ function remove(key) {
99
+ delete session[key];
100
+ rawRemove(key);
101
+ }
102
+
103
+ function getNum(key, fallback) {
104
+ var raw = get(key);
105
+ var value = raw === null ? NaN : parseFloat(raw);
106
+ return isFinite(value) ? value : fallback;
107
+ }
108
+ function getBool(key, fallback) {
109
+ var raw = get(key);
110
+ return raw === null ? !!fallback : raw === '1';
111
+ }
112
+ function setBool(key, value) { return set(key, value ? '1' : '0'); }
113
+
114
+ /* The guard that keeps a stale or hand-edited key from putting the viewer
115
+ into a state its own controls cannot express. */
116
+ function getEnum(key, allowed, fallback) {
117
+ var raw = get(key);
118
+ return (raw !== null && allowed.indexOf(raw) >= 0) ? raw : fallback;
119
+ }
120
+
121
+ /* Every key this module owns, minus the master switch: a "no" has to
122
+ survive the purge it triggers. */
123
+ function storedKeys() {
124
+ var s = store(); if (!s) return [];
125
+ var found = [];
126
+ try {
127
+ for (var i = 0; i < s.length; i++) {
128
+ var key = s.key(i);
129
+ if (key && key.indexOf(PREFIX) === 0 && key !== KEYS.remember) found.push(key);
130
+ }
131
+ } catch (e) { return []; }
132
+ return found;
133
+ }
134
+
135
+ function clearStored() {
136
+ var doomed = storedKeys();
137
+ for (var i = 0; i < doomed.length; i++) rawRemove(doomed[i]);
138
+ }
139
+
140
+ /* "Forget stored settings": wipe the disk and the in-memory shadow, so the
141
+ next reload really does start from defaults. */
142
+ function forget() {
143
+ session = {};
144
+ clearStored();
145
+ }
146
+
147
+ function setEnabled(on) {
148
+ if (on) {
149
+ rawSet(KEYS.remember, '1');
150
+ /* Write through whatever the session accumulated while we were not
151
+ allowed to, so ticking the box keeps the state now on screen. */
152
+ var keys = Object.keys(session);
153
+ for (var i = 0; i < keys.length; i++) rawSet(keys[i], session[keys[i]]);
154
+ } else {
155
+ clearStored();
156
+ rawSet(KEYS.remember, '0');
157
+ }
158
+ return enabled();
159
+ }
160
+
161
+ /* ── ASC profile cache (standalone web and MATLAB hosts) ───────────────
162
+ The VS Code host stores a path on the extension side instead; a browser
163
+ file input hands over a File with no re-openable path, so the text is
164
+ the only thing worth keeping. */
165
+ function setAsc(name, text) {
166
+ if (!name || typeof text !== 'string' || text.length > ASC_MAX_CHARS) { clearAsc(); return false; }
167
+ var ok = set(KEYS.ascText, text);
168
+ set(KEYS.ascName, name);
169
+ return ok;
170
+ }
171
+ function getAsc() {
172
+ var name = get(KEYS.ascName), text = get(KEYS.ascText);
173
+ /* Both or nothing: a half-written pair (quota hit between the two sets)
174
+ must not restore a button label for a profile we cannot re-parse. */
175
+ return (name && text) ? { name: name, text: text } : null;
176
+ }
177
+ function clearAsc() {
178
+ remove(KEYS.ascName);
179
+ remove(KEYS.ascText);
180
+ }
181
+
182
+ return {
183
+ KEYS: KEYS,
184
+ ASC_MAX_CHARS: ASC_MAX_CHARS,
185
+ enabled: enabled,
186
+ setEnabled: setEnabled,
187
+ get: get,
188
+ set: set,
189
+ remove: remove,
190
+ getNum: getNum,
191
+ getBool: getBool,
192
+ setBool: setBool,
193
+ getEnum: getEnum,
194
+ storedKeys: storedKeys,
195
+ forget: forget,
196
+ setAsc: setAsc,
197
+ getAsc: getAsc,
198
+ clearAsc: clearAsc
199
+ };
200
+ })();
201
+
202
+
3
203
  /* ══ block-transport.js ══ */
4
204
  /* ══════════════════════════════════════════════════════════════════════════
5
205
  block-transport.js — rehydrate the packed block payload from the extension.
@@ -361,13 +561,29 @@ function failWaveformDetail(payload){
361
561
  the 2-D context, the time→x mapping and the row geometry. The Python
362
562
  viewer loads this file on its own, without the rest of the bundle.
363
563
 
364
- The DOM and localStorage are only touched inside functions, so the tests
365
- can run the shipped file in a bare VM context.
564
+ The DOM and storage are only touched inside functions, so the tests can
565
+ run the shipped file in a bare VM context.
366
566
 
367
567
  A "labels" argument is anything with `names` and `kinds`: the popup and the
368
568
  styles need nothing more, so ⚙ works before the per-ADC values arrive. */
369
569
  var SeqEyesLabels = (function () {
370
570
  var STORAGE_KEY = 'seqeyes.labelStyles.v1';
571
+
572
+ /* The Python viewer inlines this file on its own, so the bundle's
573
+ preferences gateway may not be there. Go through it when it is, so the
574
+ settings popover's master switch and Forget button cover marker styles
575
+ too, and fall back to raw storage when it is not. */
576
+ function prefsApi() { return (typeof SeqEyesPrefs !== 'undefined') ? SeqEyesPrefs : null; }
577
+ function readStored(key) {
578
+ var api = prefsApi();
579
+ if (api) return api.get(key);
580
+ try { return localStorage.getItem(key); } catch (_) { return null; }
581
+ }
582
+ function writeStored(key, value) {
583
+ var api = prefsApi();
584
+ if (api) { api.set(key, value); return; }
585
+ try { localStorage.setItem(key, value); } catch (_) {}
586
+ }
371
587
  // Tableau 10: distinct hues that stay readable on light and dark themes.
372
588
  var PALETTE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
373
589
  var SHAPES = ['circle', 'square', 'triangle', 'triangleDown', 'diamond', 'cross', 'plus'];
@@ -428,14 +644,14 @@ var SeqEyesLabels = (function () {
428
644
  if (overrides) return overrides;
429
645
  overrides = {};
430
646
  try {
431
- var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
647
+ var saved = JSON.parse(readStored(STORAGE_KEY) || '{}');
432
648
  if (saved && typeof saved === 'object') overrides = saved;
433
649
  } catch (_) {}
434
650
  return overrides;
435
651
  }
436
652
 
437
653
  function writeOverrides() {
438
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); } catch (_) {}
654
+ writeStored(STORAGE_KEY, JSON.stringify(overrides));
439
655
  }
440
656
 
441
657
  /* Defaults follow the label's position in its sequence, so one sequence's
@@ -808,8 +1024,13 @@ var CH_ORDER=[0,1,2,3,4,5,6,11,7,8,9,10];
808
1024
  var ox=0,sc=1; // view offset [s] & scale [px/s]
809
1025
  var dr=false,dsx=0,dso=0; // drag state
810
1026
  var cursorT=0,cursorActive=false; // mouse time position
811
- var timeUnit='ms',gradUnit='Hz/m'; // display unit selections
812
- var showBB=false; // show block boundaries (default off)
1027
+ /* Validating a remembered unit against the dropdown it came from, rather than
1028
+ against a second hard-coded list, keeps a stale or hand-edited key from
1029
+ putting the viewer in a state its own toolbar cannot express. */
1030
+ function selectOptionValues(sel){var out=[],i;if(!sel)return out;for(i=0;i<sel.options.length;i++)out.push(sel.options[i].value);return out;}
1031
+ var timeUnit=SeqEyesPrefs.getEnum(SeqEyesPrefs.KEYS.timeUnit,selectOptionValues(tuSel),'ms'),
1032
+ gradUnit=SeqEyesPrefs.getEnum(SeqEyesPrefs.KEYS.gradUnit,selectOptionValues(guSel),'Hz/m');
1033
+ var showBB=SeqEyesPrefs.getBool(SeqEyesPrefs.KEYS.showBlocks,false); // show block boundaries
813
1034
  var GAMMA=42576; // Hz/m per mT/m for 1H
814
1035
 
815
1036
  var ampZoom=[1,1,1,1,1,1,1];
@@ -830,10 +1051,10 @@ function viewerNoticeMessages(value){
830
1051
  for(var i=0;i<values.length;i++)if(values[i])messages.push(String(values[i]));
831
1052
  return messages;
832
1053
  }
833
- function readViewerNoticesCollapsed(){try{var saved=localStorage.getItem('seqeyes.viewerNoticesCollapsed');return saved===null?isMobileSafetyLayout():saved==='1';}catch(_){return isMobileSafetyLayout();}}
1054
+ function readViewerNoticesCollapsed(){var saved=SeqEyesPrefs.get(SeqEyesPrefs.KEYS.noticesCollapsed);return saved===null?isMobileSafetyLayout():saved==='1';}
834
1055
  function setViewerNoticesCollapsed(collapsed){
835
1056
  viewerNoticesCollapsed=!!collapsed;
836
- try{localStorage.setItem('seqeyes.viewerNoticesCollapsed',viewerNoticesCollapsed?'1':'0');}catch(_){}
1057
+ SeqEyesPrefs.setBool(SeqEyesPrefs.KEYS.noticesCollapsed,viewerNoticesCollapsed);
837
1058
  renderViewerNotices();
838
1059
  }
839
1060
  function renderViewerNotices(){
@@ -1021,9 +1242,9 @@ function applyLayoutMode(){
1021
1242
  }
1022
1243
  }
1023
1244
  }
1024
- function panelStoredWidth(){try{var v=parseFloat(localStorage.getItem('seqeyes.panelWidth'));return isFinite(v)?v:500;}catch(_){return 500;}}
1245
+ function panelStoredWidth(){return SeqEyesPrefs.getNum(SeqEyesPrefs.KEYS.panelWidth,500);}
1025
1246
  function panelMaxHeight(){var main=document.getElementById('main'),available=main?main.getBoundingClientRect().height:window.innerHeight;return Math.max(100,available-120);}
1026
- function panelStoredHeight(){var available=document.getElementById('main'),height=available?available.getBoundingClientRect().height:window.innerHeight,raw,max=panelMaxHeight(),min=Math.min(180,max);try{var v=parseFloat(localStorage.getItem('seqeyes.panelHeight'));raw=isFinite(v)?v:(isMobileSafetyLayout()?height*.58:300);}catch(_){raw=isMobileSafetyLayout()?height*.58:300;}return Math.max(min,Math.min(raw,max));}
1247
+ function panelStoredHeight(){var available=document.getElementById('main'),height=available?available.getBoundingClientRect().height:window.innerHeight,max=panelMaxHeight(),min=Math.min(180,max);var raw=SeqEyesPrefs.getNum(SeqEyesPrefs.KEYS.panelHeight,isMobileSafetyLayout()?height*.58:300);return Math.max(min,Math.min(raw,max));}
1027
1248
  function refreshLayout(){
1028
1249
  var changed=detectLayoutMode();
1029
1250
  if(changed){
@@ -1079,8 +1300,9 @@ function buildLegend(){
1079
1300
  });
1080
1301
  }
1081
1302
  buildLegend();
1082
- tuSel.onchange=function(){timeUnit=tuSel.value;draw();};
1083
- guSel.onchange=function(){gradUnit=guSel.value;draw();};
1303
+ tuSel.value=timeUnit;guSel.value=gradUnit;
1304
+ tuSel.onchange=function(){timeUnit=tuSel.value;SeqEyesPrefs.set(SeqEyesPrefs.KEYS.timeUnit,timeUnit);draw();};
1305
+ guSel.onchange=function(){gradUnit=guSel.value;SeqEyesPrefs.set(SeqEyesPrefs.KEYS.gradUnit,gradUnit);draw();};
1084
1306
  function setExportButtonEnabled(enabled){if(exportBtn)exportBtn.disabled=!enabled;}
1085
1307
  /* Throws rather than drawing an empty panel when a trajectory arrives without
1086
1308
  its ADC samples. A reply that lost its arrays in transit used to clear the
@@ -1166,6 +1388,12 @@ window.addEventListener('message',function(e){
1166
1388
  draw();drawKs();drawMinimap();
1167
1389
  setExportButtonEnabled(true);
1168
1390
  SeqEyesPanel.onSequenceLoaded();
1391
+ /* Ask the host to re-apply the ASC profile it remembered from an earlier
1392
+ session. Sent only now because PNS needs the sequence's blocks, and
1393
+ gated on the settings switch so one "no" covers both hosts. The
1394
+ extension ignores it once a profile is loaded, so a second sequence in
1395
+ the same tab costs nothing. */
1396
+ if(vscApi&&SeqEyesPrefs.enabled())vscApi.postMessage({command:'restoreAsc'});
1169
1397
  requestAnimationFrame(function(){refreshLayout();draw();drawKs();drawMinimap();});
1170
1398
  }else if(m.type==='waveformDetailData'){
1171
1399
  var detailBlocks;
@@ -2773,7 +3001,16 @@ function drawTriggerBlocks(start,end,vi,ch,colors,vs,ve){
2773
3001
 
2774
3002
 
2775
3003
  /* ══ kspace.js ══ */
2776
- var kOpen=false, kView="3d";
3004
+ /* Camera presets, declared first because the remembered projection is
3005
+ validated against this list and seeds the opening rotation. */
3006
+ var K_VIEWS=["3d","xy","xz","yz"];
3007
+ function kProjectionAngles(view){
3008
+ if(view==="xy")return{x:0,y:0};
3009
+ if(view==="xz")return{x:-Math.PI/2,y:0};
3010
+ if(view==="yz")return{x:0,y:Math.PI/2};
3011
+ return{x:-0.5,y:0.7}; // 3d — default perspective
3012
+ }
3013
+ var kOpen=false, kView=SeqEyesPrefs.getEnum(SeqEyesPrefs.KEYS.kspaceProjection,K_VIEWS,"3d");
2777
3014
  var kSpaceTrajectoryDrawCount=0,kSpaceOverlayDrawCount=0;
2778
3015
  /**
2779
3016
  * Pan is a screen offset in CSS pixels, applied after rotation; the rotation
@@ -2787,7 +3024,8 @@ var kSpaceTrajectoryDrawCount=0,kSpaceOverlayDrawCount=0;
2787
3024
  */
2788
3025
  var kPanX=0, kPanY=0, kScl=1;
2789
3026
  var kAutoFit=true;
2790
- var kRotX=-0.5, kRotY=0.7; // default 3D perspective
3027
+ var _kOpeningAngles=kProjectionAngles(kView);
3028
+ var kRotX=_kOpeningAngles.x, kRotY=_kOpeningAngles.y; // matches the restored projection
2791
3029
  var kDragging=false, kDragPrev=null, kDragBtn=0;
2792
3030
  /**
2793
3031
  * Pending coalesced drag redraw. A drag updates the rotation on every
@@ -2797,7 +3035,10 @@ var kDragging=false, kDragPrev=null, kDragBtn=0;
2797
3035
  */
2798
3036
  var _kDragRaf=0;
2799
3037
  var kCanvas=document.getElementById("kc"), kCtx=kCanvas.getContext("2d");
2800
- var kDotSize=2, kUnit="cyc"; // cyc=1/m, rad=rad/m
3038
+ /* Clamped to the slider's own range so a hand-edited key cannot produce a dot
3039
+ size the control has no position for. */
3040
+ var kDotSize=Math.max(1,Math.min(12,Math.round(SeqEyesPrefs.getNum(SeqEyesPrefs.KEYS.kspaceDotSize,2)))),
3041
+ kUnit=SeqEyesPrefs.getEnum(SeqEyesPrefs.KEYS.kspaceUnit,["cyc","rad"],"cyc"); // cyc=1/m, rad=rad/m
2801
3042
 
2802
3043
  // ── Smooth animation targets ──────────────────────────────────────────
2803
3044
  var _tRotX=kRotX, _tRotY=kRotY, _tScl=kScl, _tPanX=kPanX, _tPanY=kPanY;
@@ -2836,17 +3077,22 @@ function setKSpaceTarget(rx, ry, s, panX, panY, instant) {
2836
3077
  startKSpaceAnim();
2837
3078
  }
2838
3079
  }
2839
- document.getElementById("kdot").oninput=function(){kDotSize=parseInt(this.value);drawKs();};
2840
- document.getElementById("kunit").onclick=function(){
2841
- kUnit=kUnit==="cyc"?"rad":"cyc";this.textContent=kUnit==="cyc"?"Unit: 1/m":"Unit: rad/m";drawKs();
3080
+ function kUnitLabel(){return kUnit==="cyc"?"Unit: 1/m":"Unit: rad/m";}
3081
+ var kDotSlider=document.getElementById("kdot"),kUnitBtn=document.getElementById("kunit");
3082
+ kDotSlider.value=String(kDotSize);
3083
+ kUnitBtn.textContent=kUnitLabel();
3084
+ kDotSlider.oninput=function(){
3085
+ kDotSize=parseInt(this.value);SeqEyesPrefs.set(SeqEyesPrefs.KEYS.kspaceDotSize,kDotSize);drawKs();
3086
+ };
3087
+ kUnitBtn.onclick=function(){
3088
+ kUnit=kUnit==="cyc"?"rad":"cyc";this.textContent=kUnitLabel();
3089
+ SeqEyesPrefs.set(SeqEyesPrefs.KEYS.kspaceUnit,kUnit);drawKs();
2842
3090
  };
2843
3091
 
2844
3092
  /* ── Theme selector (toolbar) ─────────────────────────────────────── */
2845
3093
  var themeSelect=document.getElementById("theme");
2846
3094
  var systemThemeQuery=(typeof window.matchMedia==="function")?window.matchMedia("(prefers-color-scheme: dark)"):null;
2847
3095
  var inVsCode=!!vscApi;
2848
- function storageGet(k){try{return localStorage.getItem(k);}catch(_){return null;}}
2849
- function storageSet(k,v){try{localStorage.setItem(k,v);}catch(_){}}
2850
3096
  function clearThemeClasses(){
2851
3097
  var b=document.body,rm=[];
2852
3098
  b.classList.forEach(function(c){if(c.indexOf("theme-")===0)rm.push(c);});
@@ -2866,11 +3112,11 @@ function applyThemeChoice(value,persist){
2866
3112
  document.body.classList.add(systemThemeQuery&&systemThemeQuery.matches?"theme-github":"theme-githublight");
2867
3113
  }
2868
3114
  if(themeSelect&&themeSelect.value!==value)themeSelect.value=value;
2869
- if(persist)storageSet("seqeyes.theme",value);
3115
+ if(persist)SeqEyesPrefs.set(SeqEyesPrefs.KEYS.theme,value);
2870
3116
  redrawAfterThemeChange();
2871
3117
  }
2872
3118
  if(themeSelect){
2873
- var savedTheme=storageGet("seqeyes.theme")||"system";
3119
+ var savedTheme=SeqEyesPrefs.get(SeqEyesPrefs.KEYS.theme)||"system";
2874
3120
  if(!themeSelect.querySelector('option[value="'+savedTheme+'"]'))savedTheme="system";
2875
3121
  themeSelect.onchange=function(){applyThemeChoice(this.value,true);};
2876
3122
  applyThemeChoice(savedTheme,false);
@@ -3072,25 +3318,28 @@ function uploadKSpaceGPU(){
3072
3318
  off -> k-space -> spectrogram -> off, and only the off -> k-space
3073
3319
  transition passes through the safety gate. `kOpen` is still the flag the
3074
3320
  drawing and interaction code reads, and panel.js keeps it in sync. */
3075
- document.getElementById("kax").textContent="3D";
3076
- document.getElementById("krst").onclick=function(){
3321
+ /* A free rotation is no longer any of the axis presets. Guarded on the
3322
+ current value so the drag handlers that call it on every move write once. */
3323
+ function kLeavePreset(){
3324
+ if(kView==="3d")return;
3077
3325
  kView="3d";
3078
3326
  document.getElementById("kax").textContent="3D";
3079
- var af=_kAutoFitVals();
3080
- setKSpaceTarget(-0.5, 0.7, af.scl, af.panX, af.panY, false);
3081
- };
3327
+ SeqEyesPrefs.set(SeqEyesPrefs.KEYS.kspaceProjection,"3d");
3328
+ }
3329
+ /* One place that moves the camera to a named projection, so the reset button,
3330
+ the cycle button and the restore path cannot drift apart on the angles. */
3331
+ function applyKProjection(view,persist){
3332
+ kView=view;
3333
+ var angles=kProjectionAngles(view),af=_kAutoFitVals();
3334
+ document.getElementById("kax").textContent=view.toUpperCase();
3335
+ if(persist)SeqEyesPrefs.set(SeqEyesPrefs.KEYS.kspaceProjection,view);
3336
+ setKSpaceTarget(angles.x, angles.y, af.scl, af.panX, af.panY, false);
3337
+ }
3338
+ document.getElementById("kax").textContent=kView.toUpperCase();
3339
+ document.getElementById("krst").onclick=function(){applyKProjection("3d",true);};
3082
3340
  // Camera presets: smoothly rotate to look straight down an axis
3083
3341
  document.getElementById("kax").onclick=function(){
3084
- var views=["3d","xy","xz","yz"];var idx=views.indexOf(kView);
3085
- kView=views[(idx+1)%4];
3086
- var trx=_tRotX, tr=_tRotY;
3087
- if(kView==="xy"){trx=0; tr=0;}
3088
- else if(kView==="xz"){trx=-Math.PI/2; tr=0;}
3089
- else if(kView==="yz"){trx=0; tr=Math.PI/2;}
3090
- else{trx=-0.5; tr=0.7;} // 3d — default perspective
3091
- document.getElementById("kax").textContent=kView.toUpperCase();
3092
- var af=_kAutoFitVals();
3093
- setKSpaceTarget(trx, tr, af.scl, af.panX, af.panY, false);
3342
+ applyKProjection(K_VIEWS[(K_VIEWS.indexOf(kView)+1)%K_VIEWS.length],true);
3094
3343
  };
3095
3344
 
3096
3345
  function resizeKc(){
@@ -3224,7 +3473,7 @@ window.addEventListener("mousemove",function(e){
3224
3473
  if(!kDragging||!kDragPrev||!kOpen||panelMode!=="kspace")return;
3225
3474
  var dx=e.clientX-kDragPrev.x, dy=e.clientY-kDragPrev.y;
3226
3475
  kDragPrev={x:e.clientX,y:e.clientY};
3227
- if(kView!=="3d"){kView="3d";document.getElementById("kax").textContent="3D";}
3476
+ kLeavePreset();
3228
3477
  if(kDragBtn===0){
3229
3478
  // left drag = instant rotate (no lerp — feels responsive)
3230
3479
  kRotY+=dx*0.008; kRotX-=dy*0.008;
@@ -3267,7 +3516,7 @@ kCanvas.addEventListener("touchstart",function(e){
3267
3516
  },{passive:false});
3268
3517
  kCanvas.addEventListener("touchmove",function(e){
3269
3518
  if(!_kTouchActive||!_kTouchPrev||!kOpen||panelMode!=="kspace")return;
3270
- if(kView!=="3d"){kView="3d";document.getElementById("kax").textContent="3D";}
3519
+ kLeavePreset();
3271
3520
  if(e.touches.length===1&&_kTouchBtn===0){
3272
3521
  // 1‑finger rotate
3273
3522
  var dx=e.touches[0].clientX-_kTouchPrev.x;
@@ -3382,8 +3631,8 @@ window.addEventListener("resize",function(){if(kOpen){resizeKc();drawKs();}});
3382
3631
  applyLayoutMode()/setPanelMode() read it back on every open. */
3383
3632
  function persistPanelSize(){
3384
3633
  try{
3385
- if(typeof layoutMode!=='undefined'&&layoutMode==='vertical')localStorage.setItem('seqeyes.panelHeight',String(kResizeH));
3386
- else localStorage.setItem('seqeyes.panelWidth',String(kResizeW));
3634
+ if(typeof layoutMode!=='undefined'&&layoutMode==='vertical')SeqEyesPrefs.set('seqeyes.panelHeight',kResizeH);
3635
+ else SeqEyesPrefs.set('seqeyes.panelWidth',kResizeW);
3387
3636
  }catch(_){/* private mode */}
3388
3637
  }
3389
3638
 
@@ -4724,18 +4973,12 @@ var SeqEyesPanel = (function () {
4724
4973
  var host = null;
4725
4974
  var wired = false;
4726
4975
 
4727
- /* ── Persistence ──────────────────────────────────────────────────── */
4728
- function get(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
4729
- function set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* private mode */ } }
4730
- function getNum(key, fallback) {
4731
- var raw = get(key);
4732
- var value = raw === null ? NaN : parseFloat(raw);
4733
- return isFinite(value) ? value : fallback;
4734
- }
4735
- function getBool(key, fallback) {
4736
- var raw = get(key);
4737
- return raw === null ? fallback : raw === '1';
4738
- }
4976
+ /* ── Persistence ──────────────────────────────────────────────────────
4977
+ Delegated so the master switch in the settings popover governs the
4978
+ spectrogram parameters too; this module used to own a fourth copy of
4979
+ the same get/set pair. */
4980
+ var get = SeqEyesPrefs.get, set = SeqEyesPrefs.set;
4981
+ var getNum = SeqEyesPrefs.getNum, getBool = SeqEyesPrefs.getBool;
4739
4982
 
4740
4983
  /* ── Parameters and view state ────────────────────────────────────── */
4741
4984
  var params = {
@@ -6399,6 +6642,196 @@ window.SeqEyesDev.setSplitRatio = function (r) { SeqEyesPanel.setSplitRatio(r);
6399
6642
  window.SeqEyesDev.getSplitRatio = function () { return SeqEyesPanel.getSplitRatio(); };
6400
6643
 
6401
6644
 
6645
+ /* ══ prefs-ui.js ══ */
6646
+ /* ═══════════════════════════════════════════════════════════════════════
6647
+ Settings popover — the consent surface for SeqEyesPrefs
6648
+
6649
+ One ⚙ chip in the toolbar overflow, opening a small dialog that says what
6650
+ the viewer keeps, lets the user turn that off, and throws away what is
6651
+ already stored. Persistence is useless to someone who cannot see it or
6652
+ revoke it, and the ASC cache in particular puts the contents of a scanner
6653
+ hardware file into browser storage — that deserves a visible switch rather
6654
+ than a line in the README.
6655
+
6656
+ It borrows the `.lblc` popover styles from the label marker controls
6657
+ instead of growing a second popup look.
6658
+ ═══════════════════════════════════════════════════════════════════════ */
6659
+
6660
+ var SeqEyesPrefsUi = (function () {
6661
+ var popover = null, anchor = null, rememberBox = null, summaryEl = null;
6662
+
6663
+ function element(tag, className, text) {
6664
+ var el = document.createElement(tag);
6665
+ if (className) el.className = className;
6666
+ if (text !== undefined && text !== null) el.textContent = text;
6667
+ return el;
6668
+ }
6669
+
6670
+ function inVsCode() { return typeof vscApi !== 'undefined' && !!vscApi; }
6671
+
6672
+ function whereText() {
6673
+ return inVsCode()
6674
+ ? 'this copy of VS Code'
6675
+ : 'this browser';
6676
+ }
6677
+
6678
+ /* Plain counts rather than a key dump: enough to tell whether "Forget" will
6679
+ do anything, without turning the dialog into a storage inspector. */
6680
+ function summarise() {
6681
+ if (!SeqEyesPrefs.enabled()) return 'Nothing is being stored.';
6682
+ var stored = SeqEyesPrefs.storedKeys();
6683
+ if (!stored.length) return 'Nothing stored yet.';
6684
+ var asc = SeqEyesPrefs.getAsc();
6685
+ var line = stored.length + ' setting' + (stored.length === 1 ? '' : 's') + ' stored';
6686
+ return asc ? line + ', including the ASC profile ' + asc.name + '.' : line + '.';
6687
+ }
6688
+
6689
+ function refresh() {
6690
+ if (rememberBox) rememberBox.checked = SeqEyesPrefs.enabled();
6691
+ if (summaryEl) summaryEl.textContent = summarise();
6692
+ }
6693
+
6694
+ function render() {
6695
+ popover.textContent = '';
6696
+
6697
+ var head = element('div', 'lblc-head');
6698
+ head.appendChild(element('span', null, 'SeqEyes settings'));
6699
+ var close = element('button', 'lblc-close', '✕');
6700
+ close.type = 'button';
6701
+ close.setAttribute('aria-label', 'Close');
6702
+ close.onclick = function () { closeControls(true); };
6703
+ head.appendChild(close);
6704
+
6705
+ var list = element('div', 'lblc-list');
6706
+
6707
+ var row = element('div', 'lblc-row');
6708
+ rememberBox = document.createElement('input');
6709
+ rememberBox.type = 'checkbox';
6710
+ rememberBox.id = 'prefsRemember';
6711
+ rememberBox.checked = SeqEyesPrefs.enabled();
6712
+ var label = element('label', 'prefs-label', 'Remember my settings on ' + whereText());
6713
+ label.setAttribute('for', 'prefsRemember');
6714
+ rememberBox.onchange = function () {
6715
+ SeqEyesPrefs.setEnabled(this.checked);
6716
+ refresh();
6717
+ };
6718
+ row.appendChild(rememberBox);
6719
+ row.appendChild(label);
6720
+ list.appendChild(row);
6721
+
6722
+ var note = element('div', 'prefs-note');
6723
+ note.appendChild(element('div', null,
6724
+ 'Theme, time and gradient units, the Blocks toggle, k-space and '
6725
+ + 'spectrogram options, and panel sizes are kept between sessions.'));
6726
+ note.appendChild(element('div', null, inVsCode()
6727
+ /* Different sentences because the two hosts really do keep different
6728
+ things: a path the extension re-reads, versus the file's text. */
6729
+ ? 'A loaded ASC profile is remembered by its file path and re-read when '
6730
+ + 'you open a sequence. If the file moves, it is quietly forgotten.'
6731
+ : 'A loaded ASC profile is stored as text in this browser so it can be '
6732
+ + 'restored without picking the file again.'));
6733
+ list.appendChild(note);
6734
+
6735
+ summaryEl = element('div', 'prefs-note prefs-summary', summarise());
6736
+ list.appendChild(summaryEl);
6737
+
6738
+ var foot = element('div', 'lblc-foot');
6739
+ var forget = element('button', null, 'Forget stored settings');
6740
+ forget.type = 'button';
6741
+ forget.title = 'Delete everything SeqEyes has stored. The current view is left as it is.';
6742
+ forget.onclick = function () {
6743
+ SeqEyesPrefs.forget();
6744
+ refresh();
6745
+ };
6746
+ foot.appendChild(forget);
6747
+
6748
+ popover.appendChild(head);
6749
+ popover.appendChild(list);
6750
+ popover.appendChild(foot);
6751
+ }
6752
+
6753
+ /* Same placement rules as the label popover: below the chip when it fits,
6754
+ flipped above when it does not, and a bottom sheet on narrow screens. */
6755
+ function positionPopover() {
6756
+ if (!popover) return;
6757
+ var compact = !!(window.matchMedia && window.matchMedia('(max-width: 768px), (pointer: coarse)').matches);
6758
+ popover.classList.toggle('sheet', compact);
6759
+ if (compact) { popover.style.left = ''; popover.style.top = ''; return; }
6760
+ var rect = anchor && anchor.isConnected ? anchor.getBoundingClientRect() : { left: 8, top: 8, bottom: 8 };
6761
+ var width = popover.offsetWidth, height = popover.offsetHeight;
6762
+ var vw = window.innerWidth, vh = window.innerHeight;
6763
+ var top = rect.bottom + 4;
6764
+ if (top + height > vh - 8) top = Math.max(8, rect.top - height - 4);
6765
+ if (top + height > vh - 8) top = Math.max(8, vh - height - 8);
6766
+ popover.style.left = Math.max(8, Math.min(rect.left, vw - width - 8)) + 'px';
6767
+ popover.style.top = top + 'px';
6768
+ }
6769
+
6770
+ function onKeyDown(event) {
6771
+ if (event.key !== 'Escape') return;
6772
+ event.preventDefault(); event.stopPropagation();
6773
+ closeControls(true);
6774
+ }
6775
+
6776
+ function onPointerDown(event) {
6777
+ var target = event.target;
6778
+ if (popover.contains(target)) return;
6779
+ // The chip toggles on its own click; closing here first would reopen it.
6780
+ if (target && target.closest && target.closest('#prefsBtn')) return;
6781
+ closeControls(false);
6782
+ }
6783
+
6784
+ function openControls(chip) {
6785
+ closeControls(false);
6786
+ popover = element('div', 'lblc prefs-pop');
6787
+ popover.id = 'prefsControls';
6788
+ popover.setAttribute('role', 'dialog');
6789
+ popover.setAttribute('aria-label', 'SeqEyes settings');
6790
+ anchor = chip;
6791
+ render();
6792
+ document.body.appendChild(popover);
6793
+ if (anchor) anchor.setAttribute('aria-expanded', 'true');
6794
+ positionPopover();
6795
+ document.addEventListener('keydown', onKeyDown, true);
6796
+ document.addEventListener('pointerdown', onPointerDown, true);
6797
+ window.addEventListener('resize', positionPopover);
6798
+ if (rememberBox) rememberBox.focus();
6799
+ }
6800
+
6801
+ function closeControls(restoreFocus) {
6802
+ if (!popover) return;
6803
+ document.removeEventListener('keydown', onKeyDown, true);
6804
+ document.removeEventListener('pointerdown', onPointerDown, true);
6805
+ window.removeEventListener('resize', positionPopover);
6806
+ if (popover.parentNode) popover.parentNode.removeChild(popover);
6807
+ var chip = anchor;
6808
+ popover = null; anchor = null; rememberBox = null; summaryEl = null;
6809
+ if (chip) {
6810
+ chip.setAttribute('aria-expanded', 'false');
6811
+ if (restoreFocus && chip.isConnected) chip.focus();
6812
+ }
6813
+ }
6814
+
6815
+ /* Idempotent, because web/index.html installs its own handlers over the
6816
+ bundle's and may call this again. */
6817
+ var wired = false;
6818
+ function install() {
6819
+ if (wired) return;
6820
+ var chip = document.getElementById('prefsBtn');
6821
+ if (!chip) return;
6822
+ wired = true;
6823
+ chip.onclick = function () { if (popover) closeControls(false); else openControls(chip); };
6824
+ }
6825
+
6826
+ return {
6827
+ install: install,
6828
+ open: openControls,
6829
+ close: closeControls,
6830
+ isOpen: function () { return !!popover; }
6831
+ };
6832
+ })();
6833
+
6834
+
6402
6835
  /* ══ interaction.js ══ */
6403
6836
  /* ═══════════════════════════════════════════════════════════════════════
6404
6837
  Mouse interaction
@@ -6672,7 +7105,9 @@ document.getElementById('zi').onclick=function(){if(zoomAtCenter(1.5))scheduleVi
6672
7105
  document.getElementById('zo').onclick=function(){if(zoomAtCenter(1/1.5))scheduleViewerDraw(true);};
6673
7106
  document.getElementById('zf').onclick=function(){fit();drawMinimap();};
6674
7107
  document.getElementById('zr').onclick=function(){fit();drawMinimap();};
6675
- document.getElementById('bbc').onchange=function(){showBB=this.checked;draw();};
7108
+ var bbCheck=document.getElementById('bbc');
7109
+ bbCheck.checked=showBB; // state.js read the remembered value; match the box to it
7110
+ bbCheck.onchange=function(){showBB=this.checked;SeqEyesPrefs.setBool(SeqEyesPrefs.KEYS.showBlocks,showBB);draw();};
6676
7111
 
6677
7112
  /* ── Mobile hamburger menu ─────────────────────────────────────────── */
6678
7113
  var menuBtn=document.getElementById('menuBtn');
@@ -6773,6 +7208,7 @@ rs();
6773
7208
  the host adapter to exist. web/index.html installs its own adapter over
6774
7209
  this one — install() swaps the host without re-binding the DOM. */
6775
7210
  SeqEyesPanel.install(window.SeqEyesPanelHost);
7211
+ SeqEyesPrefsUi.install();
6776
7212
  /* Deferred by one macrotask so web/index.html, whose inline IIFE runs after
6777
7213
  this bundle, has installed its own adapter before the persisted mode is
6778
7214
  restored against it. */