9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 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;
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 18 convex?: boolean; // false → non-convex (star) region
21function isHitAndRunData(d: unknown): d is HitAndRunData {
22 return (
23 !!d &&
24 typeof d === "object" &&
25 (d as HitAndRunData).type === "hitandrun" &&
26 !!(d as HitAndRunData).region &&
27 !!(d as HitAndRunData).samples
28 );
29}
31/** `samples` event payload: fresh points for the *same* region (after Resample
32 * or moving the samples slider). */
33interface SamplesEvent {
34 x: number[];
35 y: number[];
36 n: number;
37}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 39// Discrete sample-count choices; the slider indexes into the active array.
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 40const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000, 1000000];
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 41// Non-convex sampling runs in the interpreter (no JIT), so cap it lower.
42const SAMPLE_CHOICES_NONCONVEX = [10, 100, 1000, 10000];
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 43const DEFAULT_N = 10000;
45// Sampling movie: reveal points one at a time. Each frame shows the chord the
46// step samples along AND the point that landed on it, together.
47const MOVIE_STEP_MS = 750;
48const MOVIE_LAST_INDEX = 41; // animate sampling of points up to this index
50interface MovieState {
51 step: number; // index of the point currently being sampled
52}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 54/** The in-region segment(s) hit-and-run samples along: the line through
55 * (px,py) with direction (dx,dy), intersected with the polygon. One segment
56 * for a convex region, possibly several for a non-convex one. Mirrors the
57 * sampler's geometry, so it reproduces each step exactly. */
58function regionSegments(
60 px: number,
61 py: number,
62 dx: number,
63 dy: number
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 64): Segment[] {
65 if (Math.hypot(dx, dy) < 1e-12) return [];
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 66 const m = region.x.length;
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 67 const ts: number[] = [];
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 68 for (let i = 0; i < m; i++) {
69 const j = (i + 1) % m;
70 const ex = region.x[j] - region.x[i];
71 const ey = region.y[j] - region.y[i];
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 72 const denom = dy * ex - dx * ey;
73 if (Math.abs(denom) < 1e-12) continue;
74 const wx = region.x[i] - px;
75 const wy = region.y[i] - py;
76 const s = (dx * wy - dy * wx) / denom; // position along the edge
77 if (s >= 0 && s < 1) ts.push((wy * ex - wx * ey) / denom);
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 79 ts.sort((a, b) => a - b);
80 const segs: Segment[] = [];
81 for (let k = 0; k < ts.length - 1; k++) {
82 const tm = (ts[k] + ts[k + 1]) / 2;
83 if (pointInPolygon(region, px + tm * dx, py + tm * dy)) {
84 segs.push({
85 x0: px + ts[k] * dx,
86 y0: py + ts[k] * dy,
87 x1: px + ts[k + 1] * dx,
88 y1: py + ts[k + 1] * dy,
89 });
90 }
91 }
92 return segs;
93}
95function pointInPolygon(region: Points, x: number, y: number): boolean {
96 const n = region.x.length;
97 let inside = false;
98 for (let i = 0, j = n - 1; i < n; j = i++) {
99 const xi = region.x[i];
100 const yi = region.y[i];
101 const xj = region.x[j];
102 const yj = region.y[j];
103 if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
104 inside = !inside;
105 }
106 }
107 return inside;
110const prefixPoints = (p: Points, k: number): Points => ({
111 x: p.x.slice(0, k),
112 y: p.y.slice(0, k),
113});
115export function App() {
116 const [data, setData] = useState<HitAndRunData | null>(null);
117 const [n, setN] = useState(DEFAULT_N);
118 const [busy, setBusy] = useState(false);
119 const [movie, setMovie] = useState<MovieState | null>(null);
121 useEffect(() => {
122 // Initial region + samples (script → page via `Data`).
123 const offData = onData(d => {
124 if (isHitAndRunData(d)) {
125 setData(d);
126 setN(d.n);
127 setMovie(null);
128 }
129 });
130 // "New region": the script sends a full fresh payload.
131 const offFull = onHostEvent("data", d => {
132 if (isHitAndRunData(d)) {
133 setData(d);
134 setN(d.n);
135 setBusy(false);
136 setMovie(null);
137 }
138 });
139 // Resample (same region): only the points change.
140 const offSamples = onHostEvent("samples", ev => {
141 const s = ev as SamplesEvent;
142 if (!s || !Array.isArray(s.x)) return;
143 setData(prev =>
144 prev ? { ...prev, samples: { x: s.x, y: s.y }, n: s.n } : prev
145 );
146 setBusy(false);
147 setMovie(null);
148 });
149 return () => {
150 offData();
151 offFull();
152 offSamples();
153 };
154 }, []);
156 // Movie clock: advance phase/step on a timer (a setTimeout chain — the effect
157 // reruns whenever `movie` changes). Ends (→ full cloud) past the last index.
158 useEffect(() => {
159 if (!movie || !data) return;
160 const lastIndex = Math.min(data.samples.x.length - 1, MOVIE_LAST_INDEX);
161 const id = setTimeout(() => {
162 setMovie(m => {
163 if (!m) return m;
164 const next = m.step + 1;
165 return next > lastIndex ? null : { step: next };
166 });
167 }, MOVIE_STEP_MS);
168 return () => clearTimeout(id);
169 }, [movie, data]);
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 171 const nonConvex = !!data && data.convex === false;
172 const choices = nonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES;
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 174 // Re-draw `count` samples in the current region (script round-trip).
175 const resample = (count: number) => {
176 if (!data) return;
177 setBusy(true);
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 178 sendToMATLAB("resample", {
179 n: count,
180 x: data.region.x,
181 y: data.region.y,
182 convex: data.convex !== false,
183 });
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 186 // Generate a brand new region (convex or not) and sample it.
187 const newRegion = (count: number, convex: boolean) => {
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 189 sendToMATLAB("newRegion", { n: count, convex });
190 };
192 // Checkbox: switch region type. Clamp N to the active set's max first.
193 const setNonConvex = (makeNonConvex: boolean) => {
194 const c = makeNonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES;
195 const clamped = Math.min(n, c[c.length - 1]);
196 setN(clamped);
197 newRegion(clamped, !makeNonConvex);
200 const toggleMovie = () => {
201 if (movie) {
202 setMovie(null);
203 } else if (data && data.samples.x.length >= 3) {
204 setMovie({ step: 2 });
205 }
206 };
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 208 // Overlay for the current movie frame (settled points + segments + marks).
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 209 let cloud: Points = data ? data.samples : { x: [], y: [] };
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 210 let segments: Segment[] | null = null;
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 211 let from: Pt | null = null;
212 let newPoint: Pt | null = null;
213 if (movie && data) {
214 const i = movie.step; // point being sampled
215 const f = i - 1; // point the step starts from
216 const { x, y } = data.samples;
217 cloud = prefixPoints(data.samples, i); // settled points 0..i-1
218 from = { x: x[f], y: y[f] };
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 219 segments = regionSegments(data.region, x[f], y[f], x[i] - x[f], y[i] - y[f]);
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 220 newPoint = { x: x[i], y: y[i] };
221 }
223 const canPlay = !!data && data.samples.x.length >= 3;
225 return (
226 <div style={rootStyle}>
227 {data ? (
228 <RegionView
229 region={data.region}
230 samples={cloud}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 231 segments={segments}
233 newPoint={newPoint}
234 />
235 ) : (
236 <div style={waitingStyle}>Waiting for the region from the script…</div>
237 )}
239 <div style={panelStyle}>
240 <label style={labelStyle}>
241 Samples: <b>{n.toLocaleString()}</b>
242 <input
243 type="range"
244 min={0}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 245 max={choices.length - 1}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 247 value={Math.max(0, choices.indexOf(n))}
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 248 disabled={!data || busy || !!movie}
249 // Drag updates the label live; the script round-trip fires on
250 // release (and on arrow-key release) to avoid flooding it.
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 251 onChange={e => setN(choices[Number(e.target.value)])}
252 onPointerUp={e => resample(choices[Number(e.currentTarget.value)])}
254 if (e.key.startsWith("Arrow")) {
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 255 resample(choices[Number(e.currentTarget.value)]);
257 }}
258 style={sliderStyle}
259 />
260 </label>
262 <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
263 <button
264 style={btnStyle}
265 disabled={!data || busy || !!movie}
266 onClick={() => resample(n)}
267 title="Draw a fresh set of samples in the same region"
268 >
269 Resample
270 </button>
271 <button
272 style={btnStyle}
273 disabled={busy || !!movie}
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 274 onClick={() => newRegion(n, !nonConvex)}
275 title="Generate a new region and sample it"
277 New region
278 </button>
279 </div>
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 281 <label style={checkLabelStyle}>
282 <input
283 type="checkbox"
284 checked={nonConvex}
285 disabled={!data || busy || !!movie}
286 onChange={e => setNonConvex(e.target.checked)}
287 />
288 non-convex region
289 </label>
292 style={playBtnStyle}
293 disabled={!canPlay || busy}
294 onClick={toggleMovie}
295 title="Animate the hit-and-run steps: chord, then the sampled point"
296 >
297 {movie ? "■ Stop movie" : "▶ Play movie"}
298 </button>
300 <div style={{ fontSize: 10, color: "#64748b", marginTop: 6 }}>
301 {busy
302 ? "sampling…"
303 : movie
304 ? `movie · point ${movie.step + 1}`
305 : `${data ? data.n.toLocaleString() : "—"} points`}
306 </div>
307 </div>
308 </div>
309 );
310}
312const rootStyle: CSSProperties = {
313 position: "absolute",
314 inset: 0,
315 overflow: "hidden",
316 background: "#ffffff",
317 fontFamily: "system-ui, -apple-system, Arial, sans-serif",
318};
320const waitingStyle: CSSProperties = {
321 position: "absolute",
322 inset: 0,
323 display: "flex",
324 alignItems: "center",
325 justifyContent: "center",
326 color: "#94a3b8",
327};
329const panelStyle: CSSProperties = {
330 position: "absolute",
331 top: 8,
332 left: 8,
333 width: 150,
334 padding: "7px 9px",
335 background: "rgba(255,255,255,0.9)",
336 border: "1px solid #e2e8f0",
337 borderRadius: 6,
338 boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
339 color: "#0f172a",
340};
342const labelStyle: CSSProperties = {
343 display: "block",
344 fontSize: 11,
345};
d9d003fAdd non-convex (star) regions with a general hit-and-run samplerJeremy Magland 347const checkLabelStyle: CSSProperties = {
348 display: "flex",
349 alignItems: "center",
350 gap: 5,
351 fontSize: 11,
352 marginTop: 8,
353 cursor: "pointer",
354};
9fbdc3eInteractive hit-and-run sampling of a 2D convex regionJeremy Magland 356const sliderStyle: CSSProperties = {
357 width: "100%",
358 marginTop: 2,
359};
361const btnStyle: CSSProperties = {
362 flex: 1,
363 padding: "3px 4px",
364 fontSize: 10,
365 whiteSpace: "nowrap",
366 cursor: "pointer",
367 background: "#f8fafc",
368 border: "1px solid #cbd5e1",
369 borderRadius: 5,
370 color: "#0f172a",
371};
373const playBtnStyle: CSSProperties = {
374 width: "100%",
375 marginTop: 6,
376 padding: "4px 6px",
377 fontSize: 10,
378 cursor: "pointer",
379 background: "#eff6ff",
380 border: "1px solid #bfdbfe",
381 borderRadius: 5,
382 color: "#1e3a8a",
383};