mri-scanner: in-browser Bloch simulation of pulseq sequences
Upload a pulseq .seq file, run a Bloch simulation on a digital phantom in
the browser (Web Worker), and view the raw k-space. Companion to seqlab.
- Isochromat Bloch simulator (analytic free precession + Rodrigues excitation)
- Two built-in .phantom (HDF5) phantoms read via h5wasm: cube and sphere
- .seq parser/reconstruction reused from seqlab
- Sequences stored in IndexedDB (upload/rename/delete/select)
- Raw k-space view (log-mag/mag/phase/real/imag)
36 changed files+9043−0
.github/workflows/deploy.ymladded+40−0View file
@@ -0,0 +1,40 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: false
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - uses: actions/setup-node@v4
23+ with:
24+ node-version: 22
25+ cache: npm
26+ - run: npm ci
27+ - run: npm run build
28+ - uses: actions/upload-pages-artifact@v3
29+ with:
30+ path: dist
31+
32+ deploy:
33+ needs: build
34+ runs-on: ubuntu-latest
35+ environment:
36+ name: github-pages
37+ url: ${{ steps.deployment.outputs.page_url }}
38+ steps:
39+ - id: deployment
40+ uses: actions/deploy-pages@v4
.gitignoreadded+7−0View file
@@ -0,0 +1,7 @@
1+node_modules
2+dist
3+.cache
4+*.local
5+# h5wasm/node scratch files (written to cwd); real phantoms live in src/phantom/data
6+*.tmp
7+/*.phantom
CLAUDE.mdadded+94−0View file
@@ -0,0 +1,94 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo.
4+
5+## What this is
6+
7+mri-scanner is a companion to [seqlab](https://github.com/concept-collection/seqlab).
8+You upload a pulseq `.seq` file (e.g. one exported from seqlab), pick a digital
9+phantom, and it runs a Bloch simulation **in the browser** (Web Worker) and
10+shows the raw k-space (the acquired signal laid out one row per ADC readout).
11+Reconstruction is intentionally deferred — this is the "raw data" stage.
12+
13+## Architecture
14+
15+```
16+src/seq/ .seq parser + reconstruction, COPIED from seqlab
17+ (parseSeq.ts, reconstruct.ts, types.ts, md5.ts). If you
18+ fix a parser bug, fix it in seqlab too. summary.ts wraps
19+ them for a display/validate summary.
20+src/phantom/ phantomTypes.ts (Phantom = spin cloud + tissue props),
21+ loadPhantom.ts (read a KomaMRI .phantom HDF5 via h5wasm,
22+ in the browser), builtins.ts (the two bundled phantoms,
23+ imported with Vite ?url), data/{cube,sphere}.phantom
24+ (GENERATED — do not hand-edit).
25+src/sim/ simulate.ts = the Bloch simulator (pure, no DOM — so it
26+ runs headlessly in Node too). simWorker.ts runs it off
27+ the main thread; useSimulation.ts is the React hook
28+ (progress streaming; cancel = terminate the worker).
29+src/kspace/ kspace.ts (RawSignal -> RGBA image, DOM-free),
30+ KspaceView.tsx (canvas + mode selector).
31+src/storage/ seqStore.ts — uploaded .seq files in IndexedDB.
32+src/ui/ SequencePanel, PhantomPanel.
33+src/App.tsx orchestration.
34+scripts/gen-phantoms.mjs regenerate the two .phantom files (h5wasm/node).
35+scripts/sim-test.mjs headless physics smoke test.
36+test-data/ fid.seq, gre.seq goldens (from seqlab) for sim-test.
37+```
38+
39+## The simulator (src/sim/simulate.ts)
40+
41+Isochromat Bloch simulation in the rotating frame. The timeline is split at the
42+union of gradient vertices, RF samples and ADC samples (`boundaries`); within a
43+segment gradients are linear and RF is ~one raster. Two regimes:
44+
45+- **Free precession** (no RF): transverse magnetisation rotates about z by
46+ `dφ = 2π·(g·r)·dt + Δw·dt` and relaxes — exact for a linear gradient, so these
47+ segments can be long (a whole dwell/delay). This is what makes it fast.
48+- **Excitation** (RF present): full 3-D Rodrigues rotation about
49+ `Ω = (2π·B1·cosθ, 2π·B1·sinθ, 2π·g·r + Δw)`.
50+
51+Signal at each ADC sample = `Σ_j ρ_j·(Mx+iMy)`, demodulated by the receiver
52+phase (ADC phase + frequency offset). Units: pulseq gradients are **Hz/m**, RF
53+amplitude **Hz**, positions **m**, so `g·r` and `B1` are already in Hz — no γ
54+needed. Sign convention is internally consistent (excitation reduces to the
55+free-precession z-rotation when B1=0) but not pinned to a physical handedness;
56+that only matters once we add reconstruction. Relaxation has a uniform fast
57+path (our built-in phantoms are single-tissue) and a per-spin fallback.
58+
59+Cost ≈ O(numSegments · numSpins). A 128×128 GRE on the 2197-spin cube is ~400k
60+segments → ~20 s. FID/EPI are much cheaper. The sphere (~1000 spins) is ~2×
61+faster than the cube. Progress is streamed; cancel terminates the worker.
62+
63+## The .phantom format
64+
65+Genuine KomaMRI `.phantom` = HDF5 (see `../KomaMRI.jl` Phantom.jl):
66+root attrs `Version`/`Name`/`Ns`/`Dims`; group `position` with `x`/`y`/`z`
67+(metres); group `contrast` with `ρ`, `T1`, `T2`, `T2s` (s) and `Δw` (rad/s).
68+Note the **Unicode** dataset names `ρ` and `Δw` — h5wasm reads/writes them
69+fine. `loadPhantom.ts` maps aliases so it also opens arbitrary KomaMRI
70+phantoms. The two built-ins are ~10 mm, 0.8 mm uniform spin spacing, single
71+water-like tissue (T1 1000 ms, T2 100 ms, Δw 0).
72+
73+## Gotchas
74+
75+- **h5wasm inlines its WASM** into the main JS bundle (~5 MB raw, ~1.1 MB gz).
76+ That's expected; there is no separate `.wasm` asset. It's used on the main
77+ thread (loadPhantom); the worker only needs parse+simulate, so the worker
78+ chunk stays tiny.
79+- **The worker gets plain typed arrays**, not the h5wasm-decoded file — the
80+ Phantom is structured-cloned to the worker. Don't move HDF5 decoding into the
81+ worker.
82+- **Node runs the `.ts` sources directly** (Node ≥ 22.6 type stripping), which
83+ is why src imports use explicit `.ts` extensions (inherited from seqlab).
84+- **Regenerating phantoms**: `npm run gen-phantoms`. Change SIZE/SPACING there.
85+ Denser phantoms = slower sims; keep the balance.
86+- **base: './'** in vite.config so it works from the Pages subpath.
87+
88+## Testing
89+
90+- `npm run sim-test` — headless physics checks: FID |S(0)| ≈ Σρ and decays at
91+ exactly 1/T2; GRE echo peaks at the readout centre. Uses test-data goldens.
92+- `npm run build` — tsc typecheck + vite build.
93+- Browser verification (h5wasm load, worker run, canvas, drag & drop,
94+ IndexedDB persistence) is manual: `npm run dev`.
index.htmladded+17−0View file
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <meta
8+ name="description"
9+ content="mri-scanner — upload a pulseq .seq file, run a Bloch simulation on a digital phantom in your browser, and view the raw k-space data."
10+ />
11+ <title>mri-scanner — Bloch simulation in the browser</title>
12+ </head>
13+ <body>
14+ <div id="root"></div>
15+ <script type="module" src="/src/main.tsx"></script>
16+ </body>
17+</html>
package-lock.jsonadded+1426−0View file
@@ -0,0 +1,1426 @@
1+{
2+ "name": "mri-scanner",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "mri-scanner",
9+ "version": "0.0.0",
10+ "dependencies": {
11+ "h5wasm": "^0.10.3",
12+ "react": "^19.2.7",
13+ "react-dom": "^19.2.7"
14+ },
15+ "devDependencies": {
16+ "@types/node": "^24.13.2",
17+ "@types/react": "^19.2.17",
18+ "@types/react-dom": "^19.2.3",
19+ "@vitejs/plugin-react": "^6.0.3",
20+ "oxlint": "^1.71.0",
21+ "typescript": "~6.0.2",
22+ "vite": "^8.1.1"
23+ }
24+ },
25+ "node_modules/@emnapi/core": {
26+ "version": "1.11.1",
27+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
28+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
29+ "dev": true,
30+ "license": "MIT",
31+ "optional": true,
32+ "dependencies": {
33+ "@emnapi/wasi-threads": "1.2.2",
34+ "tslib": "^2.4.0"
35+ }
36+ },
37+ "node_modules/@emnapi/runtime": {
38+ "version": "1.11.1",
39+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
40+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
41+ "dev": true,
42+ "license": "MIT",
43+ "optional": true,
44+ "dependencies": {
45+ "tslib": "^2.4.0"
46+ }
47+ },
48+ "node_modules/@emnapi/wasi-threads": {
49+ "version": "1.2.2",
50+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
51+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
52+ "dev": true,
53+ "license": "MIT",
54+ "optional": true,
55+ "dependencies": {
56+ "tslib": "^2.4.0"
57+ }
58+ },
59+ "node_modules/@napi-rs/wasm-runtime": {
60+ "version": "1.1.6",
61+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
62+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
63+ "dev": true,
64+ "license": "MIT",
65+ "optional": true,
66+ "dependencies": {
67+ "@tybys/wasm-util": "^0.10.3"
68+ },
69+ "funding": {
70+ "type": "github",
71+ "url": "https://github.com/sponsors/Brooooooklyn"
72+ },
73+ "peerDependencies": {
74+ "@emnapi/core": "^1.7.1",
75+ "@emnapi/runtime": "^1.7.1"
76+ }
77+ },
78+ "node_modules/@oxc-project/types": {
79+ "version": "0.139.0",
80+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
81+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
82+ "dev": true,
83+ "license": "MIT",
84+ "funding": {
85+ "url": "https://github.com/sponsors/Boshen"
86+ }
87+ },
88+ "node_modules/@oxlint/binding-android-arm-eabi": {
89+ "version": "1.75.0",
90+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz",
91+ "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==",
92+ "cpu": [
93+ "arm"
94+ ],
95+ "dev": true,
96+ "license": "MIT",
97+ "optional": true,
98+ "os": [
99+ "android"
100+ ],
101+ "engines": {
102+ "node": "^20.19.0 || >=22.12.0"
103+ }
104+ },
105+ "node_modules/@oxlint/binding-android-arm64": {
106+ "version": "1.75.0",
107+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz",
108+ "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==",
109+ "cpu": [
110+ "arm64"
111+ ],
112+ "dev": true,
113+ "license": "MIT",
114+ "optional": true,
115+ "os": [
116+ "android"
117+ ],
118+ "engines": {
119+ "node": "^20.19.0 || >=22.12.0"
120+ }
121+ },
122+ "node_modules/@oxlint/binding-darwin-arm64": {
123+ "version": "1.75.0",
124+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz",
125+ "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==",
126+ "cpu": [
127+ "arm64"
128+ ],
129+ "dev": true,
130+ "license": "MIT",
131+ "optional": true,
132+ "os": [
133+ "darwin"
134+ ],
135+ "engines": {
136+ "node": "^20.19.0 || >=22.12.0"
137+ }
138+ },
139+ "node_modules/@oxlint/binding-darwin-x64": {
140+ "version": "1.75.0",
141+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz",
142+ "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==",
143+ "cpu": [
144+ "x64"
145+ ],
146+ "dev": true,
147+ "license": "MIT",
148+ "optional": true,
149+ "os": [
150+ "darwin"
151+ ],
152+ "engines": {
153+ "node": "^20.19.0 || >=22.12.0"
154+ }
155+ },
156+ "node_modules/@oxlint/binding-freebsd-x64": {
157+ "version": "1.75.0",
158+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz",
159+ "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==",
160+ "cpu": [
161+ "x64"
162+ ],
163+ "dev": true,
164+ "license": "MIT",
165+ "optional": true,
166+ "os": [
167+ "freebsd"
168+ ],
169+ "engines": {
170+ "node": "^20.19.0 || >=22.12.0"
171+ }
172+ },
173+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
174+ "version": "1.75.0",
175+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz",
176+ "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==",
177+ "cpu": [
178+ "arm"
179+ ],
180+ "dev": true,
181+ "license": "MIT",
182+ "optional": true,
183+ "os": [
184+ "linux"
185+ ],
186+ "engines": {
187+ "node": "^20.19.0 || >=22.12.0"
188+ }
189+ },
190+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
191+ "version": "1.75.0",
192+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz",
193+ "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==",
194+ "cpu": [
195+ "arm"
196+ ],
197+ "dev": true,
198+ "license": "MIT",
199+ "optional": true,
200+ "os": [
201+ "linux"
202+ ],
203+ "engines": {
204+ "node": "^20.19.0 || >=22.12.0"
205+ }
206+ },
207+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
208+ "version": "1.75.0",
209+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz",
210+ "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==",
211+ "cpu": [
212+ "arm64"
213+ ],
214+ "dev": true,
215+ "libc": [
216+ "glibc"
217+ ],
218+ "license": "MIT",
219+ "optional": true,
220+ "os": [
221+ "linux"
222+ ],
223+ "engines": {
224+ "node": "^20.19.0 || >=22.12.0"
225+ }
226+ },
227+ "node_modules/@oxlint/binding-linux-arm64-musl": {
228+ "version": "1.75.0",
229+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz",
230+ "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==",
231+ "cpu": [
232+ "arm64"
233+ ],
234+ "dev": true,
235+ "libc": [
236+ "musl"
237+ ],
238+ "license": "MIT",
239+ "optional": true,
240+ "os": [
241+ "linux"
242+ ],
243+ "engines": {
244+ "node": "^20.19.0 || >=22.12.0"
245+ }
246+ },
247+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
248+ "version": "1.75.0",
249+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz",
250+ "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==",
251+ "cpu": [
252+ "ppc64"
253+ ],
254+ "dev": true,
255+ "libc": [
256+ "glibc"
257+ ],
258+ "license": "MIT",
259+ "optional": true,
260+ "os": [
261+ "linux"
262+ ],
263+ "engines": {
264+ "node": "^20.19.0 || >=22.12.0"
265+ }
266+ },
267+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
268+ "version": "1.75.0",
269+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz",
270+ "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==",
271+ "cpu": [
272+ "riscv64"
273+ ],
274+ "dev": true,
275+ "libc": [
276+ "glibc"
277+ ],
278+ "license": "MIT",
279+ "optional": true,
280+ "os": [
281+ "linux"
282+ ],
283+ "engines": {
284+ "node": "^20.19.0 || >=22.12.0"
285+ }
286+ },
287+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
288+ "version": "1.75.0",
289+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz",
290+ "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==",
291+ "cpu": [
292+ "riscv64"
293+ ],
294+ "dev": true,
295+ "libc": [
296+ "musl"
297+ ],
298+ "license": "MIT",
299+ "optional": true,
300+ "os": [
301+ "linux"
302+ ],
303+ "engines": {
304+ "node": "^20.19.0 || >=22.12.0"
305+ }
306+ },
307+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
308+ "version": "1.75.0",
309+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz",
310+ "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==",
311+ "cpu": [
312+ "s390x"
313+ ],
314+ "dev": true,
315+ "libc": [
316+ "glibc"
317+ ],
318+ "license": "MIT",
319+ "optional": true,
320+ "os": [
321+ "linux"
322+ ],
323+ "engines": {
324+ "node": "^20.19.0 || >=22.12.0"
325+ }
326+ },
327+ "node_modules/@oxlint/binding-linux-x64-gnu": {
328+ "version": "1.75.0",
329+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz",
330+ "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==",
331+ "cpu": [
332+ "x64"
333+ ],
334+ "dev": true,
335+ "libc": [
336+ "glibc"
337+ ],
338+ "license": "MIT",
339+ "optional": true,
340+ "os": [
341+ "linux"
342+ ],
343+ "engines": {
344+ "node": "^20.19.0 || >=22.12.0"
345+ }
346+ },
347+ "node_modules/@oxlint/binding-linux-x64-musl": {
348+ "version": "1.75.0",
349+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz",
350+ "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==",
351+ "cpu": [
352+ "x64"
353+ ],
354+ "dev": true,
355+ "libc": [
356+ "musl"
357+ ],
358+ "license": "MIT",
359+ "optional": true,
360+ "os": [
361+ "linux"
362+ ],
363+ "engines": {
364+ "node": "^20.19.0 || >=22.12.0"
365+ }
366+ },
367+ "node_modules/@oxlint/binding-openharmony-arm64": {
368+ "version": "1.75.0",
369+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz",
370+ "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==",
371+ "cpu": [
372+ "arm64"
373+ ],
374+ "dev": true,
375+ "license": "MIT",
376+ "optional": true,
377+ "os": [
378+ "openharmony"
379+ ],
380+ "engines": {
381+ "node": "^20.19.0 || >=22.12.0"
382+ }
383+ },
384+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
385+ "version": "1.75.0",
386+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz",
387+ "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==",
388+ "cpu": [
389+ "arm64"
390+ ],
391+ "dev": true,
392+ "license": "MIT",
393+ "optional": true,
394+ "os": [
395+ "win32"
396+ ],
397+ "engines": {
398+ "node": "^20.19.0 || >=22.12.0"
399+ }
400+ },
401+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
402+ "version": "1.75.0",
403+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz",
404+ "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==",
405+ "cpu": [
406+ "ia32"
407+ ],
408+ "dev": true,
409+ "license": "MIT",
410+ "optional": true,
411+ "os": [
412+ "win32"
413+ ],
414+ "engines": {
415+ "node": "^20.19.0 || >=22.12.0"
416+ }
417+ },
418+ "node_modules/@oxlint/binding-win32-x64-msvc": {
419+ "version": "1.75.0",
420+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz",
421+ "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==",
422+ "cpu": [
423+ "x64"
424+ ],
425+ "dev": true,
426+ "license": "MIT",
427+ "optional": true,
428+ "os": [
429+ "win32"
430+ ],
431+ "engines": {
432+ "node": "^20.19.0 || >=22.12.0"
433+ }
434+ },
435+ "node_modules/@rolldown/binding-android-arm64": {
436+ "version": "1.1.5",
437+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
438+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
439+ "cpu": [
440+ "arm64"
441+ ],
442+ "dev": true,
443+ "license": "MIT",
444+ "optional": true,
445+ "os": [
446+ "android"
447+ ],
448+ "engines": {
449+ "node": "^20.19.0 || >=22.12.0"
450+ }
451+ },
452+ "node_modules/@rolldown/binding-darwin-arm64": {
453+ "version": "1.1.5",
454+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
455+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
456+ "cpu": [
457+ "arm64"
458+ ],
459+ "dev": true,
460+ "license": "MIT",
461+ "optional": true,
462+ "os": [
463+ "darwin"
464+ ],
465+ "engines": {
466+ "node": "^20.19.0 || >=22.12.0"
467+ }
468+ },
469+ "node_modules/@rolldown/binding-darwin-x64": {
470+ "version": "1.1.5",
471+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
472+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
473+ "cpu": [
474+ "x64"
475+ ],
476+ "dev": true,
477+ "license": "MIT",
478+ "optional": true,
479+ "os": [
480+ "darwin"
481+ ],
482+ "engines": {
483+ "node": "^20.19.0 || >=22.12.0"
484+ }
485+ },
486+ "node_modules/@rolldown/binding-freebsd-x64": {
487+ "version": "1.1.5",
488+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
489+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
490+ "cpu": [
491+ "x64"
492+ ],
493+ "dev": true,
494+ "license": "MIT",
495+ "optional": true,
496+ "os": [
497+ "freebsd"
498+ ],
499+ "engines": {
500+ "node": "^20.19.0 || >=22.12.0"
501+ }
502+ },
503+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
504+ "version": "1.1.5",
505+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
506+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
507+ "cpu": [
508+ "arm"
509+ ],
510+ "dev": true,
511+ "license": "MIT",
512+ "optional": true,
513+ "os": [
514+ "linux"
515+ ],
516+ "engines": {
517+ "node": "^20.19.0 || >=22.12.0"
518+ }
519+ },
520+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
521+ "version": "1.1.5",
522+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
523+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
524+ "cpu": [
525+ "arm64"
526+ ],
527+ "dev": true,
528+ "libc": [
529+ "glibc"
530+ ],
531+ "license": "MIT",
532+ "optional": true,
533+ "os": [
534+ "linux"
535+ ],
536+ "engines": {
537+ "node": "^20.19.0 || >=22.12.0"
538+ }
539+ },
540+ "node_modules/@rolldown/binding-linux-arm64-musl": {
541+ "version": "1.1.5",
542+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
543+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
544+ "cpu": [
545+ "arm64"
546+ ],
547+ "dev": true,
548+ "libc": [
549+ "musl"
550+ ],
551+ "license": "MIT",
552+ "optional": true,
553+ "os": [
554+ "linux"
555+ ],
556+ "engines": {
557+ "node": "^20.19.0 || >=22.12.0"
558+ }
559+ },
560+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
561+ "version": "1.1.5",
562+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
563+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
564+ "cpu": [
565+ "ppc64"
566+ ],
567+ "dev": true,
568+ "libc": [
569+ "glibc"
570+ ],
571+ "license": "MIT",
572+ "optional": true,
573+ "os": [
574+ "linux"
575+ ],
576+ "engines": {
577+ "node": "^20.19.0 || >=22.12.0"
578+ }
579+ },
580+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
581+ "version": "1.1.5",
582+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
583+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
584+ "cpu": [
585+ "s390x"
586+ ],
587+ "dev": true,
588+ "libc": [
589+ "glibc"
590+ ],
591+ "license": "MIT",
592+ "optional": true,
593+ "os": [
594+ "linux"
595+ ],
596+ "engines": {
597+ "node": "^20.19.0 || >=22.12.0"
598+ }
599+ },
600+ "node_modules/@rolldown/binding-linux-x64-gnu": {
601+ "version": "1.1.5",
602+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
603+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
604+ "cpu": [
605+ "x64"
606+ ],
607+ "dev": true,
608+ "libc": [
609+ "glibc"
610+ ],
611+ "license": "MIT",
612+ "optional": true,
613+ "os": [
614+ "linux"
615+ ],
616+ "engines": {
617+ "node": "^20.19.0 || >=22.12.0"
618+ }
619+ },
620+ "node_modules/@rolldown/binding-linux-x64-musl": {
621+ "version": "1.1.5",
622+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
623+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
624+ "cpu": [
625+ "x64"
626+ ],
627+ "dev": true,
628+ "libc": [
629+ "musl"
630+ ],
631+ "license": "MIT",
632+ "optional": true,
633+ "os": [
634+ "linux"
635+ ],
636+ "engines": {
637+ "node": "^20.19.0 || >=22.12.0"
638+ }
639+ },
640+ "node_modules/@rolldown/binding-openharmony-arm64": {
641+ "version": "1.1.5",
642+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
643+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
644+ "cpu": [
645+ "arm64"
646+ ],
647+ "dev": true,
648+ "license": "MIT",
649+ "optional": true,
650+ "os": [
651+ "openharmony"
652+ ],
653+ "engines": {
654+ "node": "^20.19.0 || >=22.12.0"
655+ }
656+ },
657+ "node_modules/@rolldown/binding-wasm32-wasi": {
658+ "version": "1.1.5",
659+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
660+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
661+ "cpu": [
662+ "wasm32"
663+ ],
664+ "dev": true,
665+ "license": "MIT",
666+ "optional": true,
667+ "dependencies": {
668+ "@emnapi/core": "1.11.1",
669+ "@emnapi/runtime": "1.11.1",
670+ "@napi-rs/wasm-runtime": "^1.1.6"
671+ },
672+ "engines": {
673+ "node": "^20.19.0 || >=22.12.0"
674+ }
675+ },
676+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
677+ "version": "1.1.5",
678+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
679+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
680+ "cpu": [
681+ "arm64"
682+ ],
683+ "dev": true,
684+ "license": "MIT",
685+ "optional": true,
686+ "os": [
687+ "win32"
688+ ],
689+ "engines": {
690+ "node": "^20.19.0 || >=22.12.0"
691+ }
692+ },
693+ "node_modules/@rolldown/binding-win32-x64-msvc": {
694+ "version": "1.1.5",
695+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
696+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
697+ "cpu": [
698+ "x64"
699+ ],
700+ "dev": true,
701+ "license": "MIT",
702+ "optional": true,
703+ "os": [
704+ "win32"
705+ ],
706+ "engines": {
707+ "node": "^20.19.0 || >=22.12.0"
708+ }
709+ },
710+ "node_modules/@rolldown/pluginutils": {
711+ "version": "1.0.1",
712+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
713+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
714+ "dev": true,
715+ "license": "MIT"
716+ },
717+ "node_modules/@tybys/wasm-util": {
718+ "version": "0.10.3",
719+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
720+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
721+ "dev": true,
722+ "license": "MIT",
723+ "optional": true,
724+ "dependencies": {
725+ "tslib": "^2.4.0"
726+ }
727+ },
728+ "node_modules/@types/node": {
729+ "version": "24.13.3",
730+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
731+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
732+ "dev": true,
733+ "license": "MIT",
734+ "dependencies": {
735+ "undici-types": "~7.18.0"
736+ }
737+ },
738+ "node_modules/@types/react": {
739+ "version": "19.2.17",
740+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
741+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
742+ "dev": true,
743+ "license": "MIT",
744+ "dependencies": {
745+ "csstype": "^3.2.2"
746+ }
747+ },
748+ "node_modules/@types/react-dom": {
749+ "version": "19.2.3",
750+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
751+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
752+ "dev": true,
753+ "license": "MIT",
754+ "peerDependencies": {
755+ "@types/react": "^19.2.0"
756+ }
757+ },
758+ "node_modules/@vitejs/plugin-react": {
759+ "version": "6.0.4",
760+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
761+ "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
762+ "dev": true,
763+ "license": "MIT",
764+ "dependencies": {
765+ "@rolldown/pluginutils": "^1.0.1"
766+ },
767+ "engines": {
768+ "node": "^20.19.0 || >=22.12.0"
769+ },
770+ "peerDependencies": {
771+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
772+ "babel-plugin-react-compiler": "^1.0.0",
773+ "vite": "^8.0.0"
774+ },
775+ "peerDependenciesMeta": {
776+ "@rolldown/plugin-babel": {
777+ "optional": true
778+ },
779+ "babel-plugin-react-compiler": {
780+ "optional": true
781+ }
782+ }
783+ },
784+ "node_modules/csstype": {
785+ "version": "3.2.3",
786+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
787+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
788+ "dev": true,
789+ "license": "MIT"
790+ },
791+ "node_modules/detect-libc": {
792+ "version": "2.1.2",
793+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
794+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
795+ "dev": true,
796+ "license": "Apache-2.0",
797+ "engines": {
798+ "node": ">=8"
799+ }
800+ },
801+ "node_modules/fdir": {
802+ "version": "6.5.0",
803+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
804+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
805+ "dev": true,
806+ "license": "MIT",
807+ "engines": {
808+ "node": ">=12.0.0"
809+ },
810+ "peerDependencies": {
811+ "picomatch": "^3 || ^4"
812+ },
813+ "peerDependenciesMeta": {
814+ "picomatch": {
815+ "optional": true
816+ }
817+ }
818+ },
819+ "node_modules/fsevents": {
820+ "version": "2.3.3",
821+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
822+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
823+ "dev": true,
824+ "hasInstallScript": true,
825+ "license": "MIT",
826+ "optional": true,
827+ "os": [
828+ "darwin"
829+ ],
830+ "engines": {
831+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
832+ }
833+ },
834+ "node_modules/h5wasm": {
835+ "version": "0.10.3",
836+ "resolved": "https://registry.npmjs.org/h5wasm/-/h5wasm-0.10.3.tgz",
837+ "integrity": "sha512-W4Jy5ExtX/VNbyD8GdOBckDuj6AL16TemppVNxZsV3rJZEWCv2sxlCzOttZLer3zkMttbDYsWHl0qt1z3Bln+Q==",
838+ "license": "SEE LICENSE IN LICENSE.txt"
839+ },
840+ "node_modules/lightningcss": {
841+ "version": "1.33.0",
842+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
843+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
844+ "dev": true,
845+ "license": "MPL-2.0",
846+ "dependencies": {
847+ "detect-libc": "^2.0.3"
848+ },
849+ "engines": {
850+ "node": ">= 12.0.0"
851+ },
852+ "funding": {
853+ "type": "opencollective",
854+ "url": "https://opencollective.com/parcel"
855+ },
856+ "optionalDependencies": {
857+ "lightningcss-android-arm64": "1.33.0",
858+ "lightningcss-darwin-arm64": "1.33.0",
859+ "lightningcss-darwin-x64": "1.33.0",
860+ "lightningcss-freebsd-x64": "1.33.0",
861+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
862+ "lightningcss-linux-arm64-gnu": "1.33.0",
863+ "lightningcss-linux-arm64-musl": "1.33.0",
864+ "lightningcss-linux-x64-gnu": "1.33.0",
865+ "lightningcss-linux-x64-musl": "1.33.0",
866+ "lightningcss-win32-arm64-msvc": "1.33.0",
867+ "lightningcss-win32-x64-msvc": "1.33.0"
868+ }
869+ },
870+ "node_modules/lightningcss-android-arm64": {
871+ "version": "1.33.0",
872+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
873+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
874+ "cpu": [
875+ "arm64"
876+ ],
877+ "dev": true,
878+ "license": "MPL-2.0",
879+ "optional": true,
880+ "os": [
881+ "android"
882+ ],
883+ "engines": {
884+ "node": ">= 12.0.0"
885+ },
886+ "funding": {
887+ "type": "opencollective",
888+ "url": "https://opencollective.com/parcel"
889+ }
890+ },
891+ "node_modules/lightningcss-darwin-arm64": {
892+ "version": "1.33.0",
893+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
894+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
895+ "cpu": [
896+ "arm64"
897+ ],
898+ "dev": true,
899+ "license": "MPL-2.0",
900+ "optional": true,
901+ "os": [
902+ "darwin"
903+ ],
904+ "engines": {
905+ "node": ">= 12.0.0"
906+ },
907+ "funding": {
908+ "type": "opencollective",
909+ "url": "https://opencollective.com/parcel"
910+ }
911+ },
912+ "node_modules/lightningcss-darwin-x64": {
913+ "version": "1.33.0",
914+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
915+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
916+ "cpu": [
917+ "x64"
918+ ],
919+ "dev": true,
920+ "license": "MPL-2.0",
921+ "optional": true,
922+ "os": [
923+ "darwin"
924+ ],
925+ "engines": {
926+ "node": ">= 12.0.0"
927+ },
928+ "funding": {
929+ "type": "opencollective",
930+ "url": "https://opencollective.com/parcel"
931+ }
932+ },
933+ "node_modules/lightningcss-freebsd-x64": {
934+ "version": "1.33.0",
935+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
936+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
937+ "cpu": [
938+ "x64"
939+ ],
940+ "dev": true,
941+ "license": "MPL-2.0",
942+ "optional": true,
943+ "os": [
944+ "freebsd"
945+ ],
946+ "engines": {
947+ "node": ">= 12.0.0"
948+ },
949+ "funding": {
950+ "type": "opencollective",
951+ "url": "https://opencollective.com/parcel"
952+ }
953+ },
954+ "node_modules/lightningcss-linux-arm-gnueabihf": {
955+ "version": "1.33.0",
956+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
957+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
958+ "cpu": [
959+ "arm"
960+ ],
961+ "dev": true,
962+ "license": "MPL-2.0",
963+ "optional": true,
964+ "os": [
965+ "linux"
966+ ],
967+ "engines": {
968+ "node": ">= 12.0.0"
969+ },
970+ "funding": {
971+ "type": "opencollective",
972+ "url": "https://opencollective.com/parcel"
973+ }
974+ },
975+ "node_modules/lightningcss-linux-arm64-gnu": {
976+ "version": "1.33.0",
977+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
978+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
979+ "cpu": [
980+ "arm64"
981+ ],
982+ "dev": true,
983+ "libc": [
984+ "glibc"
985+ ],
986+ "license": "MPL-2.0",
987+ "optional": true,
988+ "os": [
989+ "linux"
990+ ],
991+ "engines": {
992+ "node": ">= 12.0.0"
993+ },
994+ "funding": {
995+ "type": "opencollective",
996+ "url": "https://opencollective.com/parcel"
997+ }
998+ },
999+ "node_modules/lightningcss-linux-arm64-musl": {
1000+ "version": "1.33.0",
1001+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
1002+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
1003+ "cpu": [
1004+ "arm64"
1005+ ],
1006+ "dev": true,
1007+ "libc": [
1008+ "musl"
1009+ ],
1010+ "license": "MPL-2.0",
1011+ "optional": true,
1012+ "os": [
1013+ "linux"
1014+ ],
1015+ "engines": {
1016+ "node": ">= 12.0.0"
1017+ },
1018+ "funding": {
1019+ "type": "opencollective",
1020+ "url": "https://opencollective.com/parcel"
1021+ }
1022+ },
1023+ "node_modules/lightningcss-linux-x64-gnu": {
1024+ "version": "1.33.0",
1025+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
1026+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
1027+ "cpu": [
1028+ "x64"
1029+ ],
1030+ "dev": true,
1031+ "libc": [
1032+ "glibc"
1033+ ],
1034+ "license": "MPL-2.0",
1035+ "optional": true,
1036+ "os": [
1037+ "linux"
1038+ ],
1039+ "engines": {
1040+ "node": ">= 12.0.0"
1041+ },
1042+ "funding": {
1043+ "type": "opencollective",
1044+ "url": "https://opencollective.com/parcel"
1045+ }
1046+ },
1047+ "node_modules/lightningcss-linux-x64-musl": {
1048+ "version": "1.33.0",
1049+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
1050+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
1051+ "cpu": [
1052+ "x64"
1053+ ],
1054+ "dev": true,
1055+ "libc": [
1056+ "musl"
1057+ ],
1058+ "license": "MPL-2.0",
1059+ "optional": true,
1060+ "os": [
1061+ "linux"
1062+ ],
1063+ "engines": {
1064+ "node": ">= 12.0.0"
1065+ },
1066+ "funding": {
1067+ "type": "opencollective",
1068+ "url": "https://opencollective.com/parcel"
1069+ }
1070+ },
1071+ "node_modules/lightningcss-win32-arm64-msvc": {
1072+ "version": "1.33.0",
1073+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
1074+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
1075+ "cpu": [
1076+ "arm64"
1077+ ],
1078+ "dev": true,
1079+ "license": "MPL-2.0",
1080+ "optional": true,
1081+ "os": [
1082+ "win32"
1083+ ],
1084+ "engines": {
1085+ "node": ">= 12.0.0"
1086+ },
1087+ "funding": {
1088+ "type": "opencollective",
1089+ "url": "https://opencollective.com/parcel"
1090+ }
1091+ },
1092+ "node_modules/lightningcss-win32-x64-msvc": {
1093+ "version": "1.33.0",
1094+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
1095+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
1096+ "cpu": [
1097+ "x64"
1098+ ],
1099+ "dev": true,
1100+ "license": "MPL-2.0",
1101+ "optional": true,
1102+ "os": [
1103+ "win32"
1104+ ],
1105+ "engines": {
1106+ "node": ">= 12.0.0"
1107+ },
1108+ "funding": {
1109+ "type": "opencollective",
1110+ "url": "https://opencollective.com/parcel"
1111+ }
1112+ },
1113+ "node_modules/nanoid": {
1114+ "version": "3.3.16",
1115+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
1116+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
1117+ "dev": true,
1118+ "funding": [
1119+ {
1120+ "type": "github",
1121+ "url": "https://github.com/sponsors/ai"
1122+ }
1123+ ],
1124+ "license": "MIT",
1125+ "bin": {
1126+ "nanoid": "bin/nanoid.cjs"
1127+ },
1128+ "engines": {
1129+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1130+ }
1131+ },
1132+ "node_modules/oxlint": {
1133+ "version": "1.75.0",
1134+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz",
1135+ "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==",
1136+ "dev": true,
1137+ "license": "MIT",
1138+ "bin": {
1139+ "oxlint": "bin/oxlint"
1140+ },
1141+ "engines": {
1142+ "node": "^20.19.0 || >=22.12.0"
1143+ },
1144+ "funding": {
1145+ "url": "https://github.com/sponsors/Boshen"
1146+ },
1147+ "optionalDependencies": {
1148+ "@oxlint/binding-android-arm-eabi": "1.75.0",
1149+ "@oxlint/binding-android-arm64": "1.75.0",
1150+ "@oxlint/binding-darwin-arm64": "1.75.0",
1151+ "@oxlint/binding-darwin-x64": "1.75.0",
1152+ "@oxlint/binding-freebsd-x64": "1.75.0",
1153+ "@oxlint/binding-linux-arm-gnueabihf": "1.75.0",
1154+ "@oxlint/binding-linux-arm-musleabihf": "1.75.0",
1155+ "@oxlint/binding-linux-arm64-gnu": "1.75.0",
1156+ "@oxlint/binding-linux-arm64-musl": "1.75.0",
1157+ "@oxlint/binding-linux-ppc64-gnu": "1.75.0",
1158+ "@oxlint/binding-linux-riscv64-gnu": "1.75.0",
1159+ "@oxlint/binding-linux-riscv64-musl": "1.75.0",
1160+ "@oxlint/binding-linux-s390x-gnu": "1.75.0",
1161+ "@oxlint/binding-linux-x64-gnu": "1.75.0",
1162+ "@oxlint/binding-linux-x64-musl": "1.75.0",
1163+ "@oxlint/binding-openharmony-arm64": "1.75.0",
1164+ "@oxlint/binding-win32-arm64-msvc": "1.75.0",
1165+ "@oxlint/binding-win32-ia32-msvc": "1.75.0",
1166+ "@oxlint/binding-win32-x64-msvc": "1.75.0"
1167+ },
1168+ "peerDependencies": {
1169+ "oxlint-tsgolint": ">=7.0.2001",
1170+ "vite-plus": "*"
1171+ },
1172+ "peerDependenciesMeta": {
1173+ "oxlint-tsgolint": {
1174+ "optional": true
1175+ },
1176+ "vite-plus": {
1177+ "optional": true
1178+ }
1179+ }
1180+ },
1181+ "node_modules/picocolors": {
1182+ "version": "1.1.1",
1183+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1184+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1185+ "dev": true,
1186+ "license": "ISC"
1187+ },
1188+ "node_modules/picomatch": {
1189+ "version": "4.0.5",
1190+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1191+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1192+ "dev": true,
1193+ "license": "MIT",
1194+ "engines": {
1195+ "node": ">=12"
1196+ },
1197+ "funding": {
1198+ "url": "https://github.com/sponsors/jonschlinkert"
1199+ }
1200+ },
1201+ "node_modules/postcss": {
1202+ "version": "8.5.23",
1203+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
1204+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
1205+ "dev": true,
1206+ "funding": [
1207+ {
1208+ "type": "opencollective",
1209+ "url": "https://opencollective.com/postcss/"
1210+ },
1211+ {
1212+ "type": "tidelift",
1213+ "url": "https://tidelift.com/funding/github/npm/postcss"
1214+ },
1215+ {
1216+ "type": "github",
1217+ "url": "https://github.com/sponsors/ai"
1218+ }
1219+ ],
1220+ "license": "MIT",
1221+ "dependencies": {
1222+ "nanoid": "^3.3.16",
1223+ "picocolors": "^1.1.1",
1224+ "source-map-js": "^1.2.1"
1225+ },
1226+ "engines": {
1227+ "node": "^10 || ^12 || >=14"
1228+ }
1229+ },
1230+ "node_modules/react": {
1231+ "version": "19.2.8",
1232+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
1233+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
1234+ "license": "MIT",
1235+ "engines": {
1236+ "node": ">=0.10.0"
1237+ }
1238+ },
1239+ "node_modules/react-dom": {
1240+ "version": "19.2.8",
1241+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
1242+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
1243+ "license": "MIT",
1244+ "dependencies": {
1245+ "scheduler": "^0.27.0"
1246+ },
1247+ "peerDependencies": {
1248+ "react": "^19.2.8"
1249+ }
1250+ },
1251+ "node_modules/rolldown": {
1252+ "version": "1.1.5",
1253+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
1254+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
1255+ "dev": true,
1256+ "license": "MIT",
1257+ "dependencies": {
1258+ "@oxc-project/types": "=0.139.0",
1259+ "@rolldown/pluginutils": "^1.0.0"
1260+ },
1261+ "bin": {
1262+ "rolldown": "bin/cli.mjs"
1263+ },
1264+ "engines": {
1265+ "node": "^20.19.0 || >=22.12.0"
1266+ },
1267+ "optionalDependencies": {
1268+ "@rolldown/binding-android-arm64": "1.1.5",
1269+ "@rolldown/binding-darwin-arm64": "1.1.5",
1270+ "@rolldown/binding-darwin-x64": "1.1.5",
1271+ "@rolldown/binding-freebsd-x64": "1.1.5",
1272+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
1273+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
1274+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
1275+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
1276+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
1277+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
1278+ "@rolldown/binding-linux-x64-musl": "1.1.5",
1279+ "@rolldown/binding-openharmony-arm64": "1.1.5",
1280+ "@rolldown/binding-wasm32-wasi": "1.1.5",
1281+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
1282+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
1283+ }
1284+ },
1285+ "node_modules/scheduler": {
1286+ "version": "0.27.0",
1287+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1288+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1289+ "license": "MIT"
1290+ },
1291+ "node_modules/source-map-js": {
1292+ "version": "1.2.1",
1293+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1294+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1295+ "dev": true,
1296+ "license": "BSD-3-Clause",
1297+ "engines": {
1298+ "node": ">=0.10.0"
1299+ }
1300+ },
1301+ "node_modules/tinyglobby": {
1302+ "version": "0.2.17",
1303+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1304+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1305+ "dev": true,
1306+ "license": "MIT",
1307+ "dependencies": {
1308+ "fdir": "^6.5.0",
1309+ "picomatch": "^4.0.4"
1310+ },
1311+ "engines": {
1312+ "node": ">=12.0.0"
1313+ },
1314+ "funding": {
1315+ "url": "https://github.com/sponsors/SuperchupuDev"
1316+ }
1317+ },
1318+ "node_modules/tslib": {
1319+ "version": "2.8.1",
1320+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1321+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1322+ "dev": true,
1323+ "license": "0BSD",
1324+ "optional": true
1325+ },
1326+ "node_modules/typescript": {
1327+ "version": "6.0.3",
1328+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
1329+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
1330+ "dev": true,
1331+ "license": "Apache-2.0",
1332+ "bin": {
1333+ "tsc": "bin/tsc",
1334+ "tsserver": "bin/tsserver"
1335+ },
1336+ "engines": {
1337+ "node": ">=14.17"
1338+ }
1339+ },
1340+ "node_modules/undici-types": {
1341+ "version": "7.18.2",
1342+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1343+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1344+ "dev": true,
1345+ "license": "MIT"
1346+ },
1347+ "node_modules/vite": {
1348+ "version": "8.1.5",
1349+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
1350+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
1351+ "dev": true,
1352+ "license": "MIT",
1353+ "dependencies": {
1354+ "lightningcss": "^1.32.0",
1355+ "picomatch": "^4.0.5",
1356+ "postcss": "^8.5.17",
1357+ "rolldown": "~1.1.5",
1358+ "tinyglobby": "^0.2.17"
1359+ },
1360+ "bin": {
1361+ "vite": "bin/vite.js"
1362+ },
1363+ "engines": {
1364+ "node": "^20.19.0 || >=22.12.0"
1365+ },
1366+ "funding": {
1367+ "url": "https://github.com/vitejs/vite?sponsor=1"
1368+ },
1369+ "optionalDependencies": {
1370+ "fsevents": "~2.3.3"
1371+ },
1372+ "peerDependencies": {
1373+ "@types/node": "^20.19.0 || >=22.12.0",
1374+ "@vitejs/devtools": "^0.3.0",
1375+ "esbuild": "^0.27.0 || ^0.28.0",
1376+ "jiti": ">=1.21.0",
1377+ "less": "^4.0.0",
1378+ "sass": "^1.70.0",
1379+ "sass-embedded": "^1.70.0",
1380+ "stylus": ">=0.54.8",
1381+ "sugarss": "^5.0.0",
1382+ "terser": "^5.16.0",
1383+ "tsx": "^4.8.1",
1384+ "yaml": "^2.4.2"
1385+ },
1386+ "peerDependenciesMeta": {
1387+ "@types/node": {
1388+ "optional": true
1389+ },
1390+ "@vitejs/devtools": {
1391+ "optional": true
1392+ },
1393+ "esbuild": {
1394+ "optional": true
1395+ },
1396+ "jiti": {
1397+ "optional": true
1398+ },
1399+ "less": {
1400+ "optional": true
1401+ },
1402+ "sass": {
1403+ "optional": true
1404+ },
1405+ "sass-embedded": {
1406+ "optional": true
1407+ },
1408+ "stylus": {
1409+ "optional": true
1410+ },
1411+ "sugarss": {
1412+ "optional": true
1413+ },
1414+ "terser": {
1415+ "optional": true
1416+ },
1417+ "tsx": {
1418+ "optional": true
1419+ },
1420+ "yaml": {
1421+ "optional": true
1422+ }
1423+ }
1424+ }
1425+ }
1426+}
package.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "name": "mri-scanner",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "oxlint",
10+ "preview": "vite preview",
11+ "gen-phantoms": "node scripts/gen-phantoms.mjs",
12+ "sim-test": "node scripts/sim-test.mjs"
13+ },
14+ "dependencies": {
15+ "h5wasm": "^0.10.3",
16+ "react": "^19.2.7",
17+ "react-dom": "^19.2.7"
18+ },
19+ "devDependencies": {
20+ "@types/node": "^24.13.2",
21+ "@types/react": "^19.2.17",
22+ "@types/react-dom": "^19.2.3",
23+ "@vitejs/plugin-react": "^6.0.3",
24+ "oxlint": "^1.71.0",
25+ "typescript": "~6.0.2",
26+ "vite": "^8.1.1"
27+ }
28+}
public/favicon.svgadded+6−0View file
@@ -0,0 +1,6 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="6" fill="#0b1020" />
3+ <circle cx="16" cy="16" r="10" fill="none" stroke="#4fd1c5" stroke-width="2" />
4+ <circle cx="16" cy="16" r="2.4" fill="#f6ad55" />
5+ <path d="M16 6 v20 M6 16 h20" stroke="#4fd1c5" stroke-width="1" opacity="0.35" />
6+</svg>
scripts/gen-phantoms.mjsadded+112−0View file
@@ -0,0 +1,112 @@
1+// Generate the two built-in digital phantoms as genuine KomaMRI `.phantom`
2+// (HDF5) files: a small cube and a small sphere, ~10 mm, densely and uniformly
3+// sampled with spins. Structure mirrors ../KomaMRI.jl write_phantom:
4+// root attrs: Version, Name, Ns, Dims
5+// group "position": x, y, z (metres)
6+// group "contrast": ρ, T1, T2, T2s, Δw (s, s, s, rad/s)
7+//
8+// Run: npm run gen-phantoms (writes src/phantom/data/{cube,sphere}.phantom)
9+import * as h5 from 'h5wasm/node'
10+import fs from 'node:fs'
11+import path from 'node:path'
12+import { fileURLToPath } from 'node:url'
13+
14+const __dirname = path.dirname(fileURLToPath(import.meta.url))
15+const OUT_DIR = path.resolve(__dirname, '../src/phantom/data')
16+
17+// Geometry (metres) and uniform spin spacing.
18+const SIZE_MM = 10 // cube side / sphere diameter
19+const SPACING_MM = 0.8 // uniform grid spacing
20+const MM = 1e-3
21+
22+// Uniform tissue properties (a single "water-like" material for the whole object).
23+const RHO = 1.0
24+const T1 = 1.0 // s
25+const T2 = 0.1 // s
26+const T2S = 0.05 // s
27+const DW = 0.0 // rad/s
28+
29+/** Build a centred uniform grid of points, keeping those for which keep(x,y,z) (metres). */
30+function buildGrid(sizeMm, spacingMm, keep) {
31+ const n = Math.floor(sizeMm / spacingMm) + 1 // points per axis
32+ const span = (n - 1) * spacingMm // actual extent covered
33+ const start = -span / 2 // centre on origin
34+ const xs = [],
35+ ys = [],
36+ zs = []
37+ for (let i = 0; i < n; i++) {
38+ const x = (start + i * spacingMm) * MM
39+ for (let j = 0; j < n; j++) {
40+ const y = (start + j * spacingMm) * MM
41+ for (let k = 0; k < n; k++) {
42+ const z = (start + k * spacingMm) * MM
43+ if (keep(x, y, z)) {
44+ xs.push(x)
45+ ys.push(y)
46+ zs.push(z)
47+ }
48+ }
49+ }
50+ }
51+ return {
52+ x: Float32Array.from(xs),
53+ y: Float32Array.from(ys),
54+ z: Float32Array.from(zs),
55+ }
56+}
57+
58+function writePhantom(name, grid) {
59+ const ns = grid.x.length
60+ const fill = (v) => {
61+ const a = new Float32Array(ns)
62+ a.fill(v)
63+ return a
64+ }
65+ // h5wasm/node writes to the real working directory, so use a temp name and
66+ // unlink it after reading the bytes back out.
67+ const filename = `.gen-${name}.phantom.tmp`
68+ const f = new h5.File(filename, 'w')
69+ f.create_attribute('Version', '1.0.0')
70+ f.create_attribute('Name', name)
71+ f.create_attribute('Ns', ns, [], '<i4')
72+ f.create_attribute('Dims', 3, [], '<i4')
73+
74+ const pos = f.create_group('position')
75+ pos.create_dataset({ name: 'x', data: grid.x, dtype: '<f4' })
76+ pos.create_dataset({ name: 'y', data: grid.y, dtype: '<f4' })
77+ pos.create_dataset({ name: 'z', data: grid.z, dtype: '<f4' })
78+
79+ const con = f.create_group('contrast')
80+ con.create_dataset({ name: 'ρ', data: fill(RHO), dtype: '<f4' })
81+ con.create_dataset({ name: 'T1', data: fill(T1), dtype: '<f4' })
82+ con.create_dataset({ name: 'T2', data: fill(T2), dtype: '<f4' })
83+ con.create_dataset({ name: 'T2s', data: fill(T2S), dtype: '<f4' })
84+ con.create_dataset({ name: 'Δw', data: fill(DW), dtype: '<f4' })
85+
86+ f.flush()
87+ f.close()
88+
89+ const bytes = h5.FS.readFile(filename)
90+ h5.FS.unlink(filename)
91+ const outPath = path.join(OUT_DIR, `${name}.phantom`)
92+ fs.writeFileSync(outPath, Buffer.from(bytes))
93+ console.log(` ${name}.phantom: ${ns} spins, ${bytes.length} bytes -> ${path.relative(process.cwd(), outPath)}`)
94+}
95+
96+async function main() {
97+ await h5.ready
98+ fs.mkdirSync(OUT_DIR, { recursive: true })
99+ const r = (SIZE_MM / 2) * MM
100+ console.log('Generating phantoms (size %d mm, spacing %d mm):', SIZE_MM, SPACING_MM)
101+ writePhantom(
102+ 'cube',
103+ buildGrid(SIZE_MM, SPACING_MM, () => true),
104+ )
105+ writePhantom(
106+ 'sphere',
107+ buildGrid(SIZE_MM, SPACING_MM, (x, y, z) => x * x + y * y + z * z <= r * r + 1e-18),
108+ )
109+ console.log('Done.')
110+}
111+
112+main()
scripts/sim-test.mjsadded+114−0View file
@@ -0,0 +1,114 @@
1+// Headless smoke test for the Bloch simulator. Reads a phantom (.phantom via
2+// h5wasm) and a golden .seq, runs simulate(), and checks basic physics:
3+// - FID: right after a 90° pulse all spins are in phase, so |S| at the first
4+// ADC sample ≈ Σρ (≈ number of spins), decaying by T2 thereafter.
5+// - GRE: the DC point of k-space (gradient-refocused echo centre) should be
6+// the brightest sample in its readout.
7+// Run: npm run sim-test
8+import * as h5 from 'h5wasm/node'
9+import fs from 'node:fs'
10+import path from 'node:path'
11+import { fileURLToPath } from 'node:url'
12+import { parseSeq } from '../src/seq/parseSeq.ts'
13+import { simulate } from '../src/sim/simulate.ts'
14+
15+const __dirname = path.dirname(fileURLToPath(import.meta.url))
16+const root = path.resolve(__dirname, '..')
17+
18+function loadPhantomNode(file) {
19+ const bytes = fs.readFileSync(file)
20+ // h5wasm/node writes to the real cwd; use a temp name and unlink after.
21+ const tmp = `.sim-test-${path.basename(file)}.tmp`
22+ h5.FS.writeFile(tmp, new Uint8Array(bytes))
23+ const f = new h5.File(tmp, 'r')
24+ const g = (p) => Float32Array.from(f.get(p).value)
25+ const ph = {
26+ name: path.basename(file),
27+ x: g('position/x'),
28+ y: g('position/y'),
29+ z: g('position/z'),
30+ rho: g('contrast/ρ'),
31+ t1: g('contrast/T1'),
32+ t2: g('contrast/T2'),
33+ t2s: g('contrast/T2s'),
34+ dw: g('contrast/Δw'),
35+ }
36+ ph.ns = ph.x.length
37+ f.close()
38+ h5.FS.unlink(tmp)
39+ return ph
40+}
41+
42+function mag(sig, i) {
43+ return Math.hypot(sig.re[i], sig.im[i])
44+}
45+
46+let failures = 0
47+function check(name, cond, detail) {
48+ if (cond) {
49+ console.log(` ✓ ${name}${detail ? ' — ' + detail : ''}`)
50+ } else {
51+ console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`)
52+ failures++
53+ }
54+}
55+
56+async function main() {
57+ await h5.ready
58+ const cube = loadPhantomNode(path.join(root, 'src/phantom/data/cube.phantom'))
59+ console.log(`Phantom: cube, ${cube.ns} spins`)
60+
61+ // --- FID ---
62+ console.log('\nFID (test-data/fid.seq):')
63+ const fidSeq = parseSeq(fs.readFileSync(path.join(root, 'test-data/fid.seq'), 'utf8'))
64+ const t0 = Date.now()
65+ const fid = simulate(fidSeq, cube)
66+ console.log(` simulated in ${Date.now() - t0} ms; ${fid.numReadouts} readouts, ${fid.re.length} samples`)
67+ const m0 = mag(fid, 0)
68+ const sumRho = cube.rho.reduce((a, b) => a + b, 0)
69+ check('all samples finite', fid.re.every((v) => Number.isFinite(v)) && fid.im.every((v) => Number.isFinite(v)))
70+ // Right after a 90° pulse all spins are in phase; |S(0)| = Σρ, minus T2 decay
71+ // over the ~20 ms of dead time before the first sample. So it should be a
72+ // large fraction of Σρ but not exceed it.
73+ check('|S(0)| ~ Σρ (in phase)', m0 > 0.7 * sumRho && m0 <= sumRho * 1.001, `|S(0)|=${m0.toFixed(1)}, Σρ=${sumRho.toFixed(0)}`)
74+ // Direct T2 validation: within a readout, |S| must decay as exp(-t/T2).
75+ const dwell = fidSeq.adcs.get(1).dwell
76+ const T2 = cube.t2[0]
77+ const K = 400
78+ const ratio = mag(fid, K) / m0
79+ const expected = Math.exp(-(K * dwell) / T2)
80+ check('FID decays at rate 1/T2', Math.abs(ratio - expected) / expected < 0.02, `|S(${K})|/|S(0)|=${ratio.toFixed(4)}, exp(-t/T2)=${expected.toFixed(4)}`)
81+
82+ // --- GRE ---
83+ console.log('\nGRE (test-data/gre.seq):')
84+ const greSeq = parseSeq(fs.readFileSync(path.join(root, 'test-data/gre.seq'), 'utf8'))
85+ const t1 = Date.now()
86+ const gre = simulate(greSeq, cube, {
87+ onProgress: (p) => {
88+ if (p.segment === 1 || p.fraction === 1 || p.segment % 20000 === 0)
89+ process.stdout.write(`\r progress ${(p.fraction * 100).toFixed(1)}% (seg ${p.segment}/${p.numSegments}, ${p.samplesDone}/${p.numSamples} samples) `)
90+ },
91+ })
92+ process.stdout.write('\n')
93+ console.log(` simulated in ${((Date.now() - t1) / 1000).toFixed(1)} s; ${gre.numReadouts} readouts, ${gre.maxSamplesPerReadout} samples/readout`)
94+ check('all samples finite', gre.re.every((v) => Number.isFinite(v)) && gre.im.every((v) => Number.isFinite(v)))
95+ // For the middle readout, the brightest sample should be near the readout centre (k=0 echo).
96+ const midR = Math.floor(gre.numReadouts / 2)
97+ const o = gre.offsets[midR]
98+ const n = gre.samplesPerReadout[midR]
99+ let peak = -1
100+ let peakIdx = -1
101+ for (let i = 0; i < n; i++) {
102+ const m = mag(gre, o + i)
103+ if (m > peak) {
104+ peak = m
105+ peakIdx = i
106+ }
107+ }
108+ check('GRE echo peaks near readout centre', Math.abs(peakIdx - n / 2) < n * 0.2, `peak at col ${peakIdx}/${n}`)
109+
110+ console.log(`\n${failures === 0 ? 'All checks passed.' : failures + ' check(s) FAILED.'}`)
111+ process.exit(failures === 0 ? 0 : 1)
112+}
113+
114+main()
src/App.tsxadded+242−0View file
@@ -0,0 +1,242 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+import { SequencePanel } from './ui/SequencePanel.tsx'
3+import { PhantomPanel } from './ui/PhantomPanel.tsx'
4+import { KspaceView } from './kspace/KspaceView.tsx'
5+import { BUILTIN_PHANTOMS, fetchBuiltinPhantom } from './phantom/builtins.ts'
6+import type { Phantom } from './phantom/phantomTypes.ts'
7+import {
8+ addSequence,
9+ deleteSequence,
10+ listSequences,
11+ renameSequence,
12+} from './storage/seqStore.ts'
13+import type { StoredSequence } from './storage/seqStore.ts'
14+import { summarizeSeq } from './seq/summary.ts'
15+import { useSimulation } from './sim/useSimulation.ts'
16+
17+function stripExt(name: string): string {
18+ return name.replace(/\.seq$/i, '')
19+}
20+
21+export default function App() {
22+ const [sequences, setSequences] = useState<StoredSequence[]>([])
23+ const [selectedSeqId, setSelectedSeqId] = useState<string | null>(null)
24+ const [phantomId, setPhantomId] = useState<string>(BUILTIN_PHANTOMS[0].id)
25+ const [phantom, setPhantom] = useState<Phantom | null>(null)
26+ const [phantomLoading, setPhantomLoading] = useState(false)
27+ const [uploadError, setUploadError] = useState<string | null>(null)
28+ const phantomCache = useRef<Map<string, Phantom>>(new Map())
29+
30+ const { state: sim, run, cancel, reset } = useSimulation()
31+
32+ const refresh = useCallback(async () => {
33+ const list = await listSequences()
34+ setSequences(list)
35+ setSelectedSeqId((cur) => cur ?? (list.length ? list[0].id : null))
36+ }, [])
37+
38+ useEffect(() => {
39+ void refresh()
40+ }, [refresh])
41+
42+ // Load the selected phantom (cached).
43+ useEffect(() => {
44+ let cancelled = false
45+ const cached = phantomCache.current.get(phantomId)
46+ if (cached) {
47+ setPhantom(cached)
48+ return
49+ }
50+ const builtin = BUILTIN_PHANTOMS.find((b) => b.id === phantomId)
51+ if (!builtin) return
52+ setPhantomLoading(true)
53+ fetchBuiltinPhantom(builtin)
54+ .then((p) => {
55+ if (cancelled) return
56+ phantomCache.current.set(phantomId, p)
57+ setPhantom(p)
58+ })
59+ .catch((err) => {
60+ if (!cancelled) console.error('Failed to load phantom', err)
61+ })
62+ .finally(() => {
63+ if (!cancelled) setPhantomLoading(false)
64+ })
65+ return () => {
66+ cancelled = true
67+ }
68+ }, [phantomId])
69+
70+ const selectedSeq = useMemo(
71+ () => sequences.find((s) => s.id === selectedSeqId) ?? null,
72+ [sequences, selectedSeqId],
73+ )
74+ const summary = useMemo(() => (selectedSeq ? summarizeSeq(selectedSeq.text) : null), [selectedSeq])
75+
76+ const handleUpload = useCallback(
77+ async (files: FileList) => {
78+ setUploadError(null)
79+ let lastId: string | null = null
80+ for (const file of Array.from(files)) {
81+ const text = await file.text()
82+ const s = summarizeSeq(text)
83+ if (!s.ok) {
84+ setUploadError(`"${file.name}" is not a valid .seq file: ${s.error}`)
85+ continue
86+ }
87+ const rec = await addSequence(stripExt(file.name), text)
88+ lastId = rec.id
89+ }
90+ await refresh()
91+ if (lastId) setSelectedSeqId(lastId)
92+ },
93+ [refresh],
94+ )
95+
96+ const handleRename = useCallback(
97+ async (id: string, name: string) => {
98+ await renameSequence(id, name)
99+ await refresh()
100+ },
101+ [refresh],
102+ )
103+
104+ const handleDelete = useCallback(
105+ async (id: string) => {
106+ await deleteSequence(id)
107+ const list = await listSequences()
108+ setSequences(list)
109+ setSelectedSeqId((cur) => (cur === id ? (list[0]?.id ?? null) : cur))
110+ },
111+ [],
112+ )
113+
114+ const canRun = !!selectedSeq && !!phantom && sim.status !== 'running'
115+ const handleRun = useCallback(() => {
116+ if (!selectedSeq || !phantom) return
117+ reset()
118+ run(selectedSeq.text, phantom)
119+ }, [selectedSeq, phantom, run, reset])
120+
121+ const pct = sim.progress ? Math.round(sim.progress.fraction * 100) : 0
122+
123+ return (
124+ <div className="app">
125+ <header className="app-header">
126+ <div className="brand">
127+ <span className="logo" aria-hidden>
128+ ⌾
129+ </span>
130+ <div>
131+ <h1>mri-scanner</h1>
132+ <p>Upload a pulseq sequence, run a Bloch simulation on a digital phantom, and view the raw k-space.</p>
133+ </div>
134+ </div>
135+ <a className="header-link" href="https://concept-collection.github.io/seqlab/" target="_blank" rel="noreferrer">
136+ seqlab ↗
137+ </a>
138+ </header>
139+
140+ <div className="layout">
141+ <aside className="sidebar">
142+ <SequencePanel
143+ sequences={sequences}
144+ selectedId={selectedSeqId}
145+ onSelect={setSelectedSeqId}
146+ onUpload={handleUpload}
147+ onRename={handleRename}
148+ onDelete={handleDelete}
149+ />
150+ {uploadError && <div className="error-banner">{uploadError}</div>}
151+
152+ <PhantomPanel
153+ builtins={BUILTIN_PHANTOMS}
154+ selectedId={phantomId}
155+ onSelect={setPhantomId}
156+ phantom={phantom}
157+ loading={phantomLoading}
158+ />
159+
160+ <section className="panel run-panel">
161+ <div className="panel-head">
162+ <h2>Simulate</h2>
163+ </div>
164+ {summary && summary.ok && (
165+ <div className="seq-summary">
166+ <div>
167+ <span className="k">Sequence</span>
168+ <span className="v">{selectedSeq?.name}</span>
169+ </div>
170+ <div>
171+ <span className="k">Duration</span>
172+ <span className="v">{summary.durationSec.toFixed(3)} s</span>
173+ </div>
174+ <div>
175+ <span className="k">Blocks / RF / ADC</span>
176+ <span className="v">
177+ {summary.numBlocks} / {summary.rfCount} / {summary.adcCount}
178+ </span>
179+ </div>
180+ <div>
181+ <span className="k">ADC samples</span>
182+ <span className="v">{summary.adcSamples.toLocaleString()}</span>
183+ </div>
184+ </div>
185+ )}
186+ {!selectedSeq && <p className="muted">Select a sequence to simulate.</p>}
187+
188+ <div className="run-controls">
189+ {sim.status === 'running' ? (
190+ <button type="button" className="btn danger" onClick={cancel}>
191+ Cancel
192+ </button>
193+ ) : (
194+ <button type="button" className="btn primary" disabled={!canRun} onClick={handleRun}>
195+ ▶ Run simulation
196+ </button>
197+ )}
198+ </div>
199+
200+ {sim.status === 'running' && (
201+ <div className="progress">
202+ <div className="progress-bar">
203+ <div className="progress-fill" style={{ width: `${pct}%` }} />
204+ </div>
205+ <div className="progress-text">
206+ {pct}% · {sim.progress?.samplesDone.toLocaleString()}/
207+ {sim.progress?.numSamples.toLocaleString()} samples · {(sim.elapsedMs / 1000).toFixed(1)} s
208+ </div>
209+ </div>
210+ )}
211+ {sim.status === 'cancelled' && <p className="muted">Simulation cancelled.</p>}
212+ {sim.status === 'error' && <div className="error-banner">Simulation error: {sim.error}</div>}
213+ </section>
214+ </aside>
215+
216+ <main className="main">
217+ {sim.status === 'done' && sim.result ? (
218+ <KspaceView signal={sim.result} elapsedMs={sim.elapsedMs} />
219+ ) : (
220+ <div className="placeholder">
221+ <div className="placeholder-inner">
222+ {sim.status === 'running' ? (
223+ <>
224+ <div className="spinner" />
225+ <p>Simulating {selectedSeq?.name}…</p>
226+ </>
227+ ) : (
228+ <>
229+ <span className="placeholder-icon" aria-hidden>
230+ ⌾
231+ </span>
232+ <p>The raw k-space will appear here after you run a simulation.</p>
233+ </>
234+ )}
235+ </div>
236+ </div>
237+ )}
238+ </main>
239+ </div>
240+ </div>
241+ )
242+}
src/index.cssadded+502−0View file
@@ -0,0 +1,502 @@
1+:root {
2+ --bg: #0b0f1a;
3+ --bg-elev: #131a2a;
4+ --panel: #141b2c;
5+ --panel-2: #1b2438;
6+ --border: #263349;
7+ --text: #e6ecf5;
8+ --muted: #8b98ad;
9+ --accent: #4fd1c5;
10+ --accent-2: #6aa6ff;
11+ --danger: #ff6b6b;
12+ --danger-bg: #3a1e26;
13+ color-scheme: dark;
14+}
15+
16+* {
17+ box-sizing: border-box;
18+}
19+
20+html,
21+body,
22+#root {
23+ height: 100%;
24+ margin: 0;
25+}
26+
27+body {
28+ background: var(--bg);
29+ color: var(--text);
30+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
31+ font-size: 14px;
32+ line-height: 1.5;
33+}
34+
35+code {
36+ font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
37+ background: rgba(255, 255, 255, 0.06);
38+ padding: 0 4px;
39+ border-radius: 4px;
40+ font-size: 0.9em;
41+}
42+
43+a {
44+ color: var(--accent-2);
45+ text-decoration: none;
46+}
47+a:hover {
48+ text-decoration: underline;
49+}
50+
51+.app {
52+ display: flex;
53+ flex-direction: column;
54+ height: 100%;
55+}
56+
57+/* Header */
58+.app-header {
59+ display: flex;
60+ align-items: center;
61+ justify-content: space-between;
62+ padding: 14px 20px;
63+ border-bottom: 1px solid var(--border);
64+ background: linear-gradient(180deg, var(--bg-elev), var(--bg));
65+}
66+.brand {
67+ display: flex;
68+ align-items: center;
69+ gap: 14px;
70+}
71+.logo {
72+ font-size: 32px;
73+ color: var(--accent);
74+ line-height: 1;
75+}
76+.app-header h1 {
77+ margin: 0;
78+ font-size: 18px;
79+ letter-spacing: 0.3px;
80+}
81+.app-header p {
82+ margin: 2px 0 0;
83+ color: var(--muted);
84+ font-size: 12.5px;
85+ max-width: 640px;
86+}
87+.header-link {
88+ font-size: 13px;
89+ padding: 6px 12px;
90+ border: 1px solid var(--border);
91+ border-radius: 8px;
92+}
93+
94+/* Layout */
95+.layout {
96+ flex: 1;
97+ display: grid;
98+ grid-template-columns: 340px 1fr;
99+ min-height: 0;
100+}
101+.sidebar {
102+ border-right: 1px solid var(--border);
103+ overflow-y: auto;
104+ padding: 16px;
105+ display: flex;
106+ flex-direction: column;
107+ gap: 16px;
108+ background: var(--bg);
109+}
110+.main {
111+ min-width: 0;
112+ min-height: 0;
113+ padding: 20px;
114+ display: flex;
115+ overflow: auto;
116+}
117+
118+/* Panels */
119+.panel {
120+ background: var(--panel);
121+ border: 1px solid var(--border);
122+ border-radius: 12px;
123+ padding: 14px;
124+}
125+.panel-head {
126+ display: flex;
127+ align-items: center;
128+ justify-content: space-between;
129+ gap: 8px;
130+ margin-bottom: 10px;
131+}
132+.panel-head h2 {
133+ margin: 0;
134+ font-size: 13px;
135+ text-transform: uppercase;
136+ letter-spacing: 0.8px;
137+ color: var(--muted);
138+}
139+
140+.muted {
141+ color: var(--muted);
142+}
143+.small {
144+ font-size: 12px;
145+}
146+
147+/* Buttons */
148+.btn {
149+ font: inherit;
150+ border: 1px solid var(--border);
151+ background: var(--panel-2);
152+ color: var(--text);
153+ padding: 9px 16px;
154+ border-radius: 8px;
155+ cursor: pointer;
156+ transition: filter 0.12s, background 0.12s;
157+}
158+.btn:hover:not(:disabled) {
159+ filter: brightness(1.15);
160+}
161+.btn:disabled {
162+ opacity: 0.45;
163+ cursor: not-allowed;
164+}
165+.btn.primary {
166+ background: linear-gradient(180deg, var(--accent), #35b0a4);
167+ color: #04231f;
168+ border-color: transparent;
169+ font-weight: 600;
170+}
171+.btn.danger {
172+ background: var(--danger-bg);
173+ border-color: #5a2a33;
174+ color: #ffb3b3;
175+}
176+.btn-small {
177+ font: inherit;
178+ font-size: 12.5px;
179+ padding: 5px 10px;
180+ border-radius: 7px;
181+ border: 1px solid var(--border);
182+ background: var(--panel-2);
183+ color: var(--text);
184+ cursor: pointer;
185+}
186+.btn-small:hover {
187+ filter: brightness(1.2);
188+}
189+
190+/* Dropzone */
191+.dropzone {
192+ border: 1.5px dashed var(--border);
193+ border-radius: 10px;
194+ padding: 14px;
195+ text-align: center;
196+ color: var(--muted);
197+ font-size: 12.5px;
198+ margin-bottom: 12px;
199+ transition: border-color 0.12s, background 0.12s;
200+}
201+.dropzone.dragging {
202+ border-color: var(--accent);
203+ background: rgba(79, 209, 197, 0.08);
204+ color: var(--text);
205+}
206+
207+/* Sequence list */
208+.seq-list {
209+ list-style: none;
210+ margin: 0;
211+ padding: 0;
212+ display: flex;
213+ flex-direction: column;
214+ gap: 6px;
215+}
216+.seq-list li {
217+ border: 1px solid var(--border);
218+ border-radius: 9px;
219+ padding: 8px 10px;
220+ cursor: pointer;
221+ background: var(--panel-2);
222+ transition: border-color 0.12s, background 0.12s;
223+}
224+.seq-list li:hover {
225+ border-color: #35486a;
226+}
227+.seq-list li.selected {
228+ border-color: var(--accent);
229+ background: rgba(79, 209, 197, 0.09);
230+}
231+.seq-row {
232+ display: flex;
233+ align-items: center;
234+ gap: 8px;
235+}
236+.seq-name {
237+ flex: 1;
238+ overflow: hidden;
239+ text-overflow: ellipsis;
240+ white-space: nowrap;
241+ font-weight: 500;
242+}
243+.seq-actions {
244+ display: flex;
245+ gap: 2px;
246+}
247+.icon-btn {
248+ border: none;
249+ background: transparent;
250+ color: var(--muted);
251+ cursor: pointer;
252+ font-size: 13px;
253+ padding: 3px 5px;
254+ border-radius: 6px;
255+}
256+.icon-btn:hover {
257+ background: rgba(255, 255, 255, 0.08);
258+ color: var(--text);
259+}
260+.icon-btn.danger:hover {
261+ color: var(--danger);
262+}
263+.seq-meta {
264+ color: var(--muted);
265+ font-size: 11.5px;
266+ margin-left: 26px;
267+}
268+.rename-input {
269+ flex: 1;
270+ font: inherit;
271+ background: var(--bg);
272+ border: 1px solid var(--accent);
273+ color: var(--text);
274+ border-radius: 6px;
275+ padding: 3px 6px;
276+}
277+
278+/* Phantom panel */
279+.phantom-options {
280+ display: flex;
281+ gap: 8px;
282+ margin-bottom: 12px;
283+}
284+.phantom-option {
285+ flex: 1;
286+ display: grid;
287+ grid-template-columns: auto 1fr;
288+ grid-template-rows: auto auto;
289+ gap: 1px 8px;
290+ align-items: center;
291+ padding: 9px 11px;
292+ border: 1px solid var(--border);
293+ border-radius: 9px;
294+ background: var(--panel-2);
295+ cursor: pointer;
296+}
297+.phantom-option.selected {
298+ border-color: var(--accent);
299+ background: rgba(79, 209, 197, 0.09);
300+}
301+.phantom-option input {
302+ grid-row: 1 / 3;
303+}
304+.phantom-label {
305+ font-weight: 600;
306+}
307+.phantom-desc {
308+ grid-column: 2;
309+ color: var(--muted);
310+ font-size: 11.5px;
311+}
312+.phantom-preview-wrap {
313+ display: flex;
314+ gap: 14px;
315+ align-items: center;
316+}
317+.phantom-preview {
318+ background: radial-gradient(circle at 50% 45%, #0f1830, #070b14);
319+ border: 1px solid var(--border);
320+ border-radius: 10px;
321+ width: 150px;
322+ height: 150px;
323+}
324+.phantom-stats {
325+ font-size: 12.5px;
326+ display: flex;
327+ flex-direction: column;
328+ gap: 3px;
329+}
330+.phantom-stats strong {
331+ font-size: 16px;
332+ color: var(--accent);
333+}
334+
335+/* Run panel */
336+.seq-summary {
337+ display: flex;
338+ flex-direction: column;
339+ gap: 5px;
340+ margin-bottom: 12px;
341+ font-size: 12.5px;
342+}
343+.seq-summary > div {
344+ display: flex;
345+ justify-content: space-between;
346+ gap: 12px;
347+}
348+.seq-summary .k {
349+ color: var(--muted);
350+}
351+.seq-summary .v {
352+ font-variant-numeric: tabular-nums;
353+ text-align: right;
354+}
355+.run-controls {
356+ display: flex;
357+ gap: 8px;
358+}
359+.run-controls .btn {
360+ flex: 1;
361+}
362+.progress {
363+ margin-top: 12px;
364+}
365+.progress-bar {
366+ height: 8px;
367+ background: var(--bg);
368+ border-radius: 5px;
369+ overflow: hidden;
370+ border: 1px solid var(--border);
371+}
372+.progress-fill {
373+ height: 100%;
374+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
375+ transition: width 0.15s ease-out;
376+}
377+.progress-text {
378+ margin-top: 6px;
379+ font-size: 12px;
380+ color: var(--muted);
381+ font-variant-numeric: tabular-nums;
382+}
383+.error-banner {
384+ background: var(--danger-bg);
385+ border: 1px solid #5a2a33;
386+ color: #ffb3b3;
387+ padding: 9px 11px;
388+ border-radius: 9px;
389+ font-size: 12.5px;
390+}
391+
392+/* K-space view */
393+.kspace {
394+ display: flex;
395+ flex-direction: column;
396+ gap: 12px;
397+ width: 100%;
398+ min-height: 0;
399+}
400+.kspace-toolbar {
401+ display: flex;
402+ justify-content: space-between;
403+ align-items: center;
404+}
405+.segmented {
406+ display: inline-flex;
407+ border: 1px solid var(--border);
408+ border-radius: 9px;
409+ overflow: hidden;
410+}
411+.segmented button {
412+ font: inherit;
413+ font-size: 12.5px;
414+ border: none;
415+ background: var(--panel);
416+ color: var(--muted);
417+ padding: 7px 13px;
418+ cursor: pointer;
419+ border-right: 1px solid var(--border);
420+}
421+.segmented button:last-child {
422+ border-right: none;
423+}
424+.segmented button:hover {
425+ color: var(--text);
426+}
427+.segmented button.active {
428+ background: var(--accent);
429+ color: #04231f;
430+ font-weight: 600;
431+}
432+.kspace-canvas-wrap {
433+ flex: 1;
434+ min-height: 0;
435+ display: flex;
436+ align-items: center;
437+ justify-content: center;
438+ background: #05070d;
439+ border: 1px solid var(--border);
440+ border-radius: 12px;
441+ padding: 16px;
442+}
443+.kspace-canvas {
444+ image-rendering: pixelated;
445+ max-width: 100%;
446+ max-height: 68vh;
447+ object-fit: contain;
448+ border-radius: 4px;
449+}
450+.kspace-info {
451+ display: flex;
452+ gap: 20px;
453+ flex-wrap: wrap;
454+ color: var(--muted);
455+ font-size: 12.5px;
456+ font-variant-numeric: tabular-nums;
457+}
458+
459+/* Placeholder / spinner */
460+.placeholder {
461+ flex: 1;
462+ display: flex;
463+ align-items: center;
464+ justify-content: center;
465+ border: 1px dashed var(--border);
466+ border-radius: 12px;
467+ width: 100%;
468+}
469+.placeholder-inner {
470+ text-align: center;
471+ color: var(--muted);
472+}
473+.placeholder-icon {
474+ font-size: 56px;
475+ color: var(--border);
476+ display: block;
477+ margin-bottom: 12px;
478+}
479+.spinner {
480+ width: 34px;
481+ height: 34px;
482+ border: 3px solid var(--border);
483+ border-top-color: var(--accent);
484+ border-radius: 50%;
485+ margin: 0 auto 14px;
486+ animation: spin 0.8s linear infinite;
487+}
488+@keyframes spin {
489+ to {
490+ transform: rotate(360deg);
491+ }
492+}
493+
494+@media (max-width: 820px) {
495+ .layout {
496+ grid-template-columns: 1fr;
497+ }
498+ .sidebar {
499+ border-right: none;
500+ border-bottom: 1px solid var(--border);
501+ }
502+}
src/kspace/KspaceView.tsxadded+59−0View file
@@ -0,0 +1,59 @@
1+import { useEffect, useMemo, useRef, useState } from 'react'
2+import type { RawSignal } from '../sim/simulate.ts'
3+import { KSPACE_MODES, renderKspace } from './kspace.ts'
4+import type { KspaceMode } from './kspace.ts'
5+
6+export function KspaceView({ signal, elapsedMs }: { signal: RawSignal; elapsedMs: number }) {
7+ const [mode, setMode] = useState<KspaceMode>('log-magnitude')
8+ const canvasRef = useRef<HTMLCanvasElement>(null)
9+
10+ const image = useMemo(() => renderKspace(signal, mode), [signal, mode])
11+
12+ useEffect(() => {
13+ const canvas = canvasRef.current
14+ if (!canvas) return
15+ canvas.width = image.width
16+ canvas.height = image.height
17+ const ctx = canvas.getContext('2d')
18+ if (!ctx) return
19+ const imageData = ctx.createImageData(image.width, image.height)
20+ imageData.data.set(image.rgba)
21+ ctx.putImageData(imageData, 0, 0)
22+ }, [image])
23+
24+ const totalSamples = signal.re.length
25+
26+ return (
27+ <div className="kspace">
28+ <div className="kspace-toolbar">
29+ <div className="segmented">
30+ {KSPACE_MODES.map((m) => (
31+ <button
32+ key={m.id}
33+ className={m.id === mode ? 'active' : ''}
34+ onClick={() => setMode(m.id)}
35+ type="button"
36+ >
37+ {m.label}
38+ </button>
39+ ))}
40+ </div>
41+ </div>
42+ <div className="kspace-canvas-wrap">
43+ <canvas ref={canvasRef} className="kspace-canvas" />
44+ </div>
45+ <div className="kspace-info">
46+ <span>
47+ {signal.numReadouts} readouts × {signal.maxSamplesPerReadout} samples
48+ </span>
49+ <span>{totalSamples.toLocaleString()} complex points</span>
50+ <span>
51+ {image.scale.label
52+ ? `${image.scale.label}: ${image.scale.min.toFixed(0)} … ${image.scale.max.toFixed(0)}`
53+ : `scale: 0 … ${image.scale.max.toPrecision(3)}`}
54+ </span>
55+ <span>simulated in {(elapsedMs / 1000).toFixed(1)} s</span>
56+ </div>
57+ </div>
58+ )
59+}
src/kspace/kspace.tsadded+142−0View file
@@ -0,0 +1,142 @@
1+// Turn a RawSignal into a displayable RGBA image: the "raw k-space" grid, one
2+// row per ADC readout (phase-encode line), columns along the readout. For a
3+// Cartesian sequence this IS the k-space matrix; for anything else it is still
4+// the raw acquired data laid out by readout. DOM-free so it can be unit-tested.
5+import type { RawSignal } from '../sim/simulate.ts'
6+
7+export type KspaceMode = 'log-magnitude' | 'magnitude' | 'phase' | 'real' | 'imaginary'
8+
9+export interface KspaceImage {
10+ width: number
11+ height: number
12+ rgba: Uint8ClampedArray
13+ /** Descriptive scale info for a legend. */
14+ scale: { label: string; min: number; max: number }
15+}
16+
17+export const KSPACE_MODES: { id: KspaceMode; label: string }[] = [
18+ { id: 'log-magnitude', label: 'log magnitude' },
19+ { id: 'magnitude', label: 'magnitude' },
20+ { id: 'phase', label: 'phase' },
21+ { id: 'real', label: 'real' },
22+ { id: 'imaginary', label: 'imaginary' },
23+]
24+
25+function grayscale(t: number, out: Uint8ClampedArray, o: number) {
26+ const v = Math.round(255 * Math.max(0, Math.min(1, t)))
27+ out[o] = v
28+ out[o + 1] = v
29+ out[o + 2] = v
30+ out[o + 3] = 255
31+}
32+
33+/** Diverging blue-white-red for signed values, t in [-1,1]. */
34+function diverging(t: number, out: Uint8ClampedArray, o: number) {
35+ const x = Math.max(-1, Math.min(1, t))
36+ let r: number, g: number, b: number
37+ if (x < 0) {
38+ const f = 1 + x // 0..1
39+ r = 255 * f
40+ g = 255 * f
41+ b = 255
42+ } else {
43+ const f = 1 - x
44+ r = 255
45+ g = 255 * f
46+ b = 255 * f
47+ }
48+ out[o] = r
49+ out[o + 1] = g
50+ out[o + 2] = b
51+ out[o + 3] = 255
52+}
53+
54+/** Cyclic colormap for phase, t in [0,1) → hue wheel. */
55+function cyclic(t: number, out: Uint8ClampedArray, o: number) {
56+ const h = (t % 1) * 6
57+ const c = 1
58+ const x = 1 - Math.abs((h % 2) - 1)
59+ let r = 0,
60+ g = 0,
61+ b = 0
62+ if (h < 1) [r, g, b] = [c, x, 0]
63+ else if (h < 2) [r, g, b] = [x, c, 0]
64+ else if (h < 3) [r, g, b] = [0, c, x]
65+ else if (h < 4) [r, g, b] = [0, x, c]
66+ else if (h < 5) [r, g, b] = [x, 0, c]
67+ else [r, g, b] = [c, 0, x]
68+ out[o] = 255 * r
69+ out[o + 1] = 255 * g
70+ out[o + 2] = 255 * b
71+ out[o + 3] = 255
72+}
73+
74+export function renderKspace(signal: RawSignal, mode: KspaceMode, dynamicRangeDb = 60): KspaceImage {
75+ const width = Math.max(1, signal.maxSamplesPerReadout)
76+ const height = Math.max(1, signal.numReadouts)
77+ const rgba = new Uint8ClampedArray(width * height * 4)
78+ const { re, im, offsets, samplesPerReadout } = signal
79+
80+ // Global scale factors.
81+ let maxMag = 0
82+ let maxAbs = 0
83+ for (let i = 0; i < re.length; i++) {
84+ const m = Math.hypot(re[i], im[i])
85+ if (m > maxMag) maxMag = m
86+ if (Math.abs(re[i]) > maxAbs) maxAbs = Math.abs(re[i])
87+ if (Math.abs(im[i]) > maxAbs) maxAbs = Math.abs(im[i])
88+ }
89+ if (maxMag === 0) maxMag = 1
90+ if (maxAbs === 0) maxAbs = 1
91+ const floorDb = -Math.abs(dynamicRangeDb)
92+
93+ for (let r = 0; r < height; r++) {
94+ const n = r < samplesPerReadout.length ? samplesPerReadout[r] : 0
95+ const base = r < offsets.length ? offsets[r] : 0
96+ for (let c = 0; c < width; c++) {
97+ const o = (r * width + c) * 4
98+ if (c >= n) {
99+ // padding for ragged readouts
100+ rgba[o] = 12
101+ rgba[o + 1] = 14
102+ rgba[o + 2] = 20
103+ rgba[o + 3] = 255
104+ continue
105+ }
106+ const idx = base + c
107+ const reV = re[idx]
108+ const imV = im[idx]
109+ switch (mode) {
110+ case 'log-magnitude': {
111+ const m = Math.hypot(reV, imV)
112+ const db = 20 * Math.log10(m / maxMag + 1e-12)
113+ grayscale((db - floorDb) / -floorDb, rgba, o)
114+ break
115+ }
116+ case 'magnitude':
117+ grayscale(Math.hypot(reV, imV) / maxMag, rgba, o)
118+ break
119+ case 'phase':
120+ cyclic((Math.atan2(imV, reV) + Math.PI) / (2 * Math.PI), rgba, o)
121+ break
122+ case 'real':
123+ diverging(reV / maxAbs, rgba, o)
124+ break
125+ case 'imaginary':
126+ diverging(imV / maxAbs, rgba, o)
127+ break
128+ }
129+ }
130+ }
131+
132+ const scale =
133+ mode === 'phase'
134+ ? { label: 'rad', min: -Math.PI, max: Math.PI }
135+ : mode === 'log-magnitude'
136+ ? { label: 'dB', min: floorDb, max: 0 }
137+ : mode === 'magnitude'
138+ ? { label: '', min: 0, max: maxMag }
139+ : { label: '', min: -maxAbs, max: maxAbs }
140+
141+ return { width, height, rgba, scale }
142+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import App from './App.tsx'
4+import './index.css'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/phantom/builtins.tsadded+36−0View file
@@ -0,0 +1,36 @@
1+// The built-in phantoms, bundled as genuine `.phantom` (HDF5) assets. They are
2+// generated by scripts/gen-phantoms.mjs. Vite's `?url` import copies each file
3+// into the build and gives a base-correct URL to fetch at runtime.
4+import cubeUrl from './data/cube.phantom?url'
5+import sphereUrl from './data/sphere.phantom?url'
6+import { loadPhantom } from './loadPhantom.ts'
7+import type { Phantom } from './phantomTypes.ts'
8+
9+export interface BuiltinPhantom {
10+ id: string
11+ label: string
12+ description: string
13+ url: string
14+}
15+
16+export const BUILTIN_PHANTOMS: BuiltinPhantom[] = [
17+ {
18+ id: 'cube',
19+ label: 'Cube',
20+ description: '10 mm cube, uniform spins',
21+ url: cubeUrl,
22+ },
23+ {
24+ id: 'sphere',
25+ label: 'Sphere',
26+ description: '10 mm sphere, uniform spins',
27+ url: sphereUrl,
28+ },
29+]
30+
31+export async function fetchBuiltinPhantom(b: BuiltinPhantom): Promise<Phantom> {
32+ const resp = await fetch(b.url)
33+ if (!resp.ok) throw new Error(`Failed to fetch phantom "${b.id}": ${resp.status}`)
34+ const buf = await resp.arrayBuffer()
35+ return loadPhantom(buf, b.label)
36+}
src/phantom/data/cube.phantomadded+0−0View file
Binary file not shown.
src/phantom/data/sphere.phantomadded+0−0View file
Binary file not shown.
src/phantom/loadPhantom.tsadded+89−0View file
@@ -0,0 +1,89 @@
1+// Read a KomaMRI `.phantom` (HDF5) file into a Phantom, using h5wasm in the
2+// browser. Robust to missing contrast fields (fills sensible defaults) so it
3+// can also load arbitrary KomaMRI phantoms, not just our two built-ins.
4+import * as h5wasm from 'h5wasm'
5+import type { Phantom } from './phantomTypes.ts'
6+
7+let readyPromise: Promise<void> | null = null
8+let counter = 0
9+
10+async function ensureReady(): Promise<void> {
11+ if (!readyPromise) {
12+ readyPromise = h5wasm.ready.then(() => undefined)
13+ }
14+ return readyPromise
15+}
16+
17+function toF32(v: unknown, ns: number, fallback: number): Float32Array {
18+ if (v instanceof Float32Array) return v
19+ if (v instanceof Float64Array || Array.isArray(v)) return Float32Array.from(v as ArrayLike<number>)
20+ if (ArrayBuffer.isView(v)) return Float32Array.from(v as unknown as ArrayLike<number>)
21+ const a = new Float32Array(ns)
22+ a.fill(fallback)
23+ return a
24+}
25+
26+/** Map a contrast group's (possibly Unicode-named) datasets onto our field names. */
27+const CONTRAST_ALIASES: Record<string, string[]> = {
28+ rho: ['ρ', 'rho', 'Rho', 'PD'],
29+ t1: ['T1'],
30+ t2: ['T2'],
31+ t2s: ['T2s', 'T2*'],
32+ dw: ['Δw', 'Deltaw', 'dw', 'B0'],
33+}
34+
35+function readGroupField(group: h5wasm.Group | null, aliases: string[]): unknown {
36+ if (!group) return null
37+ const keys = group.keys()
38+ for (const alias of aliases) {
39+ if (keys.includes(alias)) {
40+ const ds = group.get(alias)
41+ if (ds && 'value' in ds) return (ds as h5wasm.Dataset).value
42+ }
43+ }
44+ return null
45+}
46+
47+export async function loadPhantom(buffer: ArrayBuffer, fallbackName = 'phantom'): Promise<Phantom> {
48+ await ensureReady()
49+ const FS = h5wasm.FS!
50+ const filename = `/phantom_${counter++}.h5`
51+ FS.writeFile(filename, new Uint8Array(buffer))
52+ let f: h5wasm.File | null = null
53+ try {
54+ f = new h5wasm.File(filename, 'r')
55+
56+ const posGroup = f.get('position') as h5wasm.Group | null
57+ const x0 = readGroupField(posGroup, ['x'])
58+ const y0 = readGroupField(posGroup, ['y'])
59+ const z0 = readGroupField(posGroup, ['z'])
60+ const ns =
61+ x0 instanceof Float32Array || x0 instanceof Float64Array || Array.isArray(x0)
62+ ? (x0 as ArrayLike<number>).length
63+ : 0
64+ if (!ns) throw new Error('Phantom has no position/x dataset (not a valid .phantom file?)')
65+
66+ const x = toF32(x0, ns, 0)
67+ const y = toF32(y0, ns, 0)
68+ const z = toF32(z0, ns, 0)
69+
70+ const con = f.get('contrast') as h5wasm.Group | null
71+ const rho = toF32(readGroupField(con, CONTRAST_ALIASES.rho), ns, 1)
72+ const t1 = toF32(readGroupField(con, CONTRAST_ALIASES.t1), ns, 1)
73+ const t2 = toF32(readGroupField(con, CONTRAST_ALIASES.t2), ns, 0.1)
74+ const t2s = toF32(readGroupField(con, CONTRAST_ALIASES.t2s), ns, 0.05)
75+ const dw = toF32(readGroupField(con, CONTRAST_ALIASES.dw), ns, 0)
76+
77+ const nameAttr = f.attrs['Name']?.value
78+ const name = typeof nameAttr === 'string' && nameAttr.length ? nameAttr : fallbackName
79+
80+ return { name, ns, x, y, z, rho, t1, t2, t2s, dw }
81+ } finally {
82+ f?.close()
83+ try {
84+ FS.unlink(filename)
85+ } catch {
86+ // ignore
87+ }
88+ }
89+}
src/phantom/phantomTypes.tsadded+46−0View file
@@ -0,0 +1,46 @@
1+// A digital phantom: a cloud of spins, each with a position (metres) and
2+// tissue properties. Matches the fields of a KomaMRI `.phantom` file
3+// (see ../KomaMRI.jl Phantom.jl): position group x/y/z, contrast group
4+// rho/T1/T2/T2s/dw.
5+
6+export interface Phantom {
7+ name: string
8+ /** Number of spins */
9+ ns: number
10+ /** Spin x position, metres */
11+ x: Float32Array
12+ /** Spin y position, metres */
13+ y: Float32Array
14+ /** Spin z position, metres */
15+ z: Float32Array
16+ /** Proton density (equilibrium magnetisation), arbitrary units */
17+ rho: Float32Array
18+ /** Longitudinal relaxation time, seconds */
19+ t1: Float32Array
20+ /** Transverse relaxation time, seconds */
21+ t2: Float32Array
22+ /** T2* relaxation time, seconds */
23+ t2s: Float32Array
24+ /** Off-resonance, rad/s */
25+ dw: Float32Array
26+}
27+
28+/** Axis-aligned bounding box of a phantom's spins, metres. */
29+export interface PhantomExtent {
30+ min: [number, number, number]
31+ max: [number, number, number]
32+}
33+
34+export function phantomExtent(p: Phantom): PhantomExtent {
35+ const min: [number, number, number] = [Infinity, Infinity, Infinity]
36+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
37+ const axes = [p.x, p.y, p.z]
38+ for (let a = 0; a < 3; a++) {
39+ const arr = axes[a]
40+ for (let i = 0; i < arr.length; i++) {
41+ if (arr[i] < min[a]) min[a] = arr[i]
42+ if (arr[i] > max[a]) max[a] = arr[i]
43+ }
44+ }
45+ return { min, max }
46+}
src/seq/md5.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+// Pure-JS MD5 (RFC 1321), used to verify .seq [SIGNATURE] sections
2+// synchronously (WebCrypto has no MD5). Input is treated as a byte string.
3+
4+export function md5Hex(input: string): string {
5+ const bytes: number[] = []
6+ for (let i = 0; i < input.length; i++) bytes.push(input.charCodeAt(i) & 0xff)
7+
8+ const origLenBits = bytes.length * 8
9+ bytes.push(0x80)
10+ while (bytes.length % 64 !== 56) bytes.push(0)
11+ let len = origLenBits
12+ for (let i = 0; i < 8; i++) {
13+ bytes.push(len & 0xff)
14+ len = Math.floor(len / 256)
15+ }
16+
17+ const S = [
18+ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9,
19+ 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15,
20+ 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
21+ ]
22+ const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0)
23+
24+ let a0 = 0x67452301
25+ let b0 = 0xefcdab89
26+ let c0 = 0x98badcfe
27+ let d0 = 0x10325476
28+
29+ const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0
30+
31+ for (let chunk = 0; chunk < bytes.length; chunk += 64) {
32+ const M = Array.from({ length: 16 }, (_, j) => {
33+ const o = chunk + j * 4
34+ return (bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24)) >>> 0
35+ })
36+ let A = a0
37+ let B = b0
38+ let C = c0
39+ let D = d0
40+ for (let i = 0; i < 64; i++) {
41+ let F: number
42+ let g: number
43+ if (i < 16) {
44+ F = (B & C) | (~B & D)
45+ g = i
46+ } else if (i < 32) {
47+ F = (D & B) | (~D & C)
48+ g = (5 * i + 1) % 16
49+ } else if (i < 48) {
50+ F = B ^ C ^ D
51+ g = (3 * i + 5) % 16
52+ } else {
53+ F = C ^ (B | ~D)
54+ g = (7 * i) % 16
55+ }
56+ F = (F + A + K[i] + M[g]) >>> 0
57+ A = D
58+ D = C
59+ C = B
60+ B = (B + rotl(F, S[i])) >>> 0
61+ }
62+ a0 = (a0 + A) >>> 0
63+ b0 = (b0 + B) >>> 0
64+ c0 = (c0 + C) >>> 0
65+ d0 = (d0 + D) >>> 0
66+ }
67+
68+ const le = (w: number) => {
69+ let s = ''
70+ for (let i = 0; i < 4; i++) s += ((w >>> (i * 8)) & 0xff).toString(16).padStart(2, '0')
71+ return s
72+ }
73+ return le(a0) + le(b0) + le(c0) + le(d0)
74+}
src/seq/parseSeq.tsadded+436−0View file
@@ -0,0 +1,436 @@
1+// Parser for pulseq .seq files, formats v1.4.x and v1.5.x, mirroring
2+// pulseq's +mr/@Sequence/read.m (column layouts, unit scales, shape RLE).
3+// Older formats (< 1.4.0) are rejected with a clear message.
4+import { md5Hex } from './md5.ts'
5+import type {
6+ AdcEvent,
7+ ArbGradEvent,
8+ DefinitionValue,
9+ ExtensionRef,
10+ ExtensionSpec,
11+ GradEvent,
12+ ParsedSeq,
13+ RfEvent,
14+ SeqBlock,
15+ SeqVersion,
16+ Shape,
17+ TrapGradEvent,
18+} from './types.ts'
19+
20+export class SeqParseError extends Error {}
21+
22+const DEFAULT_RASTERS = {
23+ blockDuration: 1e-5,
24+ gradient: 1e-5,
25+ rf: 1e-6,
26+ adc: 1e-7,
27+}
28+
29+function tokens(line: string): string[] {
30+ return line.trim().split(/\s+/)
31+}
32+
33+function nums(line: string, context: string): number[] {
34+ const out = tokens(line).map(Number)
35+ if (out.some((v) => Number.isNaN(v))) {
36+ throw new SeqParseError(`malformed numeric line in ${context}: "${line.trim()}"`)
37+ }
38+ return out
39+}
40+
41+/** pulseq shape decompression: RLE on the derivative (v v n => v x(n+2)), then cumsum. */
42+export function decompressShape(numSamples: number, data: number[]): Float64Array {
43+ if (data.length === numSamples) {
44+ // uncompressed (stored) samples — v1.4+ marks this by matching lengths
45+ return Float64Array.from(data)
46+ }
47+ const deriv: number[] = []
48+ let i = 0
49+ while (i < data.length) {
50+ if (i + 2 < data.length && data[i] === data[i + 1]) {
51+ const v = data[i]
52+ const rep = data[i + 2] + 2
53+ for (let k = 0; k < rep; k++) deriv.push(v)
54+ i += 3
55+ } else {
56+ deriv.push(data[i])
57+ i++
58+ }
59+ }
60+ if (deriv.length !== numSamples) {
61+ throw new SeqParseError(
62+ `shape decompression produced ${deriv.length} samples, expected ${numSamples}`,
63+ )
64+ }
65+ const out = new Float64Array(numSamples)
66+ let acc = 0
67+ for (let k = 0; k < numSamples; k++) {
68+ acc += deriv[k]
69+ out[k] = acc
70+ }
71+ return out
72+}
73+
74+export function parseSeq(text: string): ParsedSeq {
75+ const lines = text.split(/\r?\n/)
76+
77+ let version: SeqVersion | null = null
78+ const definitions = new Map<string, DefinitionValue>()
79+ const blocks: SeqBlock[] = []
80+ const rf = new Map<number, RfEvent>()
81+ const grads = new Map<number, GradEvent>()
82+ const adcs = new Map<number, AdcEvent>()
83+ const shapes = new Map<number, Shape>()
84+ const extensions = new Map<number, ExtensionRef>()
85+ const extensionSpecs: ExtensionSpec[] = []
86+ let signature: ParsedSeq['signature']
87+
88+ // Raw [BLOCKS] rows; converted to seconds once rasters are known (the
89+ // [DEFINITIONS] section precedes [BLOCKS] in practice, but don't rely on it).
90+ const blockRows: number[][] = []
91+
92+ type Section =
93+ | 'none'
94+ | 'version'
95+ | 'definitions'
96+ | 'blocks'
97+ | 'rf'
98+ | 'gradients'
99+ | 'trap'
100+ | 'adc'
101+ | 'shapes'
102+ | 'extensions'
103+ | 'extension-spec'
104+ | 'signature'
105+ | 'skip'
106+ let section: Section = 'none'
107+ let currentSpec: ExtensionSpec | null = null
108+
109+ // [SHAPES] accumulation state
110+ let shapeId = -1
111+ let shapeNumSamples = -1
112+ let shapeData: number[] = []
113+ const flushShape = () => {
114+ if (shapeId < 0) return
115+ shapes.set(shapeId, {
116+ id: shapeId,
117+ numSamples: shapeNumSamples,
118+ samples: decompressShape(shapeNumSamples, shapeData),
119+ storedSamples: shapeData.length,
120+ })
121+ shapeId = -1
122+ shapeNumSamples = -1
123+ shapeData = []
124+ }
125+
126+ const requireVersion = (): SeqVersion => {
127+ if (!version) throw new SeqParseError('file must declare [VERSION] before event sections')
128+ return version
129+ }
130+
131+ for (const rawLine of lines) {
132+ const line = rawLine.trim()
133+ if (line === '' || line.startsWith('#')) continue
134+
135+ if (line.startsWith('[') && line.endsWith(']')) {
136+ if (section === 'shapes') flushShape()
137+ currentSpec = null
138+ switch (line) {
139+ case '[VERSION]':
140+ section = 'version'
141+ version = { major: 0, minor: 0, revision: '0', combined: 0 }
142+ break
143+ case '[DEFINITIONS]':
144+ section = 'definitions'
145+ break
146+ case '[BLOCKS]':
147+ section = 'blocks'
148+ break
149+ case '[RF]':
150+ requireVersion()
151+ section = 'rf'
152+ break
153+ case '[GRADIENTS]':
154+ requireVersion()
155+ section = 'gradients'
156+ break
157+ case '[TRAP]':
158+ section = 'trap'
159+ break
160+ case '[ADC]':
161+ requireVersion()
162+ section = 'adc'
163+ break
164+ case '[SHAPES]':
165+ section = 'shapes'
166+ break
167+ case '[EXTENSIONS]':
168+ section = 'extensions'
169+ break
170+ case '[SIGNATURE]':
171+ section = 'signature'
172+ break
173+ case '[DELAYS]':
174+ throw new SeqParseError(
175+ 'this file uses the pre-1.4.0 pulseq format ([DELAYS] section), which is not supported',
176+ )
177+ default:
178+ section = 'skip' // unknown section: ignore its lines
179+ break
180+ }
181+ continue
182+ }
183+
184+ // `extension NAME typeId` opens a specification subsection
185+ if ((section === 'extensions' || section === 'extension-spec') && line.startsWith('extension ')) {
186+ const t = tokens(line)
187+ currentSpec = { name: t[1], typeId: Number(t[2]), rows: [] }
188+ extensionSpecs.push(currentSpec)
189+ section = 'extension-spec'
190+ continue
191+ }
192+
193+ switch (section) {
194+ case 'version': {
195+ const [key, value] = tokens(line)
196+ const v = version!
197+ if (key === 'major') v.major = Number(value)
198+ else if (key === 'minor') v.minor = Number(value)
199+ else if (key === 'revision') v.revision = value
200+ v.combined = 1000000 * v.major + 1000 * v.minor + (parseInt(v.revision, 10) || 0)
201+ break
202+ }
203+ case 'definitions': {
204+ const t = tokens(line)
205+ const key = t[0]
206+ const rest = t.slice(1)
207+ const asNums = rest.map(Number)
208+ definitions.set(
209+ key,
210+ rest.length > 0 && asNums.every((v) => !Number.isNaN(v))
211+ ? asNums
212+ : line.trim().slice(key.length).trim(),
213+ )
214+ break
215+ }
216+ case 'blocks':
217+ blockRows.push(nums(line, '[BLOCKS]'))
218+ break
219+ case 'rf': {
220+ const v = requireVersion()
221+ if (v.combined >= 1005000) {
222+ // id amp mag_id phase_id time_id center(us) delay(us) freqPPM phasePPM freq phase use
223+ const t = tokens(line)
224+ const d = t.slice(0, 11).map(Number)
225+ if (d.some(Number.isNaN) || t.length < 12) {
226+ throw new SeqParseError(`malformed [RF] line: "${line}"`)
227+ }
228+ rf.set(d[0], {
229+ id: d[0],
230+ amp: d[1],
231+ magShapeId: d[2],
232+ phaseShapeId: d[3],
233+ timeShapeId: d[4],
234+ center: d[5] * 1e-6,
235+ delay: d[6] * 1e-6,
236+ freqPPM: d[7],
237+ phasePPM: d[8],
238+ freq: d[9],
239+ phase: d[10],
240+ use: t[11],
241+ })
242+ } else {
243+ // v1.4: id amp mag_id phase_id time_id delay(us) freq phase
244+ const d = nums(line, '[RF]')
245+ rf.set(d[0], {
246+ id: d[0],
247+ amp: d[1],
248+ magShapeId: d[2],
249+ phaseShapeId: d[3],
250+ timeShapeId: d[4],
251+ center: NaN,
252+ delay: d[5] * 1e-6,
253+ freqPPM: 0,
254+ phasePPM: 0,
255+ freq: d[6],
256+ phase: d[7],
257+ use: 'u',
258+ })
259+ }
260+ break
261+ }
262+ case 'gradients': {
263+ const v = requireVersion()
264+ const d = nums(line, '[GRADIENTS]')
265+ let ev: ArbGradEvent
266+ if (v.combined >= 1005000) {
267+ // id amp first last shape_id time_id delay(us)
268+ ev = {
269+ id: d[0],
270+ kind: 'grad',
271+ amp: d[1],
272+ first: d[2],
273+ last: d[3],
274+ shapeId: d[4],
275+ timeShapeId: d[5],
276+ delay: d[6] * 1e-6,
277+ }
278+ } else {
279+ // v1.4: id amp shape_id time_id delay(us); first/last derived on demand
280+ ev = {
281+ id: d[0],
282+ kind: 'grad',
283+ amp: d[1],
284+ first: NaN,
285+ last: NaN,
286+ shapeId: d[2],
287+ timeShapeId: d[3],
288+ delay: d[4] * 1e-6,
289+ }
290+ }
291+ grads.set(ev.id, ev)
292+ break
293+ }
294+ case 'trap': {
295+ // id amp rise(us) flat(us) fall(us) delay(us)
296+ const d = nums(line, '[TRAP]')
297+ const ev: TrapGradEvent = {
298+ id: d[0],
299+ kind: 'trap',
300+ amp: d[1],
301+ rise: d[2] * 1e-6,
302+ flat: d[3] * 1e-6,
303+ fall: d[4] * 1e-6,
304+ delay: d[5] * 1e-6,
305+ }
306+ grads.set(ev.id, ev)
307+ break
308+ }
309+ case 'adc': {
310+ const v = requireVersion()
311+ const d = nums(line, '[ADC]')
312+ if (v.combined >= 1005000) {
313+ // id num dwell(ns) delay(us) freqPPM phasePPM freq phase phase_id
314+ adcs.set(d[0], {
315+ id: d[0],
316+ num: d[1],
317+ dwell: d[2] * 1e-9,
318+ delay: d[3] * 1e-6,
319+ freqPPM: d[4],
320+ phasePPM: d[5],
321+ freq: d[6],
322+ phase: d[7],
323+ phaseShapeId: d[8] ?? 0,
324+ })
325+ } else {
326+ // v1.4: id num dwell(ns) delay(us) freq phase
327+ adcs.set(d[0], {
328+ id: d[0],
329+ num: d[1],
330+ dwell: d[2] * 1e-9,
331+ delay: d[3] * 1e-6,
332+ freqPPM: 0,
333+ phasePPM: 0,
334+ freq: d[4],
335+ phase: d[5],
336+ phaseShapeId: 0,
337+ })
338+ }
339+ break
340+ }
341+ case 'shapes': {
342+ const t = tokens(line)
343+ if (t[0] === 'shape_id') {
344+ flushShape()
345+ shapeId = Number(t[1])
346+ } else if (t[0] === 'num_samples') {
347+ shapeNumSamples = Number(t[1])
348+ } else {
349+ for (const tok of t) {
350+ const v = Number(tok)
351+ if (Number.isNaN(v)) throw new SeqParseError(`malformed shape sample: "${line}"`)
352+ shapeData.push(v)
353+ }
354+ }
355+ break
356+ }
357+ case 'extensions': {
358+ const d = nums(line, '[EXTENSIONS]')
359+ extensions.set(d[0], { id: d[0], type: d[1], ref: d[2], next: d[3] })
360+ break
361+ }
362+ case 'extension-spec':
363+ currentSpec!.rows.push(tokens(line))
364+ break
365+ case 'signature': {
366+ const [key, value] = tokens(line)
367+ signature = signature ?? { type: '', hash: '' }
368+ if (key === 'Type') signature.type = value
369+ else if (key === 'Hash') signature.hash = value
370+ break
371+ }
372+ case 'none':
373+ case 'skip':
374+ break
375+ }
376+ }
377+ if (section === 'shapes') flushShape()
378+
379+ if (!version) throw new SeqParseError('not a pulseq file: no [VERSION] section found')
380+ if (version.combined < 1004000) {
381+ throw new SeqParseError(
382+ `pulseq format ${version.major}.${version.minor}.${version.revision} is not supported (1.4.0 or later required)`,
383+ )
384+ }
385+
386+ const rasters = {
387+ blockDuration:
388+ (definitions.get('BlockDurationRaster') as number[])?.[0] ?? DEFAULT_RASTERS.blockDuration,
389+ gradient:
390+ (definitions.get('GradientRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.gradient,
391+ rf: (definitions.get('RadiofrequencyRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.rf,
392+ adc: (definitions.get('AdcRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.adc,
393+ }
394+
395+ let totalDuration = 0
396+ for (const row of blockRows) {
397+ // v1.4+: id dur rf gx gy gz adc ext
398+ if (row.length < 8) throw new SeqParseError(`malformed [BLOCKS] row (${row.length} columns)`)
399+ const duration = row[1] * rasters.blockDuration
400+ blocks.push({
401+ id: row[0],
402+ duration,
403+ rfId: row[2],
404+ gxId: row[3],
405+ gyId: row[4],
406+ gzId: row[5],
407+ adcId: row[6],
408+ extId: row[7],
409+ })
410+ totalDuration += duration
411+ }
412+
413+ if (signature?.type?.toLowerCase() === 'md5') {
414+ // The hash covers the file up to (excluding) the newline that precedes
415+ // [SIGNATURE] — that newline was added together with the section.
416+ const idx = text.indexOf('\n[SIGNATURE]')
417+ if (idx >= 0) {
418+ signature.valid = md5Hex(text.slice(0, idx)) === signature.hash.toLowerCase()
419+ }
420+ }
421+
422+ return {
423+ version,
424+ definitions,
425+ rasters,
426+ blocks,
427+ rf,
428+ grads,
429+ adcs,
430+ shapes,
431+ extensions,
432+ extensionSpecs,
433+ signature,
434+ totalDuration,
435+ }
436+}
src/seq/reconstruct.tsadded+252−0View file
@@ -0,0 +1,252 @@
1+// Turn a ParsedSeq into plottable timelines, mirroring pulseq's
2+// Sequence.waveforms_and_times conventions:
3+// - trapezoids -> 4 vertices (3 when flat == 0), skipped when empty
4+// - arbitrary gradients on the centers raster -> edge points from
5+// first/last plus the raster-center samples
6+// - extended trapezoids (time shape on raster edges) -> vertices as stored
7+// - RF samples at (i+0.5)*rfRaster or the time shape; phase includes the
8+// phase/frequency offsets
9+// Series are piecewise-linear polylines; NaN values break the line.
10+import type { ParsedSeq } from './types.ts'
11+
12+export interface Series {
13+ t: Float64Array
14+ v: Float64Array
15+}
16+
17+export interface BlockSpan {
18+ index: number
19+ id: number
20+ start: number
21+ duration: number
22+}
23+
24+export interface AdcSpan {
25+ blockIndex: number
26+ start: number
27+ end: number
28+ num: number
29+ dwell: number
30+}
31+
32+export interface RfPulseSpan {
33+ blockIndex: number
34+ start: number
35+ end: number
36+ /** Absolute time of the pulse center (NaN for v1.4 files) */
37+ center: number
38+ use: string
39+}
40+
41+export interface Reconstructed {
42+ duration: number
43+ blockSpans: BlockSpan[]
44+ gx: Series
45+ gy: Series
46+ gz: Series
47+ /** |B1| in Hz */
48+ rfMag: Series
49+ /** rad, NaN between pulses */
50+ rfPhase: Series
51+ adcSpans: AdcSpan[]
52+ rfSpans: RfPulseSpan[]
53+ stats: {
54+ maxGrad: { gx: number; gy: number; gz: number }
55+ maxSlew: { gx: number; gy: number; gz: number }
56+ rfCount: number
57+ adcCount: number
58+ adcSamples: number
59+ }
60+}
61+
62+const EPS = 1e-9
63+
64+class SeriesBuilder {
65+ t: number[] = []
66+ v: number[] = []
67+ push(t: number, v: number) {
68+ this.t.push(t)
69+ this.v.push(v)
70+ }
71+ breakLine() {
72+ if (this.v.length > 0 && !Number.isNaN(this.v[this.v.length - 1])) {
73+ this.push(this.t[this.t.length - 1], NaN)
74+ }
75+ }
76+ build(): Series {
77+ return { t: Float64Array.from(this.t), v: Float64Array.from(this.v) }
78+ }
79+}
80+
81+function wrapToPi(x: number): number {
82+ const w = ((x + Math.PI) % (2 * Math.PI)) - Math.PI
83+ return w < -Math.PI ? w + 2 * Math.PI : w
84+}
85+
86+export function reconstruct(seq: ParsedSeq): Reconstructed {
87+ const { rasters } = seq
88+ const gradBuilders = {
89+ gx: new SeriesBuilder(),
90+ gy: new SeriesBuilder(),
91+ gz: new SeriesBuilder(),
92+ }
93+ const rfMag = new SeriesBuilder()
94+ const rfPhase = new SeriesBuilder()
95+ const blockSpans: BlockSpan[] = []
96+ const adcSpans: AdcSpan[] = []
97+ const rfSpans: RfPulseSpan[] = []
98+
99+ const channels = ['gx', 'gy', 'gz'] as const
100+
101+ // Zero anchors so empty channels still draw a baseline
102+ for (const ch of channels) gradBuilders[ch].push(0, 0)
103+ rfMag.push(0, 0)
104+
105+ let t0 = 0
106+ for (let bi = 0; bi < seq.blocks.length; bi++) {
107+ const block = seq.blocks[bi]
108+ blockSpans.push({ index: bi, id: block.id, start: t0, duration: block.duration })
109+
110+ for (const ch of channels) {
111+ const gid = ch === 'gx' ? block.gxId : ch === 'gy' ? block.gyId : block.gzId
112+ if (gid === 0) continue
113+ const grad = seq.grads.get(gid)
114+ if (!grad) continue
115+ const b = gradBuilders[ch]
116+ const gt0 = t0 + grad.delay
117+ if (grad.kind === 'trap') {
118+ if (Math.abs(grad.flat) > EPS) {
119+ b.push(gt0, 0)
120+ b.push(gt0 + grad.rise, grad.amp)
121+ b.push(gt0 + grad.rise + grad.flat, grad.amp)
122+ b.push(gt0 + grad.rise + grad.flat + grad.fall, 0)
123+ } else if (Math.abs(grad.rise) > EPS && Math.abs(grad.fall) > EPS) {
124+ b.push(gt0, 0)
125+ b.push(gt0 + grad.rise, grad.amp)
126+ b.push(gt0 + grad.rise + grad.fall, 0)
127+ }
128+ // else: empty gradient, skip
129+ } else {
130+ const shape = seq.shapes.get(grad.shapeId)
131+ if (!shape) continue
132+ const n = shape.numSamples
133+ const raster = rasters.gradient
134+ let tt: Float64Array
135+ if (grad.timeShapeId === 0) {
136+ tt = new Float64Array(n)
137+ for (let i = 0; i < n; i++) tt[i] = (i + 0.5) * raster
138+ } else {
139+ const timeShape = seq.shapes.get(grad.timeShapeId)
140+ if (!timeShape) continue
141+ tt = new Float64Array(n)
142+ for (let i = 0; i < n; i++) tt[i] = timeShape.samples[i] * raster
143+ }
144+ const onCenters = Math.abs(tt[0] - 0.5 * raster) < 1e-6 * raster
145+ if (onCenters) {
146+ // v1.4 files carry no first/last; fall back to the edge samples
147+ const first = Number.isNaN(grad.first) ? grad.amp * shape.samples[0] : grad.first
148+ const last =
149+ Number.isNaN(grad.last) ? grad.amp * shape.samples[n - 1] : grad.last
150+ const shapeDur = Math.ceil((tt[n - 1] - EPS) / raster) * raster
151+ b.push(gt0, first)
152+ for (let i = 0; i < n; i++) b.push(gt0 + tt[i], grad.amp * shape.samples[i])
153+ b.push(gt0 + shapeDur, last)
154+ } else {
155+ // extended trapezoid: vertices as stored
156+ for (let i = 0; i < n; i++) b.push(gt0 + tt[i], grad.amp * shape.samples[i])
157+ }
158+ }
159+ }
160+
161+ if (block.rfId !== 0) {
162+ const ev = seq.rf.get(block.rfId)
163+ const magShape = ev ? seq.shapes.get(ev.magShapeId) : undefined
164+ if (ev && magShape) {
165+ const n = magShape.numSamples
166+ const raster = rasters.rf
167+ const phaseShape = ev.phaseShapeId !== 0 ? seq.shapes.get(ev.phaseShapeId) : undefined
168+ let tt: Float64Array
169+ if (ev.timeShapeId === 0) {
170+ tt = new Float64Array(n)
171+ for (let i = 0; i < n; i++) tt[i] = (i + 0.5) * raster
172+ } else {
173+ const timeShape = seq.shapes.get(ev.timeShapeId)
174+ tt = new Float64Array(n)
175+ for (let i = 0; i < n; i++) tt[i] = (timeShape?.samples[i] ?? 0) * raster
176+ }
177+ const rt0 = t0 + ev.delay
178+ const end = rt0 + Math.ceil((tt[n - 1] - EPS) / raster) * raster
179+ rfMag.push(rt0, 0)
180+ rfPhase.breakLine()
181+ for (let i = 0; i < n; i++) {
182+ const t = rt0 + tt[i]
183+ rfMag.push(t, Math.abs(ev.amp * magShape.samples[i]))
184+ const ph = 2 * Math.PI * (phaseShape?.samples[i] ?? 0) + ev.phase + 2 * Math.PI * ev.freq * tt[i]
185+ rfPhase.push(t, wrapToPi(ph))
186+ }
187+ rfPhase.breakLine()
188+ rfMag.push(end, 0)
189+ rfSpans.push({
190+ blockIndex: bi,
191+ start: rt0,
192+ end,
193+ center: Number.isNaN(ev.center) ? NaN : rt0 + ev.center,
194+ use: ev.use,
195+ })
196+ }
197+ }
198+
199+ if (block.adcId !== 0) {
200+ const adc = seq.adcs.get(block.adcId)
201+ if (adc) {
202+ const start = t0 + adc.delay
203+ adcSpans.push({
204+ blockIndex: bi,
205+ start,
206+ end: start + adc.num * adc.dwell,
207+ num: adc.num,
208+ dwell: adc.dwell,
209+ })
210+ }
211+ }
212+
213+ t0 += block.duration
214+ }
215+
216+ for (const ch of channels) gradBuilders[ch].push(t0, 0)
217+ rfMag.push(t0, 0)
218+
219+ const stats = {
220+ maxGrad: { gx: 0, gy: 0, gz: 0 },
221+ maxSlew: { gx: 0, gy: 0, gz: 0 },
222+ rfCount: rfSpans.length,
223+ adcCount: adcSpans.length,
224+ adcSamples: adcSpans.reduce((acc, a) => acc + a.num, 0),
225+ }
226+ const series = {
227+ gx: gradBuilders.gx.build(),
228+ gy: gradBuilders.gy.build(),
229+ gz: gradBuilders.gz.build(),
230+ }
231+ for (const ch of channels) {
232+ const { t, v } = series[ch]
233+ for (let i = 0; i < v.length; i++) {
234+ if (Number.isNaN(v[i])) continue
235+ stats.maxGrad[ch] = Math.max(stats.maxGrad[ch], Math.abs(v[i]))
236+ if (i > 0 && !Number.isNaN(v[i - 1]) && t[i] > t[i - 1] + EPS) {
237+ stats.maxSlew[ch] = Math.max(stats.maxSlew[ch], Math.abs((v[i] - v[i - 1]) / (t[i] - t[i - 1])))
238+ }
239+ }
240+ }
241+
242+ return {
243+ duration: t0,
244+ blockSpans,
245+ ...series,
246+ rfMag: rfMag.build(),
247+ rfPhase: rfPhase.build(),
248+ adcSpans,
249+ rfSpans,
250+ stats,
251+ }
252+}
src/seq/summary.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+// A light summary of a .seq file for display (and to validate it parses before
2+// we store it). Built on the parser + reconstruction reused from seqlab.
3+import { parseSeq } from './parseSeq.ts'
4+import { reconstruct } from './reconstruct.ts'
5+
6+export interface SeqSummary {
7+ ok: boolean
8+ error?: string
9+ name?: string
10+ version?: string
11+ numBlocks: number
12+ durationSec: number
13+ rfCount: number
14+ adcCount: number
15+ adcSamples: number
16+ signatureValid?: boolean
17+}
18+
19+export function summarizeSeq(text: string): SeqSummary {
20+ try {
21+ const seq = parseSeq(text)
22+ const rec = reconstruct(seq)
23+ const nameDef = seq.definitions.get('Name')
24+ const name = typeof nameDef === 'string' ? nameDef : undefined
25+ return {
26+ ok: true,
27+ name,
28+ version: `${seq.version.major}.${seq.version.minor}.${seq.version.revision}`,
29+ numBlocks: seq.blocks.length,
30+ durationSec: rec.duration,
31+ rfCount: rec.stats.rfCount,
32+ adcCount: rec.stats.adcCount,
33+ adcSamples: rec.stats.adcSamples,
34+ signatureValid: seq.signature?.valid,
35+ }
36+ } catch (err) {
37+ return {
38+ ok: false,
39+ error: err instanceof Error ? err.message : String(err),
40+ numBlocks: 0,
41+ durationSec: 0,
42+ rfCount: 0,
43+ adcCount: 0,
44+ adcSamples: 0,
45+ }
46+ }
47+}
src/seq/types.tsadded+132−0View file
@@ -0,0 +1,132 @@
1+// Parsed representation of a pulseq .seq file (format v1.4.x / v1.5.x).
2+
3+export interface SeqVersion {
4+ major: number
5+ minor: number
6+ revision: string
7+ combined: number // 1000000*major + 1000*minor + numeric revision
8+}
9+
10+/** One [DEFINITIONS] entry: numeric values when every token parses, else raw string. */
11+export type DefinitionValue = number[] | string
12+
13+export interface RfEvent {
14+ id: number
15+ /** Peak amplitude, Hz */
16+ amp: number
17+ magShapeId: number
18+ phaseShapeId: number
19+ timeShapeId: number
20+ /** Center of the pulse relative to its start, s (NaN in v1.4 files) */
21+ center: number
22+ /** Delay from block start, s */
23+ delay: number
24+ freqPPM: number
25+ phasePPM: number
26+ /** Frequency offset, Hz */
27+ freq: number
28+ /** Phase offset, rad */
29+ phase: number
30+ /** Initial of excitation/refocusing/inversion/saturation/preparation/other/undefined */
31+ use: string
32+}
33+
34+export interface TrapGradEvent {
35+ id: number
36+ kind: 'trap'
37+ /** Hz/m */
38+ amp: number
39+ rise: number
40+ flat: number
41+ fall: number
42+ delay: number
43+}
44+
45+export interface ArbGradEvent {
46+ id: number
47+ kind: 'grad'
48+ /** Hz/m */
49+ amp: number
50+ /** Waveform value at the start/end edge, Hz/m (NaN in v1.4 files) */
51+ first: number
52+ last: number
53+ shapeId: number
54+ timeShapeId: number
55+ delay: number
56+}
57+
58+export type GradEvent = TrapGradEvent | ArbGradEvent
59+
60+export interface AdcEvent {
61+ id: number
62+ num: number
63+ /** s */
64+ dwell: number
65+ /** s */
66+ delay: number
67+ freqPPM: number
68+ phasePPM: number
69+ /** Hz */
70+ freq: number
71+ /** rad */
72+ phase: number
73+ phaseShapeId: number
74+}
75+
76+export interface Shape {
77+ id: number
78+ numSamples: number
79+ /** Decompressed samples (numSamples long) */
80+ samples: Float64Array
81+ /** Sample count as stored in the file (=== numSamples when uncompressed) */
82+ storedSamples: number
83+}
84+
85+/** One row of the [EXTENSIONS] linked lists: id -> (type, ref) + next row. */
86+export interface ExtensionRef {
87+ id: number
88+ type: number
89+ ref: number
90+ next: number
91+}
92+
93+/** One `extension NAME typeId` specification section, rows kept as tokens. */
94+export interface ExtensionSpec {
95+ name: string
96+ typeId: number
97+ rows: string[][]
98+}
99+
100+export interface SeqBlock {
101+ id: number
102+ /** s */
103+ duration: number
104+ rfId: number
105+ gxId: number
106+ gyId: number
107+ gzId: number
108+ adcId: number
109+ extId: number
110+}
111+
112+export interface ParsedSeq {
113+ version: SeqVersion
114+ /** Insertion-ordered [DEFINITIONS] */
115+ definitions: Map<string, DefinitionValue>
116+ rasters: {
117+ blockDuration: number
118+ gradient: number
119+ rf: number
120+ adc: number
121+ }
122+ blocks: SeqBlock[]
123+ rf: Map<number, RfEvent>
124+ grads: Map<number, GradEvent>
125+ adcs: Map<number, AdcEvent>
126+ shapes: Map<number, Shape>
127+ extensions: Map<number, ExtensionRef>
128+ extensionSpecs: ExtensionSpec[]
129+ signature?: { type: string; hash: string; valid?: boolean }
130+ /** s, sum of block durations */
131+ totalDuration: number
132+}
src/sim/simWorker.tsadded+65−0View file
@@ -0,0 +1,65 @@
1+// Web Worker that runs the Bloch simulation off the main thread. It parses the
2+// .seq text, runs simulate(), streams throttled progress messages, and posts
3+// the raw signal back. Cancellation is handled by the main thread terminating
4+// the worker (no in-loop cancel needed).
5+import { parseSeq } from '../seq/parseSeq.ts'
6+import { simulate } from './simulate.ts'
7+import type { SimProgress } from './simulate.ts'
8+import type { Phantom } from '../phantom/phantomTypes.ts'
9+
10+export interface RunMessage {
11+ type: 'run'
12+ seqText: string
13+ phantom: Phantom
14+}
15+
16+export type WorkerOut =
17+ | { type: 'progress'; progress: SimProgress; elapsedMs: number }
18+ | {
19+ type: 'done'
20+ re: Float64Array
21+ im: Float64Array
22+ numReadouts: number
23+ samplesPerReadout: Int32Array
24+ offsets: Int32Array
25+ maxSamplesPerReadout: number
26+ elapsedMs: number
27+ }
28+ | { type: 'error'; message: string }
29+
30+const ctx = self as unknown as Worker
31+
32+ctx.onmessage = (ev: MessageEvent<RunMessage>) => {
33+ const msg = ev.data
34+ if (msg.type !== 'run') return
35+ const started = Date.now()
36+ try {
37+ const seq = parseSeq(msg.seqText)
38+ let lastPost = 0
39+ const result = simulate(seq, msg.phantom, {
40+ progressEvery: 128,
41+ onProgress: (progress) => {
42+ const now = Date.now()
43+ if (now - lastPost >= 100 || progress.fraction >= 1) {
44+ lastPost = now
45+ const out: WorkerOut = { type: 'progress', progress, elapsedMs: now - started }
46+ ctx.postMessage(out)
47+ }
48+ },
49+ })
50+ const out: WorkerOut = {
51+ type: 'done',
52+ re: result.re,
53+ im: result.im,
54+ numReadouts: result.numReadouts,
55+ samplesPerReadout: result.samplesPerReadout,
56+ offsets: result.offsets,
57+ maxSamplesPerReadout: result.maxSamplesPerReadout,
58+ elapsedMs: Date.now() - started,
59+ }
60+ ctx.postMessage(out, [result.re.buffer, result.im.buffer])
61+ } catch (err) {
62+ const out: WorkerOut = { type: 'error', message: err instanceof Error ? err.message : String(err) }
63+ ctx.postMessage(out)
64+ }
65+}
src/sim/simulate.tsadded+384−0View file
@@ -0,0 +1,384 @@
1+// A from-scratch isochromat Bloch simulator for pulseq sequences.
2+//
3+// The magnetisation of every spin is evolved through the sequence in the
4+// rotating frame. The timeline is split at every gradient vertex, every RF
5+// sample, and every ADC sample (the union is `boundaries`), so within each
6+// segment the gradients are linear and the RF is ~one raster long. Two regimes:
7+//
8+// * Free precession (no RF in the segment): the effective field is purely
9+// longitudinal, so each spin's transverse magnetisation just rotates about
10+// z by the gradient phase dφ = 2π·(g·r)·dt + Δw·dt and relaxes. Exact for
11+// a linear gradient, so these segments can be as long as a whole readout
12+// dwell or delay — this is what makes the simulation fast.
13+// * Excitation (RF present): the full 3-D effective field
14+// Ω = (2π·B1·cosθ, 2π·B1·sinθ, 2π·g·r + Δw) rotates each spin (Rodrigues).
15+//
16+// Signal at each ADC sample = Σ_j ρ_j·(Mx+iMy), demodulated by the receiver
17+// phase (ADC phase + frequency offset). Units: pulseq gradients are Hz/m and RF
18+// amplitude Hz, positions m, so g·r and B1 are already in Hz.
19+import type { ParsedSeq } from '../seq/types.ts'
20+import { reconstruct } from '../seq/reconstruct.ts'
21+import type { Series } from '../seq/reconstruct.ts'
22+import type { Phantom } from '../phantom/phantomTypes.ts'
23+
24+export interface RawSignal {
25+ /** Real part of the acquired signal, one entry per ADC sample (all readouts concatenated). */
26+ re: Float64Array
27+ im: Float64Array
28+ /** Number of ADC events (readout lines). */
29+ numReadouts: number
30+ /** Samples in each readout, length numReadouts. */
31+ samplesPerReadout: Int32Array
32+ /** Prefix offsets into re/im, length numReadouts+1. */
33+ offsets: Int32Array
34+ maxSamplesPerReadout: number
35+}
36+
37+export interface SimProgress {
38+ fraction: number
39+ segment: number
40+ numSegments: number
41+ samplesDone: number
42+ numSamples: number
43+}
44+
45+export interface SimOptions {
46+ onProgress?: (p: SimProgress) => void
47+ /** How often (in segments) to invoke onProgress. */
48+ progressEvery?: number
49+}
50+
51+const TWO_PI = 2 * Math.PI
52+
53+/** Linear interpolation of a (t,v) polyline at ascending query times (moving cursor, O(n)). */
54+function sampleAtTimes(series: Series, times: Float64Array): Float64Array {
55+ const { t, v } = series
56+ const n = t.length
57+ const out = new Float64Array(times.length)
58+ if (n === 0) return out
59+ let p = 0
60+ for (let i = 0; i < times.length; i++) {
61+ const time = times[i]
62+ if (time <= t[0]) {
63+ out[i] = v[0]
64+ continue
65+ }
66+ if (time >= t[n - 1]) {
67+ out[i] = v[n - 1]
68+ continue
69+ }
70+ while (p < n - 1 && t[p + 1] < time) p++
71+ const ta = t[p]
72+ const tb = t[p + 1]
73+ if (tb === ta) {
74+ out[i] = v[p + 1]
75+ } else {
76+ const f = (time - ta) / (tb - ta)
77+ out[i] = v[p] + f * (v[p + 1] - v[p])
78+ }
79+ }
80+ return out
81+}
82+
83+/** One ADC sample's recording target: which readout, which column, and receiver phase. */
84+interface AdcSampleRec {
85+ segment: number // record after the segment ending at this sample's time
86+ readout: number
87+ col: number
88+ cosPhi: number // demod: multiply signal by exp(-i φ_rx) = (cosPhi - i sinPhi)... stored as cos/sin of φ_rx
89+ sinPhi: number
90+}
91+
92+interface Schedule {
93+ boundaries: Float64Array
94+ midpoints: Float64Array
95+ dt: Float64Array
96+ gxMid: Float64Array
97+ gyMid: Float64Array
98+ gzMid: Float64Array
99+ rfMagMid: Float64Array
100+ rfActive: Uint8Array
101+ records: AdcSampleRec[]
102+ numReadouts: number
103+ samplesPerReadout: Int32Array
104+ offsets: Int32Array
105+ numSamples: number
106+ rfPhase: Series
107+ rfMag: Series
108+}
109+
110+function buildSchedule(seq: ParsedSeq): Schedule {
111+ const rec = reconstruct(seq)
112+
113+ // ADC sample schedule, straight from the parsed events (so we keep phase/freq).
114+ const adcSampleTimes: number[] = []
115+ const adcSampleReadout: number[] = []
116+ const adcSampleCol: number[] = []
117+ const adcSamplePhi: number[] = []
118+ const samplesPerReadout: number[] = []
119+ let readout = 0
120+ let t0 = 0
121+ for (let bi = 0; bi < seq.blocks.length; bi++) {
122+ const block = seq.blocks[bi]
123+ if (block.adcId !== 0) {
124+ const adc = seq.adcs.get(block.adcId)
125+ if (adc && adc.num > 0) {
126+ const start = t0 + adc.delay
127+ samplesPerReadout.push(adc.num)
128+ for (let i = 0; i < adc.num; i++) {
129+ const tk = start + adc.dwell * (i + 0.5)
130+ // Receiver phase: constant ADC phase offset + frequency offset ramp.
131+ const phi = adc.phase + TWO_PI * adc.freq * (tk - start)
132+ adcSampleTimes.push(tk)
133+ adcSampleReadout.push(readout)
134+ adcSampleCol.push(i)
135+ adcSamplePhi.push(phi)
136+ }
137+ readout++
138+ }
139+ }
140+ t0 += block.duration
141+ }
142+ const numReadouts = readout
143+ const numSamples = adcSampleTimes.length
144+
145+ // Union of all event boundaries (gradient vertices, RF samples, ADC samples, endpoints).
146+ const times = new Set<number>()
147+ times.add(0)
148+ times.add(rec.duration)
149+ for (const s of [rec.gx, rec.gy, rec.gz, rec.rfMag]) {
150+ for (let i = 0; i < s.t.length; i++) if (!Number.isNaN(s.t[i])) times.add(s.t[i])
151+ }
152+ for (const s of rec.rfSpans) {
153+ times.add(s.start)
154+ times.add(s.end)
155+ }
156+ for (const tk of adcSampleTimes) times.add(tk)
157+ const boundaries = Float64Array.from(times)
158+ boundaries.sort()
159+
160+ const numSegments = boundaries.length - 1
161+ const midpoints = new Float64Array(numSegments)
162+ const dt = new Float64Array(numSegments)
163+ for (let i = 0; i < numSegments; i++) {
164+ midpoints[i] = 0.5 * (boundaries[i] + boundaries[i + 1])
165+ dt[i] = boundaries[i + 1] - boundaries[i]
166+ }
167+
168+ const gxMid = sampleAtTimes(rec.gx, midpoints)
169+ const gyMid = sampleAtTimes(rec.gy, midpoints)
170+ const gzMid = sampleAtTimes(rec.gz, midpoints)
171+ const rfMagMid = sampleAtTimes(rec.rfMag, midpoints)
172+
173+ // Mark segments that fall inside an RF pulse span.
174+ const rfActive = new Uint8Array(numSegments)
175+ for (const span of rec.rfSpans) {
176+ for (let i = 0; i < numSegments; i++) {
177+ if (midpoints[i] > span.start && midpoints[i] < span.end) rfActive[i] = 1
178+ }
179+ }
180+
181+ // Map each ADC sample time to the segment it ends, via a value->index lookup.
182+ const indexOf = new Map<number, number>()
183+ for (let i = 0; i < boundaries.length; i++) indexOf.set(boundaries[i], i)
184+ const records: AdcSampleRec[] = []
185+ for (let k = 0; k < numSamples; k++) {
186+ const bIndex = indexOf.get(adcSampleTimes[k])
187+ if (bIndex === undefined || bIndex === 0) continue
188+ const phi = adcSamplePhi[k]
189+ records.push({
190+ segment: bIndex - 1,
191+ readout: adcSampleReadout[k],
192+ col: adcSampleCol[k],
193+ cosPhi: Math.cos(phi),
194+ sinPhi: Math.sin(phi),
195+ })
196+ }
197+ // Group records by the segment they fire after.
198+ records.sort((a, b) => a.segment - b.segment)
199+
200+ const spr = Int32Array.from(samplesPerReadout)
201+ const offsets = new Int32Array(numReadouts + 1)
202+ for (let r = 0; r < numReadouts; r++) offsets[r + 1] = offsets[r] + spr[r]
203+
204+ return {
205+ boundaries,
206+ midpoints,
207+ dt,
208+ gxMid,
209+ gyMid,
210+ gzMid,
211+ rfMagMid,
212+ rfActive,
213+ records,
214+ numReadouts,
215+ samplesPerReadout: spr,
216+ offsets,
217+ numSamples,
218+ rfPhase: rec.rfPhase,
219+ rfMag: rec.rfMag,
220+ }
221+}
222+
223+export function simulate(seq: ParsedSeq, phantom: Phantom, opts: SimOptions = {}): RawSignal {
224+ const sched = buildSchedule(seq)
225+ const { boundaries, midpoints, dt, gxMid, gyMid, gzMid, rfMagMid, rfActive, records } = sched
226+ const numSegments = boundaries.length - 1
227+ const ns = phantom.ns
228+ const { x, y, z, rho, t1, t2, dw } = phantom
229+
230+ // Magnetisation, initialised at thermal equilibrium (Mz = ρ).
231+ const Mx = new Float64Array(ns)
232+ const My = new Float64Array(ns)
233+ const Mz = new Float64Array(ns)
234+ for (let j = 0; j < ns; j++) Mz[j] = rho[j]
235+
236+ const re = new Float64Array(sched.numSamples)
237+ const im = new Float64Array(sched.numSamples)
238+
239+ // Fast path when relaxation is uniform across the phantom (our built-ins are).
240+ let uniformRelax = true
241+ for (let j = 1; j < ns; j++) {
242+ if (t1[j] !== t1[0] || t2[j] !== t2[0]) {
243+ uniformRelax = false
244+ break
245+ }
246+ }
247+ const r1u = ns > 0 ? 1 / t1[0] : 0
248+ const r2u = ns > 0 ? 1 / t2[0] : 0
249+
250+ const progressEvery = opts.progressEvery ?? 256
251+ let recPtr = 0
252+
253+ for (let i = 0; i < numSegments; i++) {
254+ const dti = dt[i]
255+ if (dti > 0) {
256+ if (rfActive[i]) {
257+ // --- Excitation: full 3-D rotation about the effective field ---
258+ const b1 = rfMagMid[i]
259+ const phase = sampleSeriesScalar(sched.rfPhase, midpoints[i])
260+ const ph = Number.isNaN(phase) ? 0 : phase
261+ const w1 = TWO_PI * b1
262+ const wx = w1 * Math.cos(ph)
263+ const wy = w1 * Math.sin(ph)
264+ const gx = gxMid[i]
265+ const gy = gyMid[i]
266+ const gz = gzMid[i]
267+ const e1 = uniformRelax ? Math.exp(-dti * r1u) : 0
268+ const e2 = uniformRelax ? Math.exp(-dti * r2u) : 0
269+ for (let j = 0; j < ns; j++) {
270+ const wz = TWO_PI * (gx * x[j] + gy * y[j] + gz * z[j]) + dw[j]
271+ const wmag = Math.sqrt(wx * wx + wy * wy + wz * wz)
272+ let mx = Mx[j]
273+ let my = My[j]
274+ let mz = Mz[j]
275+ if (wmag > 0) {
276+ // Rotate by θ = -wmag·dt about n = (wx,wy,wz)/wmag (sign matches free precession).
277+ const theta = -wmag * dti
278+ const c = Math.cos(theta)
279+ const s = Math.sin(theta)
280+ const inv = 1 / wmag
281+ const nx = wx * inv
282+ const ny = wy * inv
283+ const nz = wz * inv
284+ const dot = nx * mx + ny * my + nz * mz
285+ // Rodrigues: m' = m c + (n×m) s + n (n·m)(1-c)
286+ const crx = ny * mz - nz * my
287+ const cry = nz * mx - nx * mz
288+ const crz = nx * my - ny * mx
289+ const k = dot * (1 - c)
290+ mx = mx * c + crx * s + nx * k
291+ my = my * c + cry * s + ny * k
292+ mz = mz * c + crz * s + nz * k
293+ }
294+ const e2j = uniformRelax ? e2 : Math.exp(-dti / t2[j])
295+ const e1j = uniformRelax ? e1 : Math.exp(-dti / t1[j])
296+ Mx[j] = mx * e2j
297+ My[j] = my * e2j
298+ Mz[j] = mz * e1j + rho[j] * (1 - e1j)
299+ }
300+ } else {
301+ // --- Free precession: rotate about z by the gradient phase, then relax ---
302+ const ax = TWO_PI * gxMid[i] * dti
303+ const ay = TWO_PI * gyMid[i] * dti
304+ const az = TWO_PI * gzMid[i] * dti
305+ const e1 = uniformRelax ? Math.exp(-dti * r1u) : 0
306+ const e2 = uniformRelax ? Math.exp(-dti * r2u) : 0
307+ for (let j = 0; j < ns; j++) {
308+ const dphi = ax * x[j] + ay * y[j] + az * z[j] + dw[j] * dti
309+ const c = Math.cos(dphi)
310+ const s = Math.sin(dphi)
311+ const mx = Mx[j]
312+ const my = My[j]
313+ const e2j = uniformRelax ? e2 : Math.exp(-dti / t2[j])
314+ const e1j = uniformRelax ? e1 : Math.exp(-dti / t1[j])
315+ Mx[j] = (mx * c + my * s) * e2j
316+ My[j] = (-mx * s + my * c) * e2j
317+ Mz[j] = Mz[j] * e1j + rho[j] * (1 - e1j)
318+ }
319+ }
320+ }
321+
322+ // Record any ADC samples that fire at the end of this segment.
323+ while (recPtr < records.length && records[recPtr].segment === i) {
324+ const r = records[recPtr]
325+ let sre = 0
326+ let sim = 0
327+ for (let j = 0; j < ns; j++) {
328+ sre += rho[j] * Mx[j]
329+ sim += rho[j] * My[j]
330+ }
331+ // recorded = (sre + i·sim) · exp(-i φ_rx)
332+ const idx = sched.offsets[r.readout] + r.col
333+ re[idx] = sre * r.cosPhi + sim * r.sinPhi
334+ im[idx] = -sre * r.sinPhi + sim * r.cosPhi
335+ recPtr++
336+ }
337+
338+ if (opts.onProgress && (i % progressEvery === 0 || i === numSegments - 1)) {
339+ opts.onProgress({
340+ fraction: numSegments > 0 ? (i + 1) / numSegments : 1,
341+ segment: i + 1,
342+ numSegments,
343+ samplesDone: recPtr,
344+ numSamples: sched.numSamples,
345+ })
346+ }
347+ }
348+
349+ let maxSamplesPerReadout = 0
350+ for (let r = 0; r < sched.numReadouts; r++)
351+ maxSamplesPerReadout = Math.max(maxSamplesPerReadout, sched.samplesPerReadout[r])
352+
353+ return {
354+ re,
355+ im,
356+ numReadouts: sched.numReadouts,
357+ samplesPerReadout: sched.samplesPerReadout,
358+ offsets: sched.offsets,
359+ maxSamplesPerReadout,
360+ }
361+}
362+
363+/** Point sample of a (t,v) polyline (binary search); used only at RF midpoints. */
364+function sampleSeriesScalar(series: Series, time: number): number {
365+ const { t, v } = series
366+ const n = t.length
367+ if (n === 0) return NaN
368+ if (time <= t[0]) return v[0]
369+ if (time >= t[n - 1]) return v[n - 1]
370+ let lo = 0
371+ let hi = n - 1
372+ while (hi - lo > 1) {
373+ const mid = (lo + hi) >> 1
374+ if (t[mid] <= time) lo = mid
375+ else hi = mid
376+ }
377+ const ta = t[lo]
378+ const tb = t[hi]
379+ const va = v[lo]
380+ const vb = v[hi]
381+ if (Number.isNaN(va) || Number.isNaN(vb)) return Number.isNaN(va) ? vb : va
382+ if (tb === ta) return vb
383+ return va + ((time - ta) / (tb - ta)) * (vb - va)
384+}
src/sim/useSimulation.tsadded+88−0View file
@@ -0,0 +1,88 @@
1+// React hook that owns the simulation worker: start a run, stream progress,
2+// cancel by terminating the worker (à la seqlab's runner).
3+import { useCallback, useEffect, useRef, useState } from 'react'
4+import type { RawSignal, SimProgress } from './simulate.ts'
5+import type { RunMessage, WorkerOut } from './simWorker.ts'
6+import type { Phantom } from '../phantom/phantomTypes.ts'
7+
8+export type SimStatus = 'idle' | 'running' | 'done' | 'error' | 'cancelled'
9+
10+export interface SimState {
11+ status: SimStatus
12+ progress: SimProgress | null
13+ elapsedMs: number
14+ result: RawSignal | null
15+ error: string | null
16+}
17+
18+const INITIAL: SimState = {
19+ status: 'idle',
20+ progress: null,
21+ elapsedMs: 0,
22+ result: null,
23+ error: null,
24+}
25+
26+export function useSimulation() {
27+ const [state, setState] = useState<SimState>(INITIAL)
28+ const workerRef = useRef<Worker | null>(null)
29+
30+ const teardown = useCallback(() => {
31+ if (workerRef.current) {
32+ workerRef.current.terminate()
33+ workerRef.current = null
34+ }
35+ }, [])
36+
37+ useEffect(() => () => teardown(), [teardown])
38+
39+ const run = useCallback(
40+ (seqText: string, phantom: Phantom) => {
41+ teardown()
42+ const worker = new Worker(new URL('./simWorker.ts', import.meta.url), { type: 'module' })
43+ workerRef.current = worker
44+ setState({ status: 'running', progress: null, elapsedMs: 0, result: null, error: null })
45+
46+ worker.onmessage = (ev: MessageEvent<WorkerOut>) => {
47+ const msg = ev.data
48+ if (msg.type === 'progress') {
49+ setState((s) => (s.status === 'running' ? { ...s, progress: msg.progress, elapsedMs: msg.elapsedMs } : s))
50+ } else if (msg.type === 'done') {
51+ const result: RawSignal = {
52+ re: msg.re,
53+ im: msg.im,
54+ numReadouts: msg.numReadouts,
55+ samplesPerReadout: msg.samplesPerReadout,
56+ offsets: msg.offsets,
57+ maxSamplesPerReadout: msg.maxSamplesPerReadout,
58+ }
59+ setState((s) => ({ ...s, status: 'done', result, elapsedMs: msg.elapsedMs }))
60+ teardown()
61+ } else if (msg.type === 'error') {
62+ setState((s) => ({ ...s, status: 'error', error: msg.message }))
63+ teardown()
64+ }
65+ }
66+ worker.onerror = (ev) => {
67+ setState((s) => ({ ...s, status: 'error', error: ev.message || 'Worker error' }))
68+ teardown()
69+ }
70+
71+ const runMsg: RunMessage = { type: 'run', seqText, phantom }
72+ worker.postMessage(runMsg)
73+ },
74+ [teardown],
75+ )
76+
77+ const cancel = useCallback(() => {
78+ teardown()
79+ setState((s) => (s.status === 'running' ? { ...s, status: 'cancelled' } : s))
80+ }, [teardown])
81+
82+ const reset = useCallback(() => {
83+ teardown()
84+ setState(INITIAL)
85+ }, [teardown])
86+
87+ return { state, run, cancel, reset }
88+}
src/storage/seqStore.tsadded+77−0View file
@@ -0,0 +1,77 @@
1+// Persist uploaded .seq files in the browser (IndexedDB). Small, promise-based
2+// wrapper — no dependency. Each record keeps the raw .seq text plus a
3+// user-editable name.
4+export interface StoredSequence {
5+ id: string
6+ name: string
7+ text: string
8+ size: number
9+ addedAt: number
10+}
11+
12+const DB_NAME = 'mri-scanner'
13+const STORE = 'sequences'
14+const VERSION = 1
15+
16+let dbPromise: Promise<IDBDatabase> | null = null
17+
18+function openDb(): Promise<IDBDatabase> {
19+ if (dbPromise) return dbPromise
20+ dbPromise = new Promise((resolve, reject) => {
21+ const req = indexedDB.open(DB_NAME, VERSION)
22+ req.onupgradeneeded = () => {
23+ const db = req.result
24+ if (!db.objectStoreNames.contains(STORE)) {
25+ db.createObjectStore(STORE, { keyPath: 'id' })
26+ }
27+ }
28+ req.onsuccess = () => resolve(req.result)
29+ req.onerror = () => reject(req.error)
30+ })
31+ return dbPromise
32+}
33+
34+function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
35+ return openDb().then(
36+ (db) =>
37+ new Promise<T>((resolve, reject) => {
38+ const t = db.transaction(STORE, mode)
39+ const req = fn(t.objectStore(STORE))
40+ req.onsuccess = () => resolve(req.result)
41+ req.onerror = () => reject(req.error)
42+ }),
43+ )
44+}
45+
46+export async function listSequences(): Promise<StoredSequence[]> {
47+ const all = await tx<StoredSequence[]>('readonly', (s) => s.getAll() as IDBRequest<StoredSequence[]>)
48+ return all.sort((a, b) => b.addedAt - a.addedAt)
49+}
50+
51+function uid(): string {
52+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
53+ return 'seq_' + Math.floor(performance.now() * 1000).toString(36) + Math.floor(performance.now()).toString(36)
54+}
55+
56+export async function addSequence(name: string, text: string): Promise<StoredSequence> {
57+ const record: StoredSequence = {
58+ id: uid(),
59+ name,
60+ text,
61+ size: text.length,
62+ addedAt: Date.now(),
63+ }
64+ await tx('readwrite', (s) => s.put(record))
65+ return record
66+}
67+
68+export async function renameSequence(id: string, name: string): Promise<void> {
69+ const rec = await tx<StoredSequence | undefined>('readonly', (s) => s.get(id) as IDBRequest<StoredSequence | undefined>)
70+ if (!rec) return
71+ rec.name = name
72+ await tx('readwrite', (s) => s.put(rec))
73+}
74+
75+export async function deleteSequence(id: string): Promise<void> {
76+ await tx('readwrite', (s) => s.delete(id) as unknown as IDBRequest<undefined>)
77+}
src/ui/PhantomPanel.tsxadded+86−0View file
@@ -0,0 +1,86 @@
1+import { useEffect, useRef } from 'react'
2+import type { BuiltinPhantom } from '../phantom/builtins.ts'
3+import type { Phantom } from '../phantom/phantomTypes.ts'
4+import { phantomExtent } from '../phantom/phantomTypes.ts'
5+
6+function PhantomPreview({ phantom }: { phantom: Phantom }) {
7+ const ref = useRef<HTMLCanvasElement>(null)
8+ useEffect(() => {
9+ const canvas = ref.current
10+ if (!canvas) return
11+ const ctx = canvas.getContext('2d')
12+ if (!ctx) return
13+ const W = canvas.width
14+ const H = canvas.height
15+ ctx.clearRect(0, 0, W, H)
16+ const ext = phantomExtent(phantom)
17+ const span = Math.max(ext.max[0] - ext.min[0], ext.max[1] - ext.min[1], 1e-6) * 1.15
18+ const cx = (ext.min[0] + ext.max[0]) / 2
19+ const cy = (ext.min[1] + ext.max[1]) / 2
20+ const scale = Math.min(W, H) / span
21+ // Depth shading by z so the 3-D shape reads.
22+ const zmin = ext.min[2]
23+ const zmax = ext.max[2]
24+ const zrange = zmax - zmin || 1
25+ ctx.globalCompositeOperation = 'lighter'
26+ for (let i = 0; i < phantom.ns; i++) {
27+ const px = W / 2 + (phantom.x[i] - cx) * scale
28+ const py = H / 2 - (phantom.y[i] - cy) * scale
29+ const zt = (phantom.z[i] - zmin) / zrange
30+ const shade = Math.round(60 + 150 * zt)
31+ ctx.fillStyle = `rgba(${Math.round(shade * 0.35)}, ${shade}, ${Math.round(shade * 0.9)}, 0.5)`
32+ ctx.fillRect(px - 1, py - 1, 2, 2)
33+ }
34+ ctx.globalCompositeOperation = 'source-over'
35+ }, [phantom])
36+ return <canvas ref={ref} width={220} height={220} className="phantom-preview" />
37+}
38+
39+export function PhantomPanel({
40+ builtins,
41+ selectedId,
42+ onSelect,
43+ phantom,
44+ loading,
45+}: {
46+ builtins: BuiltinPhantom[]
47+ selectedId: string
48+ onSelect: (id: string) => void
49+ phantom: Phantom | null
50+ loading: boolean
51+}) {
52+ return (
53+ <section className="panel">
54+ <div className="panel-head">
55+ <h2>Phantom</h2>
56+ </div>
57+ <div className="phantom-options">
58+ {builtins.map((b) => (
59+ <label key={b.id} className={`phantom-option${b.id === selectedId ? ' selected' : ''}`}>
60+ <input type="radio" name="phantom" checked={b.id === selectedId} onChange={() => onSelect(b.id)} />
61+ <span className="phantom-label">{b.label}</span>
62+ <span className="phantom-desc">{b.description}</span>
63+ </label>
64+ ))}
65+ </div>
66+ <div className="phantom-preview-wrap">
67+ {loading && <div className="muted">Loading phantom…</div>}
68+ {!loading && phantom && <PhantomPreview phantom={phantom} />}
69+ {!loading && phantom && (
70+ <div className="phantom-stats">
71+ <div>
72+ <strong>{phantom.ns.toLocaleString()}</strong> spins
73+ </div>
74+ <div>
75+ T1 {(phantom.t1[0] * 1000).toFixed(0)} ms · T2 {(phantom.t2[0] * 1000).toFixed(0)} ms
76+ </div>
77+ <div className="muted">XY projection, shaded by z</div>
78+ </div>
79+ )}
80+ </div>
81+ <p className="muted small">
82+ <code>.phantom</code> format. A single water-like tissue, densely and uniformly sampled.
83+ </p>
84+ </section>
85+ )
86+}
src/ui/SequencePanel.tsxadded+132−0View file
@@ -0,0 +1,132 @@
1+import { useRef, useState } from 'react'
2+import type { StoredSequence } from '../storage/seqStore.ts'
3+
4+function fmtBytes(n: number): string {
5+ if (n < 1024) return `${n} B`
6+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
7+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
8+}
9+
10+export function SequencePanel({
11+ sequences,
12+ selectedId,
13+ onSelect,
14+ onUpload,
15+ onRename,
16+ onDelete,
17+}: {
18+ sequences: StoredSequence[]
19+ selectedId: string | null
20+ onSelect: (id: string) => void
21+ onUpload: (files: FileList) => void
22+ onRename: (id: string, name: string) => void
23+ onDelete: (id: string) => void
24+}) {
25+ const fileRef = useRef<HTMLInputElement>(null)
26+ const [dragging, setDragging] = useState(false)
27+ const [editingId, setEditingId] = useState<string | null>(null)
28+ const [draft, setDraft] = useState('')
29+
30+ const startEdit = (seq: StoredSequence) => {
31+ setEditingId(seq.id)
32+ setDraft(seq.name)
33+ }
34+ const commitEdit = () => {
35+ if (editingId && draft.trim()) onRename(editingId, draft.trim())
36+ setEditingId(null)
37+ }
38+
39+ return (
40+ <section className="panel">
41+ <div className="panel-head">
42+ <h2>Sequences</h2>
43+ <button type="button" className="btn-small" onClick={() => fileRef.current?.click()}>
44+ + Upload .seq
45+ </button>
46+ <input
47+ ref={fileRef}
48+ type="file"
49+ accept=".seq"
50+ multiple
51+ hidden
52+ onChange={(e) => {
53+ if (e.target.files?.length) onUpload(e.target.files)
54+ e.target.value = ''
55+ }}
56+ />
57+ </div>
58+
59+ <div
60+ className={`dropzone${dragging ? ' dragging' : ''}`}
61+ onDragOver={(e) => {
62+ e.preventDefault()
63+ setDragging(true)
64+ }}
65+ onDragLeave={() => setDragging(false)}
66+ onDrop={(e) => {
67+ e.preventDefault()
68+ setDragging(false)
69+ if (e.dataTransfer.files?.length) onUpload(e.dataTransfer.files)
70+ }}
71+ >
72+ Drop <code>.seq</code> files here
73+ </div>
74+
75+ {sequences.length === 0 ? (
76+ <p className="muted">
77+ No sequences yet. Upload a <code>.seq</code> file — for example one exported from{' '}
78+ <a href="https://concept-collection.github.io/seqlab/" target="_blank" rel="noreferrer">
79+ seqlab
80+ </a>
81+ .
82+ </p>
83+ ) : (
84+ <ul className="seq-list">
85+ {sequences.map((seq) => (
86+ <li
87+ key={seq.id}
88+ className={seq.id === selectedId ? 'selected' : ''}
89+ onClick={() => onSelect(seq.id)}
90+ >
91+ <div className="seq-row">
92+ <input
93+ type="radio"
94+ checked={seq.id === selectedId}
95+ onChange={() => onSelect(seq.id)}
96+ onClick={(e) => e.stopPropagation()}
97+ />
98+ {editingId === seq.id ? (
99+ <input
100+ className="rename-input"
101+ autoFocus
102+ value={draft}
103+ onChange={(e) => setDraft(e.target.value)}
104+ onBlur={commitEdit}
105+ onKeyDown={(e) => {
106+ if (e.key === 'Enter') commitEdit()
107+ if (e.key === 'Escape') setEditingId(null)
108+ }}
109+ onClick={(e) => e.stopPropagation()}
110+ />
111+ ) : (
112+ <span className="seq-name" title={seq.name}>
113+ {seq.name}
114+ </span>
115+ )}
116+ <div className="seq-actions" onClick={(e) => e.stopPropagation()}>
117+ <button type="button" className="icon-btn" title="Rename" onClick={() => startEdit(seq)}>
118+ ✎
119+ </button>
120+ <button type="button" className="icon-btn danger" title="Delete" onClick={() => onDelete(seq.id)}>
121+ 🗑
122+ </button>
123+ </div>
124+ </div>
125+ <div className="seq-meta">{fmtBytes(seq.size)}</div>
126+ </li>
127+ ))}
128+ </ul>
129+ )}
130+ </section>
131+ )
132+}
test-data/fid.seqadded+93−0View file
@@ -0,0 +1,93 @@
1+# Pulseq sequence file
2+# Created by MATLAB mr toolbox
3+
4+[VERSION]
5+major 1
6+minor 5
7+revision 1
8+
9+[DEFINITIONS]
10+AdcRasterTime 1e-07
11+BlockDurationRaster 1e-05
12+GradientRasterTime 1e-05
13+Name fid
14+RadiofrequencyRasterTime 1e-06
15+TotalDuration 80.32
16+
17+# Format of blocks:
18+# NUM DUR RF GX GY GZ ADC EXT
19+[BLOCKS]
20+ 1 2000 1 0 0 0 0 0
21+ 2 500000 0 0 0 0 1 0
22+ 3 2000 1 0 0 0 0 0
23+ 4 500000 0 0 0 0 1 0
24+ 5 2000 1 0 0 0 0 0
25+ 6 500000 0 0 0 0 1 0
26+ 7 2000 1 0 0 0 0 0
27+ 8 500000 0 0 0 0 1 0
28+ 9 2000 1 0 0 0 0 0
29+10 500000 0 0 0 0 1 0
30+11 2000 1 0 0 0 0 0
31+12 500000 0 0 0 0 1 0
32+13 2000 1 0 0 0 0 0
33+14 500000 0 0 0 0 1 0
34+15 2000 1 0 0 0 0 0
35+16 500000 0 0 0 0 1 0
36+17 2000 1 0 0 0 0 0
37+18 500000 0 0 0 0 1 0
38+19 2000 1 0 0 0 0 0
39+20 500000 0 0 0 0 1 0
40+21 2000 1 0 0 0 0 0
41+22 500000 0 0 0 0 1 0
42+23 2000 1 0 0 0 0 0
43+24 500000 0 0 0 0 1 0
44+25 2000 1 0 0 0 0 0
45+26 500000 0 0 0 0 1 0
46+27 2000 1 0 0 0 0 0
47+28 500000 0 0 0 0 1 0
48+29 2000 1 0 0 0 0 0
49+30 500000 0 0 0 0 1 0
50+31 2000 1 0 0 0 0 0
51+32 500000 0 0 0 0 1 0
52+
53+# Format of RF events:
54+# id ampl. mag_id phase_id time_shape_id center delay freqPPM phasePPM freq phase use
55+# .. Hz .. .. .. us us ppm rad/MHz Hz rad ..
56+# Field 'use' is the initial of:
57+# excitation refocusing inversion saturation preparation other undefined
58+[RF]
59+1 833.333 1 2 3 150 100 0 0 0 0 e
60+
61+# Format of ADC events:
62+# id num dwell delay freqPPM phasePPM freq phase phase_id
63+# .. .. ns us ppm rad/MHz Hz rad ..
64+[ADC]
65+1 4096 125000 20 0 0 0 0 0
66+
67+# Sequence Shapes
68+[SHAPES]
69+
70+shape_id 1
71+num_samples 2
72+1
73+1
74+
75+shape_id 2
76+num_samples 2
77+0
78+0
79+
80+shape_id 3
81+num_samples 2
82+0
83+300
84+
85+
86+[SIGNATURE]
87+# This is the hash of the Pulseq file, calculated right before the [SIGNATURE]
88+# section was added. It can be reproduced/verified with md5sum if the file
89+# trimmed to the position right above [SIGNATURE]. The new line character
90+# preceding [SIGNATURE] BELONGS to the signature (and needs to be sripped away
91+# for recalculating/verification)
92+Type md5
93+Hash 379f84fe1b36c9422763fa576adeba10
test-data/gre.seqadded+4139−0View file
This diff is 4,144 lines long and is not shown.
tsconfig.app.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM", "WebWorker"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "noUnusedLocals": true,
21+ "noUnusedParameters": true,
22+ "erasableSyntaxOnly": true,
23+ "noFallthroughCasesInSwitch": true
24+ },
25+ "include": ["src"]
26+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "types": ["node"],
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "module": "nodenext",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ /* Linting */
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "erasableSyntaxOnly": true,
20+ "noFallthroughCasesInSwitch": true
21+ },
22+ "include": ["vite.config.ts"]
23+}
vite.config.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// base: './' so the built app works when served from a GitHub Pages subpath
5+// (https://concept-collection.github.io/mri-scanner/).
6+export default defineConfig({
7+ base: './',
8+ plugins: [react()],
9+ worker: {
10+ format: 'es',
11+ },
12+})