1import { useEffect, useRef } from 'react'
2import type { BuiltinPhantom } from '../phantom/builtins.ts'
3import type { Phantom } from '../phantom/phantomTypes.ts'
4import { phantomExtent } from '../phantom/phantomTypes.ts'
6function 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}
39export 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}