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; } 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 this array. const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000, 1000000]; 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 chord that hit-and-run samples along: the line through (px,py) with * direction (dx,dy), clipped to the convex region. Same inward-half-plane * intersection the sampler uses, so it reproduces the step exactly. */ function regionChord( region: Points, px: number, py: number, dx: number, dy: number ): Segment | null { if (Math.hypot(dx, dy) < 1e-12) return null; let tmin = -Infinity; let tmax = Infinity; const m = region.x.length; 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]; // Inward normal of a CCW edge. inside: n·p >= n·v_i -> t·a >= rhs. const nx = -ey; const ny = ex; const a = nx * dx + ny * dy; const rhs = nx * region.x[i] + ny * region.y[i] - (nx * px + ny * py); if (a > 1e-12) tmin = Math.max(tmin, rhs / a); else if (a < -1e-12) tmax = Math.min(tmax, rhs / a); } if (!(tmax > tmin) || !isFinite(tmin) || !isFinite(tmax)) return null; return { x0: px + tmin * dx, y0: py + tmin * dy, x1: px + tmax * dx, y1: py + tmax * dy, }; } 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); 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]); // Re-draw `count` samples in the current region (script round-trip). const resample = (count: number) => { if (!data) return; setBusy(true); sendToMATLAB("resample", { n: count, x: data.region.x, y: data.region.y }); }; // Generate a brand new region and sample it (script round-trip). const newRegion = (count: number) => { setBusy(true); sendToMATLAB("newRegion", { n: count }); }; 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 + chord + highlights). let cloud: Points = data ? data.samples : { x: [], y: [] }; let chord: 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] }; chord = regionChord(data.region, x[f], y[f], x[i] - x[f], y[i] - y[f]); newPoint = { x: x[i], y: y[i] }; } const canPlay = !!data && data.samples.x.length >= 3; return (
{data ? ( ) : (
Waiting for the region from the script…
)}
{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 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", };