1import {useEffect, useState, type CSSProperties} from 'react'
2import {RegionView, type Segment, type Pt} from './render/RegionView'
3import {useNetwork} from './useNetwork'
4import type {Points} from './types'
6// Discrete sample-count choices; the slider indexes into the active array.
7// Capped at 100k: every resample fans the samples out from the central peer to
8// every viewer over WebRTC (~800 KB as Float32 at 100k).
9const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000]
10// Non-convex sampling runs in the interpreter (no JIT), so cap it lower.
11const SAMPLE_CHOICES_NONCONVEX = [10, 100, 1000, 10000]
13/** The in-region segment(s) hit-and-run samples along: the line through
14 * (px,py) with direction (dx,dy), intersected with the polygon. One segment
15 * for a convex region, possibly several for a non-convex one. With `localOnly`
16 * it keeps just the segment containing the current point (t = 0) — matching the
17 * sampler's local-segment mode. Mirrors the sampler's geometry, so it
18 * reproduces each step exactly. */
19function regionSegments(
20 region: Points,
21 px: number,
22 py: number,
23 dx: number,
24 dy: number,
25 localOnly: boolean
26): Segment[] {
27 if (Math.hypot(dx, dy) < 1e-12) return []
28 const m = region.x.length
29 const ts: number[] = []
30 for (let i = 0; i < m; i++) {
31 const j = (i + 1) % m
32 const ex = region.x[j] - region.x[i]
33 const ey = region.y[j] - region.y[i]
34 const denom = dy * ex - dx * ey
35 if (Math.abs(denom) < 1e-12) continue
36 const wx = region.x[i] - px
37 const wy = region.y[i] - py
38 const s = (dx * wy - dy * wx) / denom // position along the edge
39 if (s >= 0 && s < 1) ts.push((wy * ex - wx * ey) / denom)
40 }
41 ts.sort((a, b) => a - b)
42 const segs: Segment[] = []
43 for (let k = 0; k < ts.length - 1; k++) {
44 const tm = (ts[k] + ts[k + 1]) / 2
45 if (!pointInPolygon(region, px + tm * dx, py + tm * dy)) continue
46 // Local mode: keep only the in-region interval straddling t = 0.
47 if (localOnly && !(ts[k] <= 0 && ts[k + 1] >= 0)) continue
48 segs.push({
49 x0: px + ts[k] * dx,
50 y0: py + ts[k] * dy,
51 x1: px + ts[k + 1] * dx,
52 y1: py + ts[k + 1] * dy
53 })
54 }
55 return segs
56}
58function pointInPolygon(region: Points, x: number, y: number): boolean {
59 const n = region.x.length
60 let inside = false
61 for (let i = 0, j = n - 1; i < n; j = i++) {
62 const xi = region.x[i]
63 const yi = region.y[i]
64 const xj = region.x[j]
65 const yj = region.y[j]
66 if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
67 inside = !inside
68 }
69 }
70 return inside
71}
73const prefixPoints = (p: Points, k: number): Points => ({
74 x: p.x.slice(0, k),
75 y: p.y.slice(0, k)
76})
78const short = (id: string) => id.slice(0, 8) + '…'
80export default function App() {
81 const {snapshot, dispatch} = useNetwork()
82 const {view, samples, samplesSynced, roster, amCentral, centralId, selfId} =
83 snapshot
84 const {params, region, busy, engine, engineError, movieStep} = view
86 // Slider position: local while dragging, following the shared value
87 // otherwise (a remote viewer may move it).
88 const [n, setN] = useState(params.n)
89 useEffect(() => setN(params.n), [params.n])
91 const nonConvex = !params.convex
92 const choices = nonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES
93 const useLocal = nonConvex && params.local
94 const moviePlaying = movieStep !== null
96 const controlsDisabled = !region || busy || moviePlaying || engine !== 'ready'
98 const resample = (count: number, localMode: boolean = params.local) => {
99 dispatch({op: 'resample', n: count, local: localMode})
100 }
102 const newRegion = (count: number, convex: boolean) => {
103 dispatch({op: 'newRegion', n: count, convex, local: params.local})
104 }
106 // Checkbox: switch region type. Clamp N to the active set's max first.
107 const setNonConvex = (makeNonConvex: boolean) => {
108 const c = makeNonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES
109 const clamped = Math.min(n, c[c.length - 1])
110 setN(clamped)
111 newRegion(clamped, !makeNonConvex)
112 }
114 // Overlay for the current movie frame (settled points + segments + marks).
115 // movieStep is shared state driven by the central peer's clock, so every
116 // viewer sees the same frame; the geometry is recomputed locally from the
117 // same samples, so it is identical everywhere.
118 let cloud: Points = samples ?? {x: [], y: []}
119 let segments: Segment[] | null = null
120 let from: Pt | null = null
121 let newPoint: Pt | null = null
122 if (moviePlaying && samples && region && movieStep >= 2) {
123 const i = Math.min(movieStep, samples.x.length - 1) // point being sampled
124 const f = i - 1 // point the step starts from
125 const {x, y} = samples
126 cloud = prefixPoints(samples, i) // settled points 0..i-1
127 from = {x: x[f], y: y[f]}
128 segments = regionSegments(region, x[f], y[f], x[i] - x[f], y[i] - y[f], useLocal)
129 newPoint = {x: x[i], y: y[i]}
130 }
132 const canPlay = !!samples && samples.x.length >= 3 && engine === 'ready'
134 const status = busy
135 ? 'sampling…'
136 : moviePlaying
137 ? `movie · point ${movieStep + 1}`
138 : !samplesSynced && samples
139 ? 'syncing samples…'
140 : `${params.n.toLocaleString()} points`
142 const engineLabel =
143 engine === 'ready'
144 ? amCentral
145 ? 'engine running here'
146 : 'engine on central peer'
147 : engine === 'starting'
148 ? 'starting engine…'
149 : engine === 'error'
150 ? 'engine failed'
151 : 'waiting for a central peer…'
153 const waitingMessage =
154 engine === 'starting'
155 ? 'The central peer is starting the sampling engine…'
156 : engine === 'error'
157 ? `Engine failed: ${engineError ?? 'unknown error'}`
158 : 'Connecting to the room…'
160 return (
161 <div style={rootStyle}>
162 {region && samples ? (
163 <RegionView
164 region={region}
165 samples={cloud}
166 segments={segments}
167 from={from}
168 newPoint={newPoint}
169 />
170 ) : (
171 <div style={waitingStyle}>{waitingMessage}</div>
172 )}
174 <div style={panelStyle}>
175 <label style={labelStyle}>
176 Samples: <b>{n.toLocaleString()}</b>
177 <input
178 type="range"
179 min={0}
180 max={choices.length - 1}
181 step={1}
182 value={Math.max(0, choices.indexOf(n))}
183 disabled={controlsDisabled}
184 // Drag updates the label live; the round-trip to the central
185 // peer's engine fires on release to avoid flooding it.
186 onChange={e => setN(choices[Number(e.target.value)])}
187 onPointerUp={e => resample(choices[Number(e.currentTarget.value)])}
188 onKeyUp={e => {
189 if (e.key.startsWith('Arrow')) {
190 resample(choices[Number(e.currentTarget.value)])
191 }
192 }}
193 style={sliderStyle}
194 />
195 </label>
197 <div style={{display: 'flex', gap: 6, marginTop: 6}}>
198 <button
199 style={btnStyle}
200 disabled={controlsDisabled}
201 onClick={() => resample(n)}
202 title="Draw a fresh set of samples in the same region (for everyone)"
203 >
204 Resample
205 </button>
206 <button
207 style={btnStyle}
208 disabled={busy || moviePlaying || engine !== 'ready'}
209 onClick={() => newRegion(n, !nonConvex)}
210 title="Generate a new region and sample it (for everyone)"
211 >
212 New region
213 </button>
214 </div>
216 <label style={checkLabelStyle}>
217 <input
218 type="checkbox"
219 checked={nonConvex}
220 disabled={controlsDisabled}
221 onChange={e => setNonConvex(e.target.checked)}
222 />
223 non-convex region
224 </label>
226 {nonConvex && (
227 <label
228 style={subCheckLabelStyle}
229 title="Sample only the segment through the current point instead of every segment the line crosses"
230 >
231 <input
232 type="checkbox"
233 checked={params.local}
234 disabled={controlsDisabled}
235 onChange={e => resample(n, e.target.checked)}
236 />
237 local segment only
238 </label>
239 )}
241 <button
242 style={playBtnStyle}
243 disabled={(!canPlay || busy) && !moviePlaying}
244 onClick={() => dispatch({op: 'movie', play: !moviePlaying})}
245 title="Animate the hit-and-run steps for every viewer at once"
246 >
247 {moviePlaying ? '■ Stop movie' : '▶ Play movie'}
248 </button>
250 <div style={{fontSize: 10, color: '#64748b', marginTop: 6}}>{status}</div>
251 </div>
253 <div style={presenceStyle}>
254 <div style={{fontWeight: 600, marginBottom: 2}}>
255 {roster.length} viewer{roster.length === 1 ? '' : 's'} · shared view
256 </div>
257 <div>
258 you: <code>{short(selfId)}</code>
259 {amCentral ? ' (central)' : ''}
260 </div>
261 <div>
262 central: <code>{centralId ? short(centralId) : '(none)'}</code>
263 </div>
264 <div style={{color: engine === 'error' ? '#b91c1c' : '#475569'}}>
265 {engineLabel}
266 </div>
267 {engine === 'error' && engineError && (
268 <div style={{color: '#b91c1c', marginTop: 2}}>{engineError}</div>
269 )}
270 </div>
271 </div>
272 )
273}
275const rootStyle: CSSProperties = {
276 position: 'absolute',
277 inset: 0,
278 overflow: 'hidden',
279 background: '#ffffff',
280 fontFamily: 'system-ui, -apple-system, Arial, sans-serif'
281}
283const waitingStyle: CSSProperties = {
284 position: 'absolute',
285 inset: 0,
286 display: 'flex',
287 alignItems: 'center',
288 justifyContent: 'center',
289 color: '#94a3b8',
290 padding: '0 2rem',
291 textAlign: 'center'
292}
294const panelStyle: CSSProperties = {
295 position: 'absolute',
296 top: 8,
297 left: 8,
298 width: 150,
299 padding: '7px 9px',
300 background: 'rgba(255,255,255,0.9)',
301 border: '1px solid #e2e8f0',
302 borderRadius: 6,
303 boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
304 color: '#0f172a'
305}
307const presenceStyle: CSSProperties = {
308 position: 'absolute',
309 bottom: 8,
310 left: 8,
311 padding: '6px 9px',
312 background: 'rgba(255,255,255,0.9)',
313 border: '1px solid #e2e8f0',
314 borderRadius: 6,
315 boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
316 color: '#0f172a',
317 fontSize: 10,
318 lineHeight: 1.5,
319 maxWidth: 260
320}
322const labelStyle: CSSProperties = {
323 display: 'block',
324 fontSize: 11
325}
327const checkLabelStyle: CSSProperties = {
328 display: 'flex',
329 alignItems: 'center',
330 gap: 5,
331 fontSize: 11,
332 marginTop: 8,
333 cursor: 'pointer'
334}
336const subCheckLabelStyle: CSSProperties = {
337 display: 'flex',
338 alignItems: 'center',
339 gap: 5,
340 fontSize: 10,
341 marginTop: 4,
342 marginLeft: 14,
343 color: '#475569',
344 cursor: 'pointer'
345}
347const sliderStyle: CSSProperties = {
348 width: '100%',
349 marginTop: 2
350}
352const btnStyle: CSSProperties = {
353 flex: 1,
354 padding: '3px 4px',
355 fontSize: 10,
356 whiteSpace: 'nowrap',
357 cursor: 'pointer',
358 background: '#f8fafc',
359 border: '1px solid #cbd5e1',
360 borderRadius: 5,
361 color: '#0f172a'
362}
364const playBtnStyle: CSSProperties = {
365 width: '100%',
366 marginTop: 6,
367 padding: '4px 6px',
368 fontSize: 10,
369 cursor: 'pointer',
370 background: '#eff6ff',
371 border: '1px solid #bfdbfe',
372 borderRadius: 5,
373 color: '#1e3a8a'
374}