/ concept-collection / mri-scanner
Sign in
concept-collection / mri-scanner
mri-scanner / src / App.tsx
242 lines · 8.0 KBCodeBlameHistory
5f2d2b3mri-scanner: in-browser Bloch simulation of pulseq sequencesJeremy Magland 1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { SequencePanel } from './ui/SequencePanel.tsx'
3import { PhantomPanel } from './ui/PhantomPanel.tsx'
4import { KspaceView } from './kspace/KspaceView.tsx'
5import { BUILTIN_PHANTOMS, fetchBuiltinPhantom } from './phantom/builtins.ts'
6import type { Phantom } from './phantom/phantomTypes.ts'
7import {
8 addSequence,
9 deleteSequence,
10 listSequences,
11 renameSequence,
12} from './storage/seqStore.ts'
13import type { StoredSequence } from './storage/seqStore.ts'
14import { summarizeSeq } from './seq/summary.ts'
15import { useSimulation } from './sim/useSimulation.ts'
17function stripExt(name: string): string {
18 return name.replace(/\.seq$/i, '')
21export 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())
30 const { state: sim, run, cancel, reset } = useSimulation()
32 const refresh = useCallback(async () => {
33 const list = await listSequences()
34 setSequences(list)
35 setSelectedSeqId((cur) => cur ?? (list.length ? list[0].id : null))
36 }, [])
38 useEffect(() => {
39 void refresh()
40 }, [refresh])
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])
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])
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 )
96 const handleRename = useCallback(
97 async (id: string, name: string) => {
98 await renameSequence(id, name)
99 await refresh()
100 },
101 [refresh],
102 )
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 )
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])
121 const pct = sim.progress ? Math.round(sim.progress.fraction * 100) : 0
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>
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>}
152 <PhantomPanel
153 builtins={BUILTIN_PHANTOMS}
154 selectedId={phantomId}
155 onSelect={setPhantomId}
156 phantom={phantom}
157 loading={phantomLoading}
158 />
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>}
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>
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>
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 )
moveopenescclose