/ concept-collection / walnuts-interactive
Sign in
concept-collection / walnuts-interactive
walnuts-interactive / app / src / App.tsx
380 lines · 10.2 KBBlameHistoryRaw
1import { useEffect, useState, type CSSProperties } from "react";
2import {
3 DensityView,
4 type Points,
5 type DensityGrid,
6 type OrbitPath,
7 type Pt,
8} from "./render/DensityView.js";
9import { onData, onHostEvent, sendToMATLAB } from "./bridge.js";
11/** Payload from the numbl script: the target density (for the heatmap) plus the
12 * WALNUTS samples. Mirrors what walnuts_sampler.m sends. */
13interface WalnutsData {
14 type: "walnuts";
15 density: DensityGrid;
16 samples: Points;
17 n: number;
18 dt: number;
19 maxError: number;
20 target: string;
23const TARGETS: { value: string; label: string }[] = [
24 { value: "banana", label: "Banana" },
25 { value: "gaussian", label: "Gaussian" },
26 { value: "correlated", label: "Correlated Gaussian" },
27 { value: "donut", label: "Donut (ring)" },
28];
30function isWalnutsData(d: unknown): d is WalnutsData {
31 return (
32 !!d &&
33 typeof d === "object" &&
34 (d as WalnutsData).type === "walnuts" &&
35 !!(d as WalnutsData).density &&
36 !!(d as WalnutsData).samples
37 );
40interface SamplesEvent {
41 x: number[];
42 y: number[];
43 n: number;
44 dt: number;
45 maxError: number;
48/** One recorded transition's orbit (from `walnuts(..., record=true)`). */
49interface MovieStep {
50 px: number[];
51 py: number[];
52 seg: number[];
53 startX: number;
54 startY: number;
55 selX: number;
56 selY: number;
59const SAMPLE_CHOICES = [100, 300, 1000, 3000];
60const DEFAULT_N = 1000;
61const DT_MIN = 0.05;
62const DT_MAX = 1.2;
63const ERR_MIN = 0.1;
64const ERR_MAX = 4;
66// Movie pacing: reveal a couple of leapfrog points per tick, then linger on the
67// selected draw before the next transition.
68const MOVIE_TICK_MS = 55;
69const MOVIE_REVEAL = 2;
70const MOVIE_HOLD = 14; // extra k-units to hold the selected point
72export function App() {
73 const [data, setData] = useState<WalnutsData | null>(null);
74 const [n, setN] = useState(DEFAULT_N);
75 const [dt, setDt] = useState(0.4);
76 const [maxError, setMaxError] = useState(0.8);
77 const [target, setTargetState] = useState("banana");
78 const [busy, setBusy] = useState(false);
79 const [movieData, setMovieData] = useState<MovieStep[] | null>(null);
80 const [movie, setMovie] = useState<{ si: number; k: number } | null>(null);
82 // Apply a full payload (initial Data, or a `data` event after a target change).
83 const applyData = (d: WalnutsData) => {
84 setData(d);
85 setN(d.n);
86 setDt(d.dt);
87 setMaxError(d.maxError);
88 setTargetState(d.target);
89 };
91 useEffect(() => {
92 const offData = onData(d => {
93 if (isWalnutsData(d)) applyData(d);
94 });
95 // New target: full fresh payload (density + samples).
96 const offFull = onHostEvent("data", d => {
97 if (isWalnutsData(d)) {
98 applyData(d);
99 setBusy(false);
100 setMovie(null);
101 setMovieData(null);
102 }
103 });
104 // Resample: same target, new draws.
105 const offSamples = onHostEvent("samples", ev => {
106 const s = ev as SamplesEvent;
107 if (!s || !Array.isArray(s.x)) return;
108 setData(prev =>
109 prev
110 ? { ...prev, samples: { x: s.x, y: s.y }, n: s.n, dt: s.dt, maxError: s.maxError }
111 : prev
112 );
113 setBusy(false);
114 });
115 // Movie: an array of recorded transition orbits to animate.
116 const offMovie = onHostEvent("movie", ev => {
117 if (!Array.isArray(ev) || ev.length === 0) {
118 setBusy(false);
119 return;
120 }
121 setMovieData(ev as MovieStep[]);
122 setMovie({ si: 0, k: 0 });
123 setBusy(false);
124 });
125 return () => {
126 offData();
127 offFull();
128 offSamples();
129 offMovie();
130 };
131 }, []);
133 // Movie clock.
134 useEffect(() => {
135 if (!movie || !movieData) return;
136 const id = setTimeout(() => {
137 setMovie(m => {
138 if (!m) return m;
139 const step = movieData[m.si];
140 const count = step.px.length;
141 if (m.k < count + MOVIE_HOLD) return { si: m.si, k: m.k + MOVIE_REVEAL };
142 const nextSi = m.si + 1;
143 return nextSi >= movieData.length ? null : { si: nextSi, k: 0 };
144 });
145 }, MOVIE_TICK_MS);
146 return () => clearTimeout(id);
147 }, [movie, movieData]);
149 const stopMovie = () => {
150 setMovie(null);
151 setMovieData(null);
152 };
154 const resample = (count: number, step: number, err: number) => {
155 if (!data || busy) return;
156 stopMovie();
157 setBusy(true);
158 sendToMATLAB("resample", { n: count, dt: step, maxError: err, target });
159 };
161 // Switch the target: the script rebuilds the density + draws and replies with
162 // a full `data` event.
163 const changeTarget = (value: string) => {
164 setTargetState(value);
165 if (!data) return;
166 stopMovie();
167 setBusy(true);
168 sendToMATLAB("setTarget", { target: value, n, dt, maxError });
169 };
171 const playMovie = () => {
172 if (movie) {
173 stopMovie();
174 return;
175 }
176 if (!data || busy) return;
177 setBusy(true); // until the trajectory arrives
178 sendToMATLAB("movie", { dt, maxError, target });
179 };
181 // ── derive the movie overlay for the current frame ──
182 let cloud: Points = data ? data.samples : { x: [], y: [] };
183 let orbit: OrbitPath | null = null;
184 let start: Pt | null = null;
185 let lead: Pt | null = null;
186 let chainPts: Points | null = null;
187 let selected: Pt | null = null;
188 if (movie && movieData) {
189 const step = movieData[movie.si];
190 const count = step.px.length;
191 const k = Math.min(movie.k, count);
192 orbit = { x: step.px.slice(0, k), y: step.py.slice(0, k), seg: step.seg.slice(0, k) };
193 start = { x: step.startX, y: step.startY };
194 if (k > 0) lead = { x: step.px[k - 1], y: step.py[k - 1] };
195 const cx: number[] = [];
196 const cy: number[] = [];
197 for (let j = 0; j < movie.si; j++) {
198 cx.push(movieData[j].selX);
199 cy.push(movieData[j].selY);
200 }
201 chainPts = { x: cx, y: cy };
202 if (movie.k >= count) selected = { x: step.selX, y: step.selY };
203 cloud = data ? data.samples : { x: [], y: [] };
204 }
206 const controlsDisabled = !data || busy || !!movie;
207 const status = busy
208 ? "sampling…"
209 : movie && movieData
210 ? `movie · transition ${movie.si + 1}/${movieData.length}`
211 : `${data ? data.n.toLocaleString() : "—"} samples`;
213 return (
214 <div style={rootStyle}>
215 {data ? (
216 <DensityView
217 density={data.density}
218 samples={cloud}
219 orbit={orbit}
220 start={start}
221 lead={lead}
222 chainPts={chainPts}
223 selected={selected}
224 />
225 ) : (
226 <div style={waitingStyle}>Waiting for samples from the script…</div>
227 )}
229 <div style={panelStyle}>
230 <div style={{ fontWeight: 600, marginBottom: 6 }}>WALNUTS</div>
232 <label style={labelStyle}>
233 Target
234 <select
235 value={target}
236 disabled={controlsDisabled}
237 onChange={e => changeTarget(e.target.value)}
238 style={selectStyle}
239 >
240 {TARGETS.map(t => (
241 <option key={t.value} value={t.value}>
242 {t.label}
243 </option>
244 ))}
245 </select>
246 </label>
248 <label style={labelStyle}>
249 Samples: <b>{n.toLocaleString()}</b>
250 <input
251 type="range"
252 min={0}
253 max={SAMPLE_CHOICES.length - 1}
254 step={1}
255 value={Math.max(0, SAMPLE_CHOICES.indexOf(n))}
256 disabled={controlsDisabled}
257 onChange={e => setN(SAMPLE_CHOICES[Number(e.target.value)])}
258 onPointerUp={e =>
259 resample(SAMPLE_CHOICES[Number(e.currentTarget.value)], dt, maxError)
260 }
261 style={sliderStyle}
262 />
263 </label>
265 <label style={labelStyle}>
266 Leapfrog Δt: <b>{dt.toFixed(2)}</b>
267 <input
268 type="range"
269 min={DT_MIN}
270 max={DT_MAX}
271 step={0.05}
272 value={dt}
273 disabled={controlsDisabled}
274 onChange={e => setDt(Number(e.target.value))}
275 onPointerUp={e => resample(n, Number(e.currentTarget.value), maxError)}
276 style={sliderStyle}
277 />
278 </label>
280 <label style={labelStyle}>
281 Max error: <b>{maxError.toFixed(2)}</b>
282 <input
283 type="range"
284 min={ERR_MIN}
285 max={ERR_MAX}
286 step={0.1}
287 value={maxError}
288 disabled={controlsDisabled}
289 onChange={e => setMaxError(Number(e.target.value))}
290 onPointerUp={e => resample(n, dt, Number(e.currentTarget.value))}
291 style={sliderStyle}
292 />
293 </label>
295 <div style={{ display: "flex", gap: 6, marginTop: 8 }}>
296 <button
297 style={btnStyle}
298 disabled={controlsDisabled}
299 onClick={() => resample(n, dt, maxError)}
300 title="Draw a fresh chain with these settings"
301 >
302 Resample
303 </button>
304 <button
305 style={btnStyle}
306 disabled={!data || busy}
307 onClick={playMovie}
308 title="Animate WALNUTS building orbits step by step"
309 >
310 {movie ? "■ Stop" : "▶ Movie"}
311 </button>
312 </div>
314 <div style={{ fontSize: 10, color: "#64748b", marginTop: 8 }}>
315 {status}
316 </div>
317 </div>
318 </div>
319 );
322const rootStyle: CSSProperties = {
323 position: "absolute",
324 inset: 0,
325 overflow: "hidden",
326 background: "#ffffff",
327 fontFamily: "system-ui, -apple-system, Arial, sans-serif",
328};
330const waitingStyle: CSSProperties = {
331 position: "absolute",
332 inset: 0,
333 display: "flex",
334 alignItems: "center",
335 justifyContent: "center",
336 color: "#94a3b8",
337};
339const panelStyle: CSSProperties = {
340 position: "absolute",
341 top: 8,
342 left: 8,
343 width: 168,
344 padding: "8px 10px",
345 background: "rgba(255,255,255,0.92)",
346 border: "1px solid #e2e8f0",
347 borderRadius: 6,
348 boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
349 color: "#0f172a",
350};
352const labelStyle: CSSProperties = {
353 display: "block",
354 fontSize: 11,
355 marginTop: 6,
356};
358const sliderStyle: CSSProperties = {
359 width: "100%",
360 marginTop: 2,
361};
363const selectStyle: CSSProperties = {
364 width: "100%",
365 marginTop: 2,
366 fontSize: 11,
367 padding: "2px 4px",
368};
370const btnStyle: CSSProperties = {
371 flex: 1,
372 padding: "4px 6px",
373 fontSize: 11,
374 whiteSpace: "nowrap",
375 cursor: "pointer",
376 background: "#f8fafc",
377 border: "1px solid #cbd5e1",
378 borderRadius: 5,
379 color: "#0f172a",
380};
moveopenescclose