1import { useEffect, useState, type CSSProperties } from "react";
2import {
3 RegionView,
4 type Points,
5 type Segment,
6 type Pt,
7} from "./render/RegionView.js";
8import { onData, onHostEvent, sendToMATLAB } from "./bridge.js";
10/** Payload from the numbl script: a convex region (CCW polygon) plus the
11 * hit-and-run samples drawn from it. Mirrors what hitandrun_sampler.m sends,
12 * both as the initial `Data` and as the `data` event after "New region". */
13interface HitAndRunData {
14 type: "hitandrun";
15 region: Points;
16 samples: Points;
17 n: number;
18}
20function isHitAndRunData(d: unknown): d is HitAndRunData {
21 return (
22 !!d &&
23 typeof d === "object" &&
24 (d as HitAndRunData).type === "hitandrun" &&
25 !!(d as HitAndRunData).region &&
26 !!(d as HitAndRunData).samples
27 );
28}
30/** `samples` event payload: fresh points for the *same* region (after Resample
31 * or moving the samples slider). */
32interface SamplesEvent {
33 x: number[];
34 y: number[];
35 n: number;
36}
38// Discrete sample-count choices; the slider indexes into this array.
39const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000, 1000000];
40const DEFAULT_N = 10000;
42// Sampling movie: reveal points one at a time. Each frame shows the chord the
43// step samples along AND the point that landed on it, together.
44const MOVIE_STEP_MS = 750;
45const MOVIE_LAST_INDEX = 41; // animate sampling of points up to this index
47interface MovieState {
48 step: number; // index of the point currently being sampled
49}
51/** The chord that hit-and-run samples along: the line through (px,py) with
52 * direction (dx,dy), clipped to the convex region. Same inward-half-plane
53 * intersection the sampler uses, so it reproduces the step exactly. */
54function regionChord(
55 region: Points,
56 px: number,
57 py: number,
58 dx: number,
59 dy: number
60): Segment | null {
61 if (Math.hypot(dx, dy) < 1e-12) return null;
62 let tmin = -Infinity;
63 let tmax = Infinity;
64 const m = region.x.length;
65 for (let i = 0; i < m; i++) {
66 const j = (i + 1) % m;
67 const ex = region.x[j] - region.x[i];
68 const ey = region.y[j] - region.y[i];
69 // Inward normal of a CCW edge. inside: n·p >= n·v_i -> t·a >= rhs.
70 const nx = -ey;
71 const ny = ex;
72 const a = nx * dx + ny * dy;
73 const rhs = nx * region.x[i] + ny * region.y[i] - (nx * px + ny * py);
74 if (a > 1e-12) tmin = Math.max(tmin, rhs / a);
75 else if (a < -1e-12) tmax = Math.min(tmax, rhs / a);
76 }
77 if (!(tmax > tmin) || !isFinite(tmin) || !isFinite(tmax)) return null;
78 return {
79 x0: px + tmin * dx,
80 y0: py + tmin * dy,
81 x1: px + tmax * dx,
82 y1: py + tmax * dy,
83 };
84}
86const prefixPoints = (p: Points, k: number): Points => ({
87 x: p.x.slice(0, k),
88 y: p.y.slice(0, k),
89});
91export function App() {
92 const [data, setData] = useState<HitAndRunData | null>(null);
93 const [n, setN] = useState(DEFAULT_N);
94 const [busy, setBusy] = useState(false);
95 const [movie, setMovie] = useState<MovieState | null>(null);
97 useEffect(() => {
98 // Initial region + samples (script → page via `Data`).
99 const offData = onData(d => {
100 if (isHitAndRunData(d)) {
101 setData(d);
102 setN(d.n);
103 setMovie(null);
104 }
105 });
106 // "New region": the script sends a full fresh payload.
107 const offFull = onHostEvent("data", d => {
108 if (isHitAndRunData(d)) {
109 setData(d);
110 setN(d.n);
111 setBusy(false);
112 setMovie(null);
113 }
114 });
115 // Resample (same region): only the points change.
116 const offSamples = onHostEvent("samples", ev => {
117 const s = ev as SamplesEvent;
118 if (!s || !Array.isArray(s.x)) return;
119 setData(prev =>
120 prev ? { ...prev, samples: { x: s.x, y: s.y }, n: s.n } : prev
121 );
122 setBusy(false);
123 setMovie(null);
124 });
125 return () => {
126 offData();
127 offFull();
128 offSamples();
129 };
130 }, []);
132 // Movie clock: advance phase/step on a timer (a setTimeout chain — the effect
133 // reruns whenever `movie` changes). Ends (→ full cloud) past the last index.
134 useEffect(() => {
135 if (!movie || !data) return;
136 const lastIndex = Math.min(data.samples.x.length - 1, MOVIE_LAST_INDEX);
137 const id = setTimeout(() => {
138 setMovie(m => {
139 if (!m) return m;
140 const next = m.step + 1;
141 return next > lastIndex ? null : { step: next };
142 });
143 }, MOVIE_STEP_MS);
144 return () => clearTimeout(id);
145 }, [movie, data]);
147 // Re-draw `count` samples in the current region (script round-trip).
148 const resample = (count: number) => {
149 if (!data) return;
150 setBusy(true);
151 sendToMATLAB("resample", { n: count, x: data.region.x, y: data.region.y });
152 };
154 // Generate a brand new region and sample it (script round-trip).
155 const newRegion = (count: number) => {
156 setBusy(true);
157 sendToMATLAB("newRegion", { n: count });
158 };
160 const toggleMovie = () => {
161 if (movie) {
162 setMovie(null);
163 } else if (data && data.samples.x.length >= 3) {
164 setMovie({ step: 2 });
165 }
166 };
168 // Overlay for the current movie frame (settled points + chord + highlights).
169 let cloud: Points = data ? data.samples : { x: [], y: [] };
170 let chord: Segment | null = null;
171 let from: Pt | null = null;
172 let newPoint: Pt | null = null;
173 if (movie && data) {
174 const i = movie.step; // point being sampled
175 const f = i - 1; // point the step starts from
176 const { x, y } = data.samples;
177 cloud = prefixPoints(data.samples, i); // settled points 0..i-1
178 from = { x: x[f], y: y[f] };
179 chord = regionChord(data.region, x[f], y[f], x[i] - x[f], y[i] - y[f]);
180 newPoint = { x: x[i], y: y[i] };
181 }
183 const canPlay = !!data && data.samples.x.length >= 3;
185 return (
186 <div style={rootStyle}>
187 {data ? (
188 <RegionView
189 region={data.region}
190 samples={cloud}
191 chord={chord}
192 from={from}
193 newPoint={newPoint}
194 />
195 ) : (
196 <div style={waitingStyle}>Waiting for the region from the script…</div>
197 )}
199 <div style={panelStyle}>
200 <label style={labelStyle}>
201 Samples: <b>{n.toLocaleString()}</b>
202 <input
203 type="range"
204 min={0}
205 max={SAMPLE_CHOICES.length - 1}
206 step={1}
207 value={Math.max(0, SAMPLE_CHOICES.indexOf(n))}
208 disabled={!data || busy || !!movie}
209 // Drag updates the label live; the script round-trip fires on
210 // release (and on arrow-key release) to avoid flooding it.
211 onChange={e => setN(SAMPLE_CHOICES[Number(e.target.value)])}
212 onPointerUp={e =>
213 resample(SAMPLE_CHOICES[Number(e.currentTarget.value)])
214 }
215 onKeyUp={e => {
216 if (e.key.startsWith("Arrow")) {
217 resample(SAMPLE_CHOICES[Number(e.currentTarget.value)]);
218 }
219 }}
220 style={sliderStyle}
221 />
222 </label>
224 <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
225 <button
226 style={btnStyle}
227 disabled={!data || busy || !!movie}
228 onClick={() => resample(n)}
229 title="Draw a fresh set of samples in the same region"
230 >
231 Resample
232 </button>
233 <button
234 style={btnStyle}
235 disabled={busy || !!movie}
236 onClick={() => newRegion(n)}
237 title="Generate a new convex region and sample it"
238 >
239 New region
240 </button>
241 </div>
243 <button
244 style={playBtnStyle}
245 disabled={!canPlay || busy}
246 onClick={toggleMovie}
247 title="Animate the hit-and-run steps: chord, then the sampled point"
248 >
249 {movie ? "■ Stop movie" : "▶ Play movie"}
250 </button>
252 <div style={{ fontSize: 10, color: "#64748b", marginTop: 6 }}>
253 {busy
254 ? "sampling…"
255 : movie
256 ? `movie · point ${movie.step + 1}`
257 : `${data ? data.n.toLocaleString() : "—"} points`}
258 </div>
259 </div>
260 </div>
261 );
262}
264const rootStyle: CSSProperties = {
265 position: "absolute",
266 inset: 0,
267 overflow: "hidden",
268 background: "#ffffff",
269 fontFamily: "system-ui, -apple-system, Arial, sans-serif",
270};
272const waitingStyle: CSSProperties = {
273 position: "absolute",
274 inset: 0,
275 display: "flex",
276 alignItems: "center",
277 justifyContent: "center",
278 color: "#94a3b8",
279};
281const panelStyle: CSSProperties = {
282 position: "absolute",
283 top: 8,
284 left: 8,
285 width: 150,
286 padding: "7px 9px",
287 background: "rgba(255,255,255,0.9)",
288 border: "1px solid #e2e8f0",
289 borderRadius: 6,
290 boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
291 color: "#0f172a",
292};
294const labelStyle: CSSProperties = {
295 display: "block",
296 fontSize: 11,
297};
299const sliderStyle: CSSProperties = {
300 width: "100%",
301 marginTop: 2,
302};
304const btnStyle: CSSProperties = {
305 flex: 1,
306 padding: "3px 4px",
307 fontSize: 10,
308 whiteSpace: "nowrap",
309 cursor: "pointer",
310 background: "#f8fafc",
311 border: "1px solid #cbd5e1",
312 borderRadius: 5,
313 color: "#0f172a",
314};
316const playBtnStyle: CSSProperties = {
317 width: "100%",
318 marginTop: 6,
319 padding: "4px 6px",
320 fontSize: 10,
321 cursor: "pointer",
322 background: "#eff6ff",
323 border: "1px solid #bfdbfe",
324 borderRadius: 5,
325 color: "#1e3a8a",
326};