import { useEffect, useState, type CSSProperties } from "react"; import { RegionView, type Points, type Segment, type Pt, } from "./render/RegionView.js"; import { onData, onHostEvent, sendToMATLAB } from "./bridge.js"; /** Payload from the numbl script: a convex region (CCW polygon) plus the * hit-and-run samples drawn from it. Mirrors what hitandrun_sampler.m sends, * both as the initial `Data` and as the `data` event after "New region". */ interface HitAndRunData { type: "hitandrun"; region: Points; samples: Points; n: number; convex?: boolean; // false → non-convex (star) region } function isHitAndRunData(d: unknown): d is HitAndRunData { return ( !!d && typeof d === "object" && (d as HitAndRunData).type === "hitandrun" && !!(d as HitAndRunData).region && !!(d as HitAndRunData).samples ); } /** `samples` event payload: fresh points for the *same* region (after Resample * or moving the samples slider). */ interface SamplesEvent { x: number[]; y: number[]; n: number; } // Discrete sample-count choices; the slider indexes into the active array. const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000, 1000000]; // Non-convex sampling runs in the interpreter (no JIT), so cap it lower. const SAMPLE_CHOICES_NONCONVEX = [10, 100, 1000, 10000]; const DEFAULT_N = 10000; // Sampling movie: reveal points one at a time. Each frame shows the chord the // step samples along AND the point that landed on it, together. const MOVIE_STEP_MS = 750; const MOVIE_LAST_INDEX = 41; // animate sampling of points up to this index interface MovieState { step: number; // index of the point currently being sampled } /** The in-region segment(s) hit-and-run samples along: the line through * (px,py) with direction (dx,dy), intersected with the polygon. One segment * for a convex region, possibly several for a non-convex one. With `localOnly` * it keeps just the segment containing the current point (t = 0) — matching the * sampler's local-segment mode. Mirrors the sampler's geometry, so it * reproduces each step exactly. */ function regionSegments( region: Points, px: number, py: number, dx: number, dy: number, localOnly: boolean ): Segment[] { if (Math.hypot(dx, dy) < 1e-12) return []; const m = region.x.length; const ts: number[] = []; for (let i = 0; i < m; i++) { const j = (i + 1) % m; const ex = region.x[j] - region.x[i]; const ey = region.y[j] - region.y[i]; const denom = dy * ex - dx * ey; if (Math.abs(denom) < 1e-12) continue; const wx = region.x[i] - px; const wy = region.y[i] - py; const s = (dx * wy - dy * wx) / denom; // position along the edge if (s >= 0 && s < 1) ts.push((wy * ex - wx * ey) / denom); } ts.sort((a, b) => a - b); const segs: Segment[] = []; for (let k = 0; k < ts.length - 1; k++) { const tm = (ts[k] + ts[k + 1]) / 2; if (!pointInPolygon(region, px + tm * dx, py + tm * dy)) continue; // Local mode: keep only the in-region interval straddling t = 0. if (localOnly && !(ts[k] <= 0 && ts[k + 1] >= 0)) continue; segs.push({ x0: px + ts[k] * dx, y0: py + ts[k] * dy, x1: px + ts[k + 1] * dx, y1: py + ts[k + 1] * dy, }); } return segs; } function pointInPolygon(region: Points, x: number, y: number): boolean { const n = region.x.length; let inside = false; for (let i = 0, j = n - 1; i < n; j = i++) { const xi = region.x[i]; const yi = region.y[i]; const xj = region.x[j]; const yj = region.y[j]; if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; } const prefixPoints = (p: Points, k: number): Points => ({ x: p.x.slice(0, k), y: p.y.slice(0, k), }); export function App() { const [data, setData] = useState(null); const [n, setN] = useState(DEFAULT_N); const [busy, setBusy] = useState(false); const [movie, setMovie] = useState(null); // Non-convex sampling mode: false = sample the union of all in-region // segments the line makes; true = only the segment through the current point. const [local, setLocal] = useState(false); useEffect(() => { // Initial region + samples (script → page via `Data`). const offData = onData(d => { if (isHitAndRunData(d)) { setData(d); setN(d.n); setMovie(null); } }); // "New region": the script sends a full fresh payload. const offFull = onHostEvent("data", d => { if (isHitAndRunData(d)) { setData(d); setN(d.n); setBusy(false); setMovie(null); } }); // Resample (same region): only the points change. const offSamples = onHostEvent("samples", ev => { const s = ev as SamplesEvent; if (!s || !Array.isArray(s.x)) return; setData(prev => prev ? { ...prev, samples: { x: s.x, y: s.y }, n: s.n } : prev ); setBusy(false); setMovie(null); }); return () => { offData(); offFull(); offSamples(); }; }, []); // Movie clock: advance phase/step on a timer (a setTimeout chain — the effect // reruns whenever `movie` changes). Ends (→ full cloud) past the last index. useEffect(() => { if (!movie || !data) return; const lastIndex = Math.min(data.samples.x.length - 1, MOVIE_LAST_INDEX); const id = setTimeout(() => { setMovie(m => { if (!m) return m; const next = m.step + 1; return next > lastIndex ? null : { step: next }; }); }, MOVIE_STEP_MS); return () => clearTimeout(id); }, [movie, data]); const nonConvex = !!data && data.convex === false; const choices = nonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES; // `local` only applies to non-convex regions. const useLocal = nonConvex && local; // Re-draw `count` samples in the current region (script round-trip). The // sampling mode (`localMode`) can be overridden — the local checkbox passes // its new value directly, since the `local` state hasn't committed yet. const resample = (count: number, localMode: boolean = local) => { if (!data) return; setBusy(true); sendToMATLAB("resample", { n: count, x: data.region.x, y: data.region.y, convex: data.convex !== false, local: localMode, }); }; // Generate a brand new region (convex or not) and sample it. const newRegion = (count: number, convex: boolean) => { setBusy(true); sendToMATLAB("newRegion", { n: count, convex, local }); }; // Checkbox: switch region type. Clamp N to the active set's max first. const setNonConvex = (makeNonConvex: boolean) => { const c = makeNonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES; const clamped = Math.min(n, c[c.length - 1]); setN(clamped); newRegion(clamped, !makeNonConvex); }; // Checkbox: switch the non-convex sampling mode; resample the same region. const setLocalMode = (value: boolean) => { setLocal(value); resample(n, value); }; const toggleMovie = () => { if (movie) { setMovie(null); } else if (data && data.samples.x.length >= 3) { setMovie({ step: 2 }); } }; // Overlay for the current movie frame (settled points + segments + marks). let cloud: Points = data ? data.samples : { x: [], y: [] }; let segments: Segment[] | null = null; let from: Pt | null = null; let newPoint: Pt | null = null; if (movie && data) { const i = movie.step; // point being sampled const f = i - 1; // point the step starts from const { x, y } = data.samples; cloud = prefixPoints(data.samples, i); // settled points 0..i-1 from = { x: x[f], y: y[f] }; segments = regionSegments( data.region, x[f], y[f], x[i] - x[f], y[i] - y[f], useLocal ); newPoint = { x: x[i], y: y[i] }; } const canPlay = !!data && data.samples.x.length >= 3; return (
{data ? ( ) : (
Waiting for the region from the script…
)}
{nonConvex && ( )}
{busy ? "sampling…" : movie ? `movie · point ${movie.step + 1}` : `${data ? data.n.toLocaleString() : "—"} points`}
); } const rootStyle: CSSProperties = { position: "absolute", inset: 0, overflow: "hidden", background: "#ffffff", fontFamily: "system-ui, -apple-system, Arial, sans-serif", }; const waitingStyle: CSSProperties = { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "#94a3b8", }; const panelStyle: CSSProperties = { position: "absolute", top: 8, left: 8, width: 150, padding: "7px 9px", background: "rgba(255,255,255,0.9)", border: "1px solid #e2e8f0", borderRadius: 6, boxShadow: "0 1px 3px rgba(0,0,0,0.1)", color: "#0f172a", }; const labelStyle: CSSProperties = { display: "block", fontSize: 11, }; const checkLabelStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 5, fontSize: 11, marginTop: 8, cursor: "pointer", }; const subCheckLabelStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 5, fontSize: 10, marginTop: 4, marginLeft: 14, color: "#475569", cursor: "pointer", }; const sliderStyle: CSSProperties = { width: "100%", marginTop: 2, }; const btnStyle: CSSProperties = { flex: 1, padding: "3px 4px", fontSize: 10, whiteSpace: "nowrap", cursor: "pointer", background: "#f8fafc", border: "1px solid #cbd5e1", borderRadius: 5, color: "#0f172a", }; const playBtnStyle: CSSProperties = { width: "100%", marginTop: 6, padding: "4px 6px", fontSize: 10, cursor: "pointer", background: "#eff6ff", border: "1px solid #bfdbfe", borderRadius: 5, color: "#1e3a8a", };