727e2e4Interactive WALNUTS sampling of a 2D banana targetJeremy Magland 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}
22function isWalnutsData(d: unknown): d is WalnutsData {
23 return (
24 !!d &&
25 typeof d === "object" &&
26 (d as WalnutsData).type === "walnuts" &&
27 !!(d as WalnutsData).density &&
28 !!(d as WalnutsData).samples
29 );
30}
32interface SamplesEvent {
33 x: number[];
34 y: number[];
35 n: number;
36 dt: number;
37 maxError: number;
38}
40/** One recorded transition's orbit (from `walnuts(..., record=true)`). */
41interface MovieStep {
42 px: number[];
43 py: number[];
44 seg: number[];
45 startX: number;
46 startY: number;
47 selX: number;
48 selY: number;
49}
51const SAMPLE_CHOICES = [100, 300, 1000, 3000];
52const DEFAULT_N = 1000;
53const DT_MIN = 0.05;
54const DT_MAX = 1.2;
55const ERR_MIN = 0.1;
56const ERR_MAX = 4;
58// Movie pacing: reveal a couple of leapfrog points per tick, then linger on the
59// selected draw before the next transition.
60const MOVIE_TICK_MS = 55;
61const MOVIE_REVEAL = 2;
62const MOVIE_HOLD = 14; // extra k-units to hold the selected point
64export function App() {
65 const [data, setData] = useState<WalnutsData | null>(null);
66 const [n, setN] = useState(DEFAULT_N);
67 const [dt, setDt] = useState(0.4);
68 const [maxError, setMaxError] = useState(0.8);
69 const [busy, setBusy] = useState(false);
70 const [movieData, setMovieData] = useState<MovieStep[] | null>(null);
71 const [movie, setMovie] = useState<{ si: number; k: number } | null>(null);
73 useEffect(() => {
74 const offData = onData(d => {
75 if (isWalnutsData(d)) {
76 setData(d);
77 setN(d.n);
78 setDt(d.dt);
79 setMaxError(d.maxError);
80 }
81 });
82 // Resample: same target, new draws.
83 const offSamples = onHostEvent("samples", ev => {
84 const s = ev as SamplesEvent;
85 if (!s || !Array.isArray(s.x)) return;
86 setData(prev =>
87 prev
88 ? { ...prev, samples: { x: s.x, y: s.y }, n: s.n, dt: s.dt, maxError: s.maxError }
89 : prev
90 );
91 setBusy(false);
92 });
93 // Movie: an array of recorded transition orbits to animate.
94 const offMovie = onHostEvent("movie", ev => {
95 if (!Array.isArray(ev) || ev.length === 0) {
96 setBusy(false);
97 return;
98 }
99 setMovieData(ev as MovieStep[]);
100 setMovie({ si: 0, k: 0 });
101 setBusy(false);
102 });
103 return () => {
104 offData();
105 offSamples();
106 offMovie();
107 };
108 }, []);
110 // Movie clock.
111 useEffect(() => {
112 if (!movie || !movieData) return;
113 const id = setTimeout(() => {
114 setMovie(m => {
115 if (!m) return m;
116 const step = movieData[m.si];
117 const count = step.px.length;
118 if (m.k < count + MOVIE_HOLD) return { si: m.si, k: m.k + MOVIE_REVEAL };
119 const nextSi = m.si + 1;
120 return nextSi >= movieData.length ? null : { si: nextSi, k: 0 };
121 });
122 }, MOVIE_TICK_MS);
123 return () => clearTimeout(id);
124 }, [movie, movieData]);
126 const stopMovie = () => {
127 setMovie(null);
128 setMovieData(null);
129 };
131 const resample = (count: number, step: number, err: number) => {
132 if (!data || busy) return;
133 stopMovie();
134 setBusy(true);
135 sendToMATLAB("resample", { n: count, dt: step, maxError: err });
136 };
138 const playMovie = () => {
139 if (movie) {
140 stopMovie();
141 return;
142 }
143 if (!data || busy) return;
144 setBusy(true); // until the trajectory arrives
145 sendToMATLAB("movie", { dt, maxError });
146 };
148 // ── derive the movie overlay for the current frame ──
149 let cloud: Points = data ? data.samples : { x: [], y: [] };
150 let orbit: OrbitPath | null = null;
151 let start: Pt | null = null;
152 let lead: Pt | null = null;
153 let chainPts: Points | null = null;
154 let selected: Pt | null = null;
155 if (movie && movieData) {
156 const step = movieData[movie.si];
157 const count = step.px.length;
158 const k = Math.min(movie.k, count);
159 orbit = { x: step.px.slice(0, k), y: step.py.slice(0, k), seg: step.seg.slice(0, k) };
160 start = { x: step.startX, y: step.startY };
161 if (k > 0) lead = { x: step.px[k - 1], y: step.py[k - 1] };
162 const cx: number[] = [];
163 const cy: number[] = [];
164 for (let j = 0; j < movie.si; j++) {
165 cx.push(movieData[j].selX);
166 cy.push(movieData[j].selY);
167 }
168 chainPts = { x: cx, y: cy };
169 if (movie.k >= count) selected = { x: step.selX, y: step.selY };
170 cloud = data ? data.samples : { x: [], y: [] };
171 }
173 const controlsDisabled = !data || busy || !!movie;
174 const status = busy
175 ? "sampling…"
176 : movie && movieData
177 ? `movie · transition ${movie.si + 1}/${movieData.length}`
178 : `${data ? data.n.toLocaleString() : "—"} samples`;
180 return (
181 <div style={rootStyle}>
182 {data ? (
183 <DensityView
184 density={data.density}
185 samples={cloud}
186 orbit={orbit}
187 start={start}
188 lead={lead}
189 chainPts={chainPts}
190 selected={selected}
191 />
192 ) : (
193 <div style={waitingStyle}>Waiting for samples from the script…</div>
194 )}
196 <div style={panelStyle}>
197 <div style={{ fontWeight: 600, marginBottom: 4 }}>WALNUTS</div>
198 <div style={{ fontSize: 11, color: "#475569", marginBottom: 8 }}>
199 sampling a banana target
200 </div>
202 <label style={labelStyle}>
203 Samples: <b>{n.toLocaleString()}</b>
204 <input
205 type="range"
206 min={0}
207 max={SAMPLE_CHOICES.length - 1}
208 step={1}
209 value={Math.max(0, SAMPLE_CHOICES.indexOf(n))}
210 disabled={controlsDisabled}
211 onChange={e => setN(SAMPLE_CHOICES[Number(e.target.value)])}
212 onPointerUp={e =>
213 resample(SAMPLE_CHOICES[Number(e.currentTarget.value)], dt, maxError)
214 }
215 style={sliderStyle}
216 />
217 </label>
219 <label style={labelStyle}>
220 Leapfrog Δt: <b>{dt.toFixed(2)}</b>
221 <input
222 type="range"
223 min={DT_MIN}
224 max={DT_MAX}
225 step={0.05}
226 value={dt}
227 disabled={controlsDisabled}
228 onChange={e => setDt(Number(e.target.value))}
229 onPointerUp={e => resample(n, Number(e.currentTarget.value), maxError)}
230 style={sliderStyle}
231 />
232 </label>
234 <label style={labelStyle}>
235 Max error: <b>{maxError.toFixed(2)}</b>
236 <input
237 type="range"
238 min={ERR_MIN}
239 max={ERR_MAX}
240 step={0.1}
241 value={maxError}
242 disabled={controlsDisabled}
243 onChange={e => setMaxError(Number(e.target.value))}
244 onPointerUp={e => resample(n, dt, Number(e.currentTarget.value))}
245 style={sliderStyle}
246 />
247 </label>
249 <div style={{ display: "flex", gap: 6, marginTop: 8 }}>
250 <button
251 style={btnStyle}
252 disabled={controlsDisabled}
253 onClick={() => resample(n, dt, maxError)}
254 title="Draw a fresh chain with these settings"
255 >
256 Resample
257 </button>
258 <button
259 style={btnStyle}
260 disabled={!data || busy}
261 onClick={playMovie}
262 title="Animate WALNUTS building orbits step by step"
263 >
264 {movie ? "■ Stop" : "▶ Movie"}
265 </button>
266 </div>
268 <div style={{ fontSize: 10, color: "#64748b", marginTop: 8 }}>
269 {status}
270 </div>
271 </div>
272 </div>
273 );
274}
276const rootStyle: CSSProperties = {
277 position: "absolute",
278 inset: 0,
279 overflow: "hidden",
280 background: "#ffffff",
281 fontFamily: "system-ui, -apple-system, Arial, sans-serif",
282};
284const waitingStyle: CSSProperties = {
285 position: "absolute",
286 inset: 0,
287 display: "flex",
288 alignItems: "center",
289 justifyContent: "center",
290 color: "#94a3b8",
291};
293const panelStyle: CSSProperties = {
294 position: "absolute",
295 top: 8,
296 left: 8,
297 width: 168,
298 padding: "8px 10px",
299 background: "rgba(255,255,255,0.92)",
300 border: "1px solid #e2e8f0",
301 borderRadius: 6,
302 boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
303 color: "#0f172a",
304};
306const labelStyle: CSSProperties = {
307 display: "block",
308 fontSize: 11,
309 marginTop: 6,
310};
312const sliderStyle: CSSProperties = {
313 width: "100%",
314 marginTop: 2,
315};
317const btnStyle: CSSProperties = {
318 flex: 1,
319 padding: "4px 6px",
320 fontSize: 11,
321 whiteSpace: "nowrap",
322 cursor: "pointer",
323 background: "#f8fafc",
324 border: "1px solid #cbd5e1",
325 borderRadius: 5,
326 color: "#0f172a",
327};