Initial commit: shared-view hit-and-run sampler
The hitandrun-interactive figure with commonview-style shared state:
peers form a WebRTC mesh (nostr discovery); the oldest peer runs the
unmodified MATLAB project via numbl in a web worker (uihtml intercepted
host-side, no iframe) and broadcasts params/region/samples/movie to all
viewers. Samples travel as chunked Float32 blobs behind signed SHA-256
headers; engine failures step the central peer down gracefully.
29 changed files+6353−0
.github/workflows/deploy.ymladded+41−0View file
@@ -0,0 +1,41 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+# Allow one concurrent deployment, cancel in-progress runs.
14+concurrency:
15+ group: pages
16+ cancel-in-progress: true
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - uses: actions/checkout@v4
23+ - uses: actions/setup-node@v4
24+ with:
25+ node-version: 20
26+ cache: npm
27+ - run: npm ci
28+ - run: npm run build
29+ - uses: actions/upload-pages-artifact@v3
30+ with:
31+ path: dist
32+
33+ deploy:
34+ needs: build
35+ runs-on: ubuntu-latest
36+ environment:
37+ name: github-pages
38+ url: ${{ steps.deployment.outputs.page_url }}
39+ steps:
40+ - id: deployment
41+ uses: actions/deploy-pages@v4
.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules
2+dist
3+*.tsbuildinfo
CLAUDE.mdadded+67−0View file
@@ -0,0 +1,67 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo. It combines the sibling projects
4+`commonview` (p2p shared state; central-peer authority) and
5+`hitandrun-interactive` (the figure + MATLAB sampler), so read those first —
6+this file only covers what is different here.
7+
8+## Architecture
9+
10+```
11+src/p2p/ identity, nostr discovery, WebRTC peer — ported from commonview
12+ network.ts the heart: shared ViewState + sample blobs + central election
13+ blob.ts Float32 codec + chunk reassembly for the sample sets
14+src/engine/ the numbl runtime, run ONLY by the central peer
15+ numbl.worker.ts executeCode against an in-memory VFS; uihtml intercepted
16+ engine.ts host wrapper: start/resample/newRegion with timeouts
17+ project.ts ?raw imports of matlab/*.m (verbatim from hitandrun-interactive)
18+src/App.tsx the figure UI (controls dispatch p2p commands, not sendToMATLAB)
19+src/render/ RegionView canvas — verbatim from hitandrun-interactive
20+matlab/ the .m files — DO NOT EDIT here; they are copies (see below)
21+```
22+
23+## Key design decisions
24+
25+- **Two-part state.** The small JSON "view" (params, region, busy/engine
26+ status, movie step, `samplesId`) is broadcast on every change. The samples
27+ are a Float32 blob announced by a signed header (`{t:'blob', id, bytes,
28+ hash}`) followed by raw 64 KB binary chunks. All sends to a given peer go
29+ through a per-connection promise chain, so on the ordered data channel a
30+ blob always lands before the view that references it.
31+- **Engine only on central.** `Network` takes an engine factory; it boots one
32+ when it becomes central (after a 4 s discovery grace period at startup) and
33+ disposes it on resignation. The initial run's region/samples are adopted
34+ only if the room has no state yet — a failover central keeps the inherited
35+ region (the script is stateless; resample requests carry the region).
36+- **Engine failure = step-down.** Boot errors, callback errors, and timeouts
37+ (60 s compute / 120 s start) mark the peer `engineFailed`; hellos carry the
38+ flag and the election skips failed peers (falling back to oldest-overall so
39+ the room never loses its state authority). The flag clears only on a
40+ successful later boot or a reload.
41+- **Shared movie.** The central peer ticks `movieStep` every 750 ms and
42+ broadcasts; the chord geometry is recomputed per-viewer from the same
43+ Float32 samples, so every frame is identical everywhere.
44+- **numbl comes from npm** (`>= 0.4.8`, which added the browser-embedding
45+ exports: `VirtualFileSystem`, `BrowserFileIOAdapter`, `BrowserSystemAdapter`,
46+ `UihtmlSession`). No COOP/COEP / SharedArrayBuffer needed: the script never
47+ calls `input()`, and no qhull/convhull (make_region avoids convhull
48+ deliberately).
49+
50+## The matlab/ copies
51+
52+`matlab/**/*.m` are verbatim copies from `hitandrun-interactive` (plus a
53+placeholder served as `app/dist/index.html`, which the sampler `fileread`s for
54+its uihtml HTMLSource — never rendered). If the upstream figure protocol
55+changes (`resample`/`newRegion` events, payload shapes), re-copy the files and
56+revisit `src/engine/engine.ts`.
57+
58+## Testing
59+
60+- Headless engine check (no browser): run the script + uihtml round-trip in
61+ Node against the installed numbl — see the "engine-test" pattern in git
62+ history / ask the user. `executeCode` is platform-agnostic.
63+- Full check: `npm run dev`, open in two different browser **profiles** (same
64+ profile = same localStorage key = same peer). Kill the central tab to test
65+ failover.
66+- `npm run build` type-checks (`tsc -b`) and bundles; the numbl worker chunk
67+ is ~1.5 MB.
README.mdadded+69−0View file
@@ -0,0 +1,69 @@
1+# hitandrun-commonview
2+
3+One interactive figure, one shared view. This app shows the
4+[hitandrun-interactive](https://github.com/concept-collection/hitandrun-interactive)
5+hit-and-run sampling figure — just the figure, no source code — and keeps it
6+**identical for everyone who has the page open**, in the style of
7+[commonview](https://github.com/concept-collection/commonview): peers discover
8+each other over nostr relays and form a WebRTC full mesh; the oldest peer is
9+the **central** peer and owns the authoritative state.
10+
11+The twist over commonview's counter: the shared state is a live MATLAB
12+computation. The central peer — and only the central peer — runs
13+[numbl](https://numbl.org) (a MATLAB-compatible runtime) in a web worker,
14+executing the *unmodified* `hitandrun_demo.m` from hitandrun-interactive. The
15+figure's uihtml bridge is intercepted host-side (no iframe): control changes
16+from **any** viewer are forwarded to the central peer, which feeds them to the
17+script and broadcasts the results — parameter selections, the region, and the
18+samples — to every viewer.
19+
20+## What is shared
21+
22+- **Parameters**: sample count, convex/non-convex region, local-segment mode.
23+- **The region and the samples**: samples travel as a Float32 blob (up to
24+ 100,000 points ≈ 800 KB), streamed in 64 KB chunks over the data channel and
25+ authenticated by a SHA-256 in a signed header. The JSON "view" message that
26+ follows it references the blob by id.
27+- **The sampling movie**: the central peer drives the animation clock, so every
28+ viewer watches the same step at the same time.
29+
30+## Graceful failover
31+
32+- Every message is a signed envelope (schnorr over the peer's key, which *is*
33+ its ID); state is only trusted from the current central peer.
34+- Every peer keeps the latest sample blob, so whichever peer becomes central
35+ can serve it to late joiners.
36+- If the **central peer leaves**, the next-oldest peer becomes central, boots
37+ its own engine (viewers see "starting engine…"), and continues from the
38+ last-known state — the script is stateless (the region rides along with each
39+ resample request), so any peer's engine can pick up where the last one left
40+ off.
41+- If the central peer's **engine fails to boot or to compute** (a timeout
42+ counts), it announces the failure and steps down; the election skips
43+ engine-failed peers, and the next-oldest healthy peer takes over.
44+- State is never persisted: once all peers leave, the room resets.
45+
46+## Run
47+
48+```
49+npm install # requires numbl >= 0.4.8 on npm (browser-embedding exports)
50+npm run dev
51+```
52+
53+Open the printed URL in **two different browsers or profiles** (two tabs in the
54+same profile share the same localStorage key, so they'd be the *same* peer).
55+Drag the samples slider or press "New region" in either window and watch both
56+update; close the central window and watch the other take over.
57+
58+## How the engine embedding works
59+
60+`src/engine/numbl.worker.ts` runs `executeCode` from the `numbl` npm package
61+against an in-memory filesystem holding the `.m` files (verbatim copies from
62+hitandrun-interactive, under [matlab/](matlab/)). The script's
63+`uihtml(...)` call surfaces as a plot instruction carrying the component id and
64+initial Data; `sendEventToHTMLSource` calls surface via the `onHtmlSourceEvent`
65+hook; and events from the app re-enter the still-live interpreter through the
66+`UihtmlSession` returned by `executeCode` — firing the script's
67+`HTMLEventReceivedFcn` exactly as if the figure page had sent them. The HTML
68+the script loads for the figure is replaced by a one-line placeholder; nothing
69+is ever rendered from the worker.
index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Hit-and-run · CommonView</title>
7+ </head>
8+ <body style="margin: 0">
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
matlab/helpers/hit_and_run.madded+67−0View file
@@ -0,0 +1,67 @@
1+function [sx, sy] = hit_and_run(vx, vy, N, nBurn)
2+%HIT_AND_RUN Draw N uniform samples from a convex polygon by hit-and-run.
3+% [SX, SY] = HIT_AND_RUN(VX, VY, N, NBURN) draws N samples (after NBURN
4+% burn-in steps) from the uniform distribution on the convex polygon with
5+% counterclockwise vertices (VX, VY).
6+%
7+% Represents the polygon as a set of half-planes (inside iff n_i . p >= c_i
8+% for every edge i). From the current interior point, pick a random
9+% direction, intersect the line with every half-plane to get the chord
10+% [tmin, tmax], then jump to a uniform-random point on it.
11+%
12+
13+% Guard: this hot loop must JS-JIT-compile (it's ~30x slower in the
14+% interpreter).
15+%!numbl:assert_jit
16+
17+nv = numel(vx);
18+
19+% Inward half-plane form for each edge: the left normal of a CCW edge points
20+% into the interior.
21+nx = zeros(nv, 1);
22+ny = zeros(nv, 1);
23+c = zeros(nv, 1);
24+for i = 1:nv
25+ j = mod(i, nv) + 1;
26+ ex = vx(j) - vx(i);
27+ ey = vy(j) - vy(i);
28+ len = hypot(ex, ey);
29+ n1 = -ey / len;
30+ n2 = ex / len;
31+ nx(i) = n1;
32+ ny(i) = n2;
33+ c(i) = n1 * vx(i) + n2 * vy(i);
34+end
35+
36+% Start at the centroid (always interior for a convex polygon).
37+px = mean(vx);
38+py = mean(vy);
39+
40+total = nBurn + N;
41+sx = zeros(N, 1);
42+sy = zeros(N, 1);
43+for s = 1:total
44+ th = 2 * pi * rand;
45+ dx = cos(th);
46+ dy = sin(th);
47+ % Chord [tmin, tmax] of the line p + t*d that stays inside the region.
48+ tmin = -inf;
49+ tmax = inf;
50+ for i = 1:nv
51+ a = nx(i) * dx + ny(i) * dy;
52+ rhs = c(i) - (nx(i) * px + ny(i) * py); % <= 0 since p is interior
53+ if a > 1e-12
54+ tmin = max(tmin, rhs / a);
55+ elseif a < -1e-12
56+ tmax = min(tmax, rhs / a);
57+ end
58+ end
59+ t = tmin + (tmax - tmin) * rand;
60+ px = px + t * dx;
61+ py = py + t * dy;
62+ if s > nBurn
63+ sx(s - nBurn) = px;
64+ sy(s - nBurn) = py;
65+ end
66+end
67+end
matlab/helpers/hit_and_run_general.madded+138−0View file
@@ -0,0 +1,138 @@
1+function [sx, sy] = hit_and_run_general(vx, vy, N, nBurn, local)
2+%HIT_AND_RUN_GENERAL Hit-and-run for an arbitrary simple polygon (convex or
3+% non-convex). Along each random line it finds *every* crossing of the polygon
4+% boundary, so concavities are handled correctly (unlike the convex-only chord
5+% in hit_and_run.m). The region need not be star-shaped or contain the origin,
6+% so it starts from an interior point found by rejection.
7+%
8+% LOCAL (default false) selects how the step samples along that line:
9+% false — sample uniformly across the *union* of all in-region segments the
10+% line makes (standard hit-and-run; uniform on the whole region).
11+% true — sample only within the single in-region segment that contains the
12+% current point (a local walk that can't jump across a concavity;
13+% it does not sample the region uniformly).
14+%
15+% This runs in the numbl interpreter (sort + point-in-polygon tests don't
16+% JIT), so it's used only for the non-convex demo at modest N.
17+if nargin < 5 || isempty(local)
18+ local = false;
19+end
20+nv = numel(vx);
21+[px, py] = interior_seed(vx, vy);
22+total = nBurn + N;
23+sx = zeros(N, 1);
24+sy = zeros(N, 1);
25+
26+for step = 1:total
27+ th = 2 * pi * rand;
28+ dx = cos(th);
29+ dy = sin(th);
30+
31+ % All parameters t where the line p + t*d crosses the polygon boundary.
32+ ts = zeros(1, nv);
33+ m = 0;
34+ for i = 1:nv
35+ j = mod(i, nv) + 1;
36+ ex = vx(j) - vx(i);
37+ ey = vy(j) - vy(i);
38+ denom = dy * ex - dx * ey;
39+ if abs(denom) < 1e-12
40+ continue
41+ end
42+ wx = vx(i) - px;
43+ wy = vy(i) - py;
44+ sParam = (dx * wy - dy * wx) / denom; % position along the edge
45+ if sParam >= 0 && sParam < 1
46+ m = m + 1;
47+ ts(m) = (wy * ex - wx * ey) / denom; % position along the line
48+ end
49+ end
50+ if m < 2
51+ continue
52+ end
53+ ts = sort(ts(1:m));
54+
55+ if local
56+ % Local segment: the in-region interval straddling t = 0, i.e. bounded
57+ % by the nearest crossing on each side of the current (interior) point.
58+ tlo = -inf;
59+ thi = inf;
60+ for k = 1:m
61+ if ts(k) <= 0 && ts(k) > tlo
62+ tlo = ts(k);
63+ elseif ts(k) >= 0 && ts(k) < thi
64+ thi = ts(k);
65+ end
66+ end
67+ if ~isfinite(tlo) || ~isfinite(thi) || thi <= tlo
68+ continue
69+ end
70+ tpick = tlo + (thi - tlo) * rand;
71+ else
72+ % In-region intervals are consecutive crossings whose midpoint is
73+ % inside; sample uniformly across their union.
74+ totalLen = 0;
75+ for k = 1:m - 1
76+ tm = (ts(k) + ts(k + 1)) / 2;
77+ if point_in_poly(px + tm * dx, py + tm * dy, vx, vy)
78+ totalLen = totalLen + (ts(k + 1) - ts(k));
79+ end
80+ end
81+ if totalLen <= 0
82+ continue
83+ end
84+ u = totalLen * rand;
85+ tpick = 0;
86+ for k = 1:m - 1
87+ tm = (ts(k) + ts(k + 1)) / 2;
88+ if point_in_poly(px + tm * dx, py + tm * dy, vx, vy)
89+ len = ts(k + 1) - ts(k);
90+ if u <= len
91+ tpick = ts(k) + u;
92+ break
93+ end
94+ u = u - len;
95+ end
96+ end
97+ end
98+ px = px + tpick * dx;
99+ py = py + tpick * dy;
100+
101+ if step > nBurn
102+ sx(step - nBurn) = px;
103+ sy(step - nBurn) = py;
104+ end
105+end
106+end
107+
108+function [px, py] = interior_seed(vx, vy)
109+%INTERIOR_SEED A point strictly inside polygon (VX, VY), by rejection sampling
110+% its bounding box. The polygon fills a large fraction of the box, so this
111+% lands quickly; the vertex mean is a fallback if it somehow doesn't.
112+minx = min(vx); maxx = max(vx);
113+miny = min(vy); maxy = max(vy);
114+px = mean(vx); py = mean(vy);
115+for t = 1:5000
116+ qx = minx + (maxx - minx) * rand;
117+ qy = miny + (maxy - miny) * rand;
118+ if point_in_poly(qx, qy, vx, vy)
119+ px = qx;
120+ py = qy;
121+ return
122+ end
123+end
124+end
125+
126+function inside = point_in_poly(x, y, vx, vy)
127+%POINT_IN_POLY Ray-casting test for a point against polygon (VX, VY).
128+n = numel(vx);
129+inside = false;
130+j = n;
131+for i = 1:n
132+ if ((vy(i) > y) ~= (vy(j) > y)) && ...
133+ (x < (vx(j) - vx(i)) * (y - vy(i)) / (vy(j) - vy(i)) + vx(i))
134+ inside = ~inside;
135+ end
136+ j = i;
137+end
138+end
matlab/helpers/make_region.madded+84−0View file
@@ -0,0 +1,84 @@
1+function [vx, vy] = make_region(convex)
2+%MAKE_REGION Random 2D sampling region, returned as CCW vertices (VX, VY).
3+% MAKE_REGION(true) — a convex polygon (vertices on a random ellipse).
4+% MAKE_REGION(false) — a non-convex "dumbbell": two convex disks joined by a
5+% narrow tube. It is not star-shaped and need not contain
6+% the origin — the sampler finds its own interior start.
7+if nargin < 1 || isempty(convex)
8+ convex = true;
9+end
10+
11+if convex
12+ [vx, vy] = convex_region();
13+else
14+ [vx, vy] = nonconvex_region();
15+end
16+
17+% CCW so the interior is to the left of each edge.
18+if signed_area(vx, vy) < 0
19+ vx = vx(end:-1:1);
20+ vy = vy(end:-1:1);
21+end
22+end
23+
24+function [vx, vy] = convex_region()
25+% Vertices at increasing angles on an anisotropic, randomly rotated ellipse.
26+% Points taken in angular order on the ellipse are always in convex position,
27+% so the polygon is convex by construction — no convhull backend needed (the
28+% browser worker may not have finished loading it when the figure view
29+% auto-runs the script). Even angular slots + bounded jitter keep the angles
30+% ordered and the edges non-degenerate while still random.
31+m = 6 + randi(4); % 7..10 vertices
32+slot = 2 * pi / m;
33+ang = (0:m - 1).' * slot + (rand(m, 1) - 0.5) * slot * 0.8;
34+ex = 1.4 * cos(ang); % on an ellipse (anisotropic)
35+ey = 1.0 * sin(ang);
36+phi = 2 * pi * rand; % random orientation
37+vx = cos(phi) * ex - sin(phi) * ey;
38+vy = sin(phi) * ex + cos(phi) * ey;
39+end
40+
41+function [vx, vy] = nonconvex_region()
42+% A "dumbbell": two convex disks (radius r, centers at x = +/-cx) joined by a
43+% narrow tube of half-width w < r. This is non-convex and non-star-shaped — the
44+% tube occludes each bulb from the other, so no single point sees the whole
45+% region — which is what separates the two sampling modes (the local walk mostly
46+% stays in one bulb, escaping only along a line down the tube; the union walk
47+% also hops between bulbs along a line that clips both without the tube). Built
48+% as one CCW loop: the outer (major) arc of each disk, with the straight tube
49+% edges closing the gaps between the arc ends.
50+r = 0.52 + 0.12 * rand; % bulb radius
51+cx = 0.82 + 0.24 * rand; % half-distance between bulb centers (> r: disjoint)
52+w = 0.07 + 0.06 * rand; % tube half-width (a narrow neck)
53+w = min(w, 0.5 * r); % keep the tube clearly narrower than a bulb
54+gam = asin(w / r); % half-angle each bulb's tube opening subtends
55+K = 16; % points per bulb arc
56+
57+% Right bulb: major arc from the bottom opening CCW round to the top opening.
58+tR = linspace(pi + gam, 3 * pi - gam, K);
59+rxx = cx + r * cos(tR);
60+ryy = r * sin(tR);
61+% Left bulb: major arc from the top opening CCW round to the bottom opening.
62+tL = linspace(gam, 2 * pi - gam, K);
63+lxx = -cx + r * cos(tL);
64+lyy = r * sin(tL);
65+
66+% Concatenate; the jumps arc-end -> next-arc-start are the straight tube edges.
67+px = [rxx, lxx].';
68+py = [ryy, lyy].';
69+
70+phi = 2 * pi * rand; % random overall orientation
71+vx = cos(phi) * px - sin(phi) * py;
72+vy = sin(phi) * px + cos(phi) * py;
73+end
74+
75+function A = signed_area(vx, vy)
76+%SIGNED_AREA Shoelace area; positive when the vertices run counterclockwise.
77+n = numel(vx);
78+A = 0;
79+for i = 1:n
80+ j = mod(i, n) + 1;
81+ A = A + (vx(i) * vy(j) - vx(j) * vy(i));
82+end
83+A = A / 2;
84+end
matlab/hitandrun_demo.madded+17−0View file
@@ -0,0 +1,17 @@
1+% Hit-and-run MCMC sampling of a 2D convex region.
2+%
3+% A random convex polygon is generated, then N points are drawn from the
4+% uniform distribution on it with the hit-and-run algorithm: from the current
5+% point, pick a random direction, find the chord where that line crosses the
6+% region, and jump to a uniform-random point on the chord. Repeat. The figure
7+% shows the region outline and the resulting samples.
8+%
9+% The region + sampling functions live in helpers/. `addpath` must be the
10+% first statement (numbl resolves the driver's search path before running),
11+% so it comes before everything else.
12+
13+addpath('helpers'); % make_region, hit_and_run
14+
15+rng(1); % reproducible region + samples
16+
17+hitandrun_sampler(10000); % matches the figure's default samples control
matlab/hitandrun_sampler.madded+102−0View file
@@ -0,0 +1,102 @@
1+function hitandrun_sampler(N)
2+%HITANDRUN_SAMPLER Interactive figure: hit-and-run sampling of a 2D convex region.
3+% HITANDRUN_SAMPLER(N) generates a random convex polygon, draws N samples
4+% from the uniform distribution on it using the hit-and-run algorithm, and
5+% opens a figure that shows the region and the samples.
6+%
7+% This holds all the wiring: it builds the region, runs the sampler, loads
8+% the prebuilt figure app, and sends region + samples to it (script ->
9+% figure). It also re-samples on request (figure -> script) so the controls
10+% in the figure drive the algorithm.
11+%
12+% The region/sampling algorithm lives in helpers/ (make_region, hit_and_run);
13+% the end-user script (hitandrun_demo.m) puts that folder on the path with
14+% `addpath('helpers')` and calls this. Run hitandrun_demo.m, not this file
15+% directly, so the path is set up.
16+%
17+% The script is a stateless sampling service: the figure owns the current
18+% region and sends it back with each resample request, so there is no
19+% server-side state to keep in sync across callbacks.
20+
21+if nargin < 1 || isempty(N)
22+ N = 10000;
23+end
24+
25+% Build a region (convex by default) and draw N uniform samples from it.
26+convex = true;
27+[vx, vy] = make_region(convex);
28+[sx, sy] = sample_region(vx, vy, N, convex, false);
29+
30+% The figure app is the prebuilt single-file page, relative to the project root
31+% (the current working directory when a top-level script is run).
32+html = fileread(fullfile('app', 'dist', 'index.html'));
33+
34+fig = figure;
35+gl = uigridlayout(fig, [1 1], 'Padding', [0 0 0 0], ...
36+ 'RowHeight', {'1x'}, 'ColumnWidth', {'1x'});
37+uihtml(gl, 'HTMLSource', html, 'Data', pack_data(vx, vy, sx, sy, N, convex), ...
38+ 'HTMLEventReceivedFcn', @(src, ev) on_event(src, ev));
39+end
40+
41+function on_event(src, ev)
42+% Figure -> script. Two requests the controls send (both carry the region type
43+% `convex` and, for non-convex regions, the `local` sampling mode):
44+% 'resample' {n, x, y, convex, local} -> draw n fresh samples in the given
45+% region; reply with a 'samples' event.
46+% 'newRegion' {n, convex, local} -> build a new region (convex or
47+% non-convex), draw n samples; reply with a
48+% 'data' event.
49+d = ev.HTMLEventData;
50+n = 10000;
51+convex = true;
52+local = false;
53+if isstruct(d)
54+ if isfield(d, 'n')
55+ n = max(1, round(d.n));
56+ end
57+ if isfield(d, 'convex')
58+ convex = logical(d.convex);
59+ end
60+ if isfield(d, 'local')
61+ local = logical(d.local);
62+ end
63+end
64+switch ev.HTMLEventName
65+ case 'resample'
66+ vx = d.x(:);
67+ vy = d.y(:);
68+ [sx, sy] = sample_region(vx, vy, n, convex, local);
69+ sendEventToHTMLSource(src, 'samples', pack_samples(sx, sy, n));
70+ case 'newRegion'
71+ [vx, vy] = make_region(convex);
72+ [sx, sy] = sample_region(vx, vy, n, convex, local);
73+ sendEventToHTMLSource(src, 'data', pack_data(vx, vy, sx, sy, n, convex));
74+end
75+end
76+
77+function [sx, sy] = sample_region(vx, vy, n, convex, local)
78+% Convex regions use the fast JIT-compiled chord sampler; non-convex regions
79+% use the general sampler, in union (default) or local-segment mode.
80+tic;
81+if convex
82+ [sx, sy] = hit_and_run(vx, vy, n, 50);
83+else
84+ [sx, sy] = hit_and_run_general(vx, vy, n, 50, local);
85+end
86+fprintf('sample_region: N=%d convex=%d local=%d in %.3f s\n', n, convex, local, toc);
87+end
88+
89+function data = pack_data(vx, vy, sx, sy, N, convex)
90+%PACK_DATA Full payload (region + samples) sent once when the figure opens.
91+data = struct();
92+data.type = 'hitandrun';
93+data.region = struct('x', vx(:).', 'y', vy(:).');
94+data.samples = struct('x', sx(:).', 'y', sy(:).');
95+data.n = N;
96+data.convex = convex;
97+end
98+
99+function s = pack_samples(sx, sy, N)
100+%PACK_SAMPLES Just the samples, sent on a resample (region is unchanged).
101+s = struct('x', sx(:).', 'y', sy(:).', 'n', N);
102+end
package-lock.jsonadded+3485−0View file
This diff is 3,490 lines long and is not shown.
package.jsonadded+24−0View file
@@ -0,0 +1,24 @@
1+{
2+ "name": "hitandrun-commonview",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "preview": "vite preview"
10+ },
11+ "dependencies": {
12+ "@noble/secp256k1": "^3.1.0",
13+ "numbl": "^0.4.8",
14+ "react": "^18.3.1",
15+ "react-dom": "^18.3.1"
16+ },
17+ "devDependencies": {
18+ "@types/react": "^18.3.12",
19+ "@types/react-dom": "^18.3.1",
20+ "@vitejs/plugin-react": "^4.3.4",
21+ "typescript": "^5.6.3",
22+ "vite": "^5.4.11"
23+ }
24+}
src/App.tsxadded+374−0View file
@@ -0,0 +1,374 @@
1+import {useEffect, useState, type CSSProperties} from 'react'
2+import {RegionView, type Segment, type Pt} from './render/RegionView'
3+import {useNetwork} from './useNetwork'
4+import type {Points} from './types'
5+
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).
9+const SAMPLE_CHOICES = [10, 100, 1000, 10000, 100000]
10+// Non-convex sampling runs in the interpreter (no JIT), so cap it lower.
11+const SAMPLE_CHOICES_NONCONVEX = [10, 100, 1000, 10000]
12+
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. */
19+function 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+}
57+
58+function 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+}
72+
73+const prefixPoints = (p: Points, k: number): Points => ({
74+ x: p.x.slice(0, k),
75+ y: p.y.slice(0, k)
76+})
77+
78+const short = (id: string) => id.slice(0, 8) + '…'
79+
80+export 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
85+
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])
90+
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
95+
96+ const controlsDisabled = !region || busy || moviePlaying || engine !== 'ready'
97+
98+ const resample = (count: number, localMode: boolean = params.local) => {
99+ dispatch({op: 'resample', n: count, local: localMode})
100+ }
101+
102+ const newRegion = (count: number, convex: boolean) => {
103+ dispatch({op: 'newRegion', n: count, convex, local: params.local})
104+ }
105+
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+ }
113+
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+ }
131+
132+ const canPlay = !!samples && samples.x.length >= 3 && engine === 'ready'
133+
134+ const status = busy
135+ ? 'sampling…'
136+ : moviePlaying
137+ ? `movie · point ${movieStep + 1}`
138+ : !samplesSynced && samples
139+ ? 'syncing samples…'
140+ : `${params.n.toLocaleString()} points`
141+
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…'
152+
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…'
159+
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+ )}
173+
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>
196+
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>
215+
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>
225+
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+ )}
240+
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>
249+
250+ <div style={{fontSize: 10, color: '#64748b', marginTop: 6}}>{status}</div>
251+ </div>
252+
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+}
274+
275+const rootStyle: CSSProperties = {
276+ position: 'absolute',
277+ inset: 0,
278+ overflow: 'hidden',
279+ background: '#ffffff',
280+ fontFamily: 'system-ui, -apple-system, Arial, sans-serif'
281+}
282+
283+const 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+}
293+
294+const 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+}
306+
307+const 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+}
321+
322+const labelStyle: CSSProperties = {
323+ display: 'block',
324+ fontSize: 11
325+}
326+
327+const checkLabelStyle: CSSProperties = {
328+ display: 'flex',
329+ alignItems: 'center',
330+ gap: 5,
331+ fontSize: 11,
332+ marginTop: 8,
333+ cursor: 'pointer'
334+}
335+
336+const 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+}
346+
347+const sliderStyle: CSSProperties = {
348+ width: '100%',
349+ marginTop: 2
350+}
351+
352+const 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+}
363+
364+const 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+}
src/engine/engine.tsadded+246−0View file
@@ -0,0 +1,246 @@
1+// Host-side wrapper around the numbl worker. Only the CENTRAL peer creates
2+// one; everyone else just receives the results through the p2p network.
3+//
4+// The wrapper runs hitandrun_demo.m once (which opens the uihtml "figure" —
5+// intercepted here, never rendered) and then serves compute requests by
6+// speaking the figure's own event protocol to the script:
7+// resample {n, x, y, convex, local} -> 'samples' event {x, y, n}
8+// newRegion {n, convex, local} -> 'data' event {region, samples, n, convex}
9+// Timeouts surface as EngineError so the network layer can step down and let
10+// another peer take over.
11+
12+import type {Points, Params} from '../types'
13+import {PROJECT_FILES, MAIN_FILE} from './project'
14+import type {ToWorker, FromWorker} from './protocol'
15+
16+const START_TIMEOUT_MS = 120_000
17+const COMPUTE_TIMEOUT_MS = 60_000
18+
19+export class EngineError extends Error {}
20+
21+export interface EngineInit {
22+ region: Points
23+ samples: Points
24+ n: number
25+ convex: boolean
26+}
27+
28+interface HitAndRunData {
29+ type: string
30+ region: Points
31+ samples: Points
32+ n: number
33+ convex?: boolean
34+}
35+
36+interface SamplesEvent {
37+ x: number[]
38+ y: number[]
39+ n: number
40+}
41+
42+// jsonencode collapses 1-element vectors to scalars; normalize.
43+const asArray = (v: unknown): number[] =>
44+ Array.isArray(v) ? (v as number[]) : typeof v === 'number' ? [v] : []
45+
46+const asPoints = (p: {x?: unknown; y?: unknown} | undefined): Points => ({
47+ x: asArray(p?.x),
48+ y: asArray(p?.y)
49+})
50+
51+export class Engine {
52+ private worker: Worker | null = null
53+ private compId: string | null = null
54+ private initialData: HitAndRunData | null = null
55+ private runDone = false
56+ private disposed = false
57+
58+ // One request at a time; the network layer serializes computes.
59+ private waiter: {
60+ event: string
61+ resolve: (data: unknown) => void
62+ reject: (err: Error) => void
63+ } | null = null
64+ private startWaiter: {
65+ resolve: (init: EngineInit) => void
66+ reject: (err: Error) => void
67+ } | null = null
68+
69+ /** Boot the worker, run the script, resolve with the initial region+samples. */
70+ start(): Promise<EngineInit> {
71+ if (this.worker) throw new EngineError('engine already started')
72+ this.worker = new Worker(new URL('./numbl.worker.ts', import.meta.url), {
73+ type: 'module'
74+ })
75+ this.worker.onmessage = (e: MessageEvent<FromWorker>) =>
76+ this.handleMessage(e.data)
77+ this.worker.onerror = e => {
78+ this.fail(new EngineError(`worker error: ${e.message || 'unknown'}`))
79+ }
80+ this.post({type: 'run', files: PROJECT_FILES, mainFileName: MAIN_FILE})
81+
82+ return new Promise<EngineInit>((resolve, reject) => {
83+ this.startWaiter = {resolve, reject}
84+ this.armTimeout(START_TIMEOUT_MS, 'engine start timed out')
85+ })
86+ }
87+
88+ /** Draw n fresh samples in the given (current) region. */
89+ async resample(req: {
90+ params: Params
91+ region: Points
92+ }): Promise<{samples: Points; n: number}> {
93+ const {params, region} = req
94+ const data = await this.request(
95+ 'resample',
96+ {n: params.n, x: region.x, y: region.y, convex: params.convex, local: params.local},
97+ 'samples'
98+ )
99+ const s = data as SamplesEvent
100+ return {
101+ samples: {x: asArray(s.x), y: asArray(s.y)},
102+ n: typeof s.n === 'number' ? s.n : params.n
103+ }
104+ }
105+
106+ /** Build a brand-new region (convex or not) and sample it. */
107+ async newRegion(req: {params: Params}): Promise<EngineInit> {
108+ const {params} = req
109+ const data = await this.request(
110+ 'newRegion',
111+ {n: params.n, convex: params.convex, local: params.local},
112+ 'data'
113+ )
114+ const d = data as HitAndRunData
115+ return {
116+ region: asPoints(d.region),
117+ samples: asPoints(d.samples),
118+ n: typeof d.n === 'number' ? d.n : params.n,
119+ convex: d.convex !== false
120+ }
121+ }
122+
123+ dispose(): void {
124+ if (this.disposed) return
125+ this.disposed = true
126+ this.fail(new EngineError('engine disposed'))
127+ }
128+
129+ // ---- internals ---------------------------------------------------------
130+
131+ private timeoutId: ReturnType<typeof setTimeout> | null = null
132+
133+ private armTimeout(ms: number, message: string) {
134+ this.clearTimeout()
135+ this.timeoutId = setTimeout(() => this.fail(new EngineError(message)), ms)
136+ }
137+
138+ private clearTimeout() {
139+ if (this.timeoutId !== null) clearTimeout(this.timeoutId)
140+ this.timeoutId = null
141+ }
142+
143+ private post(msg: ToWorker) {
144+ this.worker?.postMessage(msg)
145+ }
146+
147+ private request(
148+ name: 'resample' | 'newRegion',
149+ payload: unknown,
150+ expectEvent: 'samples' | 'data'
151+ ): Promise<unknown> {
152+ if (!this.worker || !this.runDone || !this.compId) {
153+ return Promise.reject(new EngineError('engine not ready'))
154+ }
155+ if (this.waiter) {
156+ return Promise.reject(new EngineError('engine busy'))
157+ }
158+ this.post({type: 'event', compId: this.compId, name, data: payload})
159+ return new Promise<unknown>((resolve, reject) => {
160+ this.waiter = {event: expectEvent, resolve, reject}
161+ this.armTimeout(COMPUTE_TIMEOUT_MS, `'${name}' timed out`)
162+ })
163+ }
164+
165+ /** A hard failure: everything pending rejects and the worker is torn down. */
166+ private fail(err: EngineError) {
167+ this.clearTimeout()
168+ this.worker?.terminate()
169+ this.worker = null
170+ this.runDone = false
171+ const sw = this.startWaiter
172+ const w = this.waiter
173+ this.startWaiter = null
174+ this.waiter = null
175+ sw?.reject(err)
176+ w?.reject(err)
177+ }
178+
179+ private handleMessage(msg: FromWorker) {
180+ switch (msg.type) {
181+ case 'output':
182+ console.log(`[numbl] ${msg.text.replace(/\n$/, '')}`)
183+ break
184+
185+ case 'uihtml': {
186+ // Track the most recent component; its Data is the initial payload.
187+ this.compId = msg.compId
188+ if (msg.dataJson) {
189+ try {
190+ this.initialData = JSON.parse(msg.dataJson) as HitAndRunData
191+ } catch {
192+ /* ignore malformed */
193+ }
194+ }
195+ this.maybeResolveStart()
196+ break
197+ }
198+
199+ case 'runDone': {
200+ this.runDone = true
201+ if (!msg.hasSession || !this.compId) {
202+ this.fail(new EngineError('script finished without a uihtml session'))
203+ return
204+ }
205+ this.maybeResolveStart()
206+ break
207+ }
208+
209+ case 'runError':
210+ this.fail(new EngineError(`script error: ${msg.message}`))
211+ break
212+
213+ case 'eventError':
214+ this.fail(new EngineError(`callback error: ${msg.message}`))
215+ break
216+
217+ case 'hostEvent': {
218+ if (!this.waiter || msg.name !== this.waiter.event) break
219+ const w = this.waiter
220+ this.waiter = null
221+ this.clearTimeout()
222+ try {
223+ w.resolve(JSON.parse(msg.dataJson))
224+ } catch (err) {
225+ w.reject(new EngineError(`bad event payload: ${String(err)}`))
226+ }
227+ break
228+ }
229+ }
230+ }
231+
232+ private maybeResolveStart() {
233+ if (!this.startWaiter || !this.runDone) return
234+ if (!this.initialData || this.initialData.type !== 'hitandrun') return
235+ const d = this.initialData
236+ const w = this.startWaiter
237+ this.startWaiter = null
238+ this.clearTimeout()
239+ w.resolve({
240+ region: asPoints(d.region),
241+ samples: asPoints(d.samples),
242+ n: typeof d.n === 'number' ? d.n : 10000,
243+ convex: d.convex !== false
244+ })
245+ }
246+}
src/engine/numbl.worker.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+// The numbl engine: runs the MATLAB project in this worker and speaks the
2+// uihtml protocol with the host, with no iframe involved. The script's
3+// uihtml(...) call surfaces here as a "uihtml" plot instruction (component id +
4+// initial Data); sendEventToHTMLSource(...) surfaces via onHtmlSourceEvent; and
5+// host "event" messages re-enter the still-live interpreter through the
6+// UihtmlSession, firing the script's HTMLEventReceivedFcn.
7+//
8+// This mirrors what numbl's own site-viewer worker does, trimmed to a single
9+// persistent script run.
10+
11+import {
12+ executeCode,
13+ VirtualFileSystem,
14+ BrowserFileIOAdapter,
15+ BrowserSystemAdapter,
16+ type UihtmlSession,
17+ type PlotInstruction
18+} from 'numbl'
19+import type {ToWorker, FromWorker} from './protocol'
20+
21+const post = (msg: FromWorker) => self.postMessage(msg)
22+
23+let session: UihtmlSession | null = null
24+
25+const reportUihtml = (instructions: PlotInstruction[]) => {
26+ for (const pi of instructions) {
27+ if (pi.type === 'uihtml') {
28+ post({type: 'uihtml', compId: pi.id, dataJson: pi.data})
29+ }
30+ }
31+}
32+
33+self.onmessage = (e: MessageEvent<ToWorker>) => {
34+ const msg = e.data
35+
36+ if (msg.type === 'run') {
37+ session?.dispose()
38+ session = null
39+
40+ const vfs = new VirtualFileSystem()
41+ const enc = new TextEncoder()
42+ for (const f of msg.files) vfs.writeFile(f.path, enc.encode(f.text))
43+ vfs.clearChangeTracking()
44+
45+ // chdir to the script's directory (project root here) so addpath('helpers')
46+ // and relative fileread() resolve, mirroring numbl's own worker.
47+ const mainAbs = vfs.normalizePath(msg.mainFileName)
48+ const lastSlash = mainAbs.lastIndexOf('/')
49+ vfs.setCwd(lastSlash > 0 ? mainAbs.slice(0, lastSlash) : '/')
50+
51+ const workspaceFiles = msg.files
52+ .filter(f => f.path.endsWith('.m'))
53+ .map(f => ({name: f.path, source: f.text}))
54+
55+ try {
56+ const result = executeCode(
57+ msg.files.find(f => f.path === msg.mainFileName)?.text ?? '',
58+ {
59+ onOutput: text => post({type: 'output', text}),
60+ onDrawnow: instructions => reportUihtml(instructions),
61+ displayResults: true,
62+ maxIterations: 10000000,
63+ optimization: '1',
64+ fileIO: new BrowserFileIOAdapter(vfs),
65+ system: new BrowserSystemAdapter(vfs),
66+ onHtmlSourceEvent: (compId, name, dataJson) =>
67+ post({type: 'hostEvent', compId, name, dataJson})
68+ },
69+ workspaceFiles,
70+ mainAbs
71+ )
72+ session = result.uihtmlSession ?? null
73+ reportUihtml(result.plotInstructions)
74+ post({type: 'runDone', hasSession: session !== null})
75+ } catch (err) {
76+ post({
77+ type: 'runError',
78+ message: err instanceof Error ? err.message : String(err)
79+ })
80+ }
81+ return
82+ }
83+
84+ if (msg.type === 'event') {
85+ if (!session) {
86+ post({type: 'eventError', message: 'no live uihtml session'})
87+ return
88+ }
89+ try {
90+ session.dispatchEvent(msg.compId, 'HTMLEventReceived', {
91+ name: msg.name,
92+ data: msg.data
93+ })
94+ } catch (err) {
95+ post({
96+ type: 'eventError',
97+ message: err instanceof Error ? err.message : String(err)
98+ })
99+ }
100+ }
101+}
src/engine/project.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+// The MATLAB project the engine runs, bundled into the app as raw text. The
2+// .m files under matlab/ are verbatim copies from the hitandrun-interactive
3+// repo (see README). hitandrun_sampler.m does
4+// `fileread(fullfile('app','dist','index.html'))` for the uihtml HTMLSource;
5+// we never render that page (the bridge is intercepted host-side), so a
6+// placeholder satisfies it.
7+
8+import demo from '../../matlab/hitandrun_demo.m?raw'
9+import sampler from '../../matlab/hitandrun_sampler.m?raw'
10+import makeRegion from '../../matlab/helpers/make_region.m?raw'
11+import hitAndRun from '../../matlab/helpers/hit_and_run.m?raw'
12+import hitAndRunGeneral from '../../matlab/helpers/hit_and_run_general.m?raw'
13+
14+export interface ProjectFile {
15+ path: string
16+ text: string
17+}
18+
19+export const PROJECT_FILES: ProjectFile[] = [
20+ {path: 'hitandrun_demo.m', text: demo},
21+ {path: 'hitandrun_sampler.m', text: sampler},
22+ {path: 'helpers/make_region.m', text: makeRegion},
23+ {path: 'helpers/hit_and_run.m', text: hitAndRun},
24+ {path: 'helpers/hit_and_run_general.m', text: hitAndRunGeneral},
25+ {path: 'app/dist/index.html', text: '<!-- headless: bridge intercepted host-side -->'}
26+]
27+
28+export const MAIN_FILE = 'hitandrun_demo.m'
src/engine/protocol.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+// Messages between the engine host (main thread) and the numbl worker.
2+
3+import type {ProjectFile} from './project'
4+
5+export type ToWorker =
6+ | {type: 'run'; files: ProjectFile[]; mainFileName: string}
7+ // An HTML→MATLAB event: fires the script's HTMLEventReceivedFcn.
8+ | {type: 'event'; compId: string; name: string; data: unknown}
9+
10+export type FromWorker =
11+ // A uihtml component was created (or re-shown): its id and initial Data.
12+ | {type: 'uihtml'; compId: string; dataJson: string | undefined}
13+ // The script called sendEventToHTMLSource(src, name, data).
14+ | {type: 'hostEvent'; compId: string; name: string; dataJson: string}
15+ | {type: 'output'; text: string}
16+ | {type: 'runDone'; hasSession: boolean}
17+ | {type: 'runError'; message: string}
18+ | {type: 'eventError'; message: string}
src/main.tsxadded+9−0View file
@@ -0,0 +1,9 @@
1+import {StrictMode} from 'react'
2+import {createRoot} from 'react-dom/client'
3+import App from './App'
4+
5+createRoot(document.getElementById('root')!).render(
6+ <StrictMode>
7+ <App />
8+ </StrictMode>
9+)
src/p2p/blob.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+// Binary codec for the sample sets. The samples are the big part of the
2+// shared state (up to 100k points), so instead of riding in the JSON view
3+// message they travel as a Float32 blob, streamed in chunks over the data
4+// channel (see Peer.sendBinary) and verified with the SHA-256 announced in the
5+// signed 'blob' header.
6+
7+import type {Points} from '../types'
8+
9+/** Points -> [x0..xn-1, y0..yn-1] as Float32 (plenty for display). */
10+export const encodeSamples = (p: Points): ArrayBuffer => {
11+ const n = Math.min(p.x.length, p.y.length)
12+ const f = new Float32Array(2 * n)
13+ for (let i = 0; i < n; i++) {
14+ f[i] = p.x[i]
15+ f[n + i] = p.y[i]
16+ }
17+ return f.buffer
18+}
19+
20+export const decodeSamples = (buf: ArrayBuffer): Points => {
21+ const f = new Float32Array(buf)
22+ const n = f.length >> 1
23+ const x = new Array<number>(n)
24+ const y = new Array<number>(n)
25+ for (let i = 0; i < n; i++) {
26+ x[i] = f[i]
27+ y[i] = f[n + i]
28+ }
29+ return {x, y}
30+}
31+
32+/** Accumulates the chunks of one announced blob on one connection. The data
33+ * channel is ordered, so chunks simply arrive in sequence after the header. */
34+export class BlobReceiver {
35+ private parts: Uint8Array[] = []
36+ private received = 0
37+
38+ constructor(
39+ readonly id: number,
40+ readonly bytes: number,
41+ readonly hash: string
42+ ) {}
43+
44+ /** Append a chunk; returns the assembled buffer once complete, else null. */
45+ append(chunk: ArrayBuffer): ArrayBuffer | null {
46+ this.parts.push(new Uint8Array(chunk))
47+ this.received += chunk.byteLength
48+ if (this.received < this.bytes) return null
49+ const out = new Uint8Array(this.bytes)
50+ let off = 0
51+ for (const part of this.parts) {
52+ // Tolerate a final chunk that would overrun (corrupt stream): truncate;
53+ // the hash check will reject it.
54+ const take = Math.min(part.length, this.bytes - off)
55+ out.set(take === part.length ? part : part.subarray(0, take), off)
56+ off += take
57+ }
58+ return out.buffer
59+ }
60+}
src/p2p/identity.tsadded+100−0View file
@@ -0,0 +1,100 @@
1+import * as secp from '@noble/secp256k1'
2+
3+// The peer's identity is a secp256k1 / BIP340 (schnorr) keypair.
4+// - The x-only public key (hex) IS the peer ID.
5+// - The private key is persisted in localStorage so the identity survives reloads.
6+// - The same key signs both nostr events (for relay discovery/signaling) and
7+// every application-level message sent over WebRTC.
8+
9+const STORAGE_KEY = 'hitandrun-commonview:privkey'
10+
11+const toHex = (bytes: Uint8Array): string =>
12+ bytes.reduce((s, b) => s + b.toString(16).padStart(2, '0'), '')
13+
14+const fromHex = (hex: string): Uint8Array => {
15+ const out = new Uint8Array(hex.length / 2)
16+ for (let i = 0; i < out.length; i++) {
17+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
18+ }
19+ return out
20+}
21+
22+const loadOrCreateSecretKey = (): Uint8Array => {
23+ const existing = localStorage.getItem(STORAGE_KEY)
24+ if (existing && existing.length === 64) {
25+ return fromHex(existing)
26+ }
27+ const {secretKey} = secp.schnorr.keygen()
28+ localStorage.setItem(STORAGE_KEY, toHex(secretKey))
29+ return secretKey
30+}
31+
32+const secretKey = loadOrCreateSecretKey()
33+const publicKey = secp.schnorr.getPublicKey(secretKey)
34+
35+/** This peer's ID = its x-only public key, as hex. */
36+export const selfId: string = toHex(publicKey)
37+
38+const sha256 = async (str: string): Promise<Uint8Array> =>
39+ new Uint8Array(
40+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
41+ )
42+
43+/** SHA-256 of raw bytes, as hex (used to authenticate binary blobs). */
44+export const sha256HexBytes = async (bytes: ArrayBuffer): Promise<string> =>
45+ toHex(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)))
46+
47+/** Sign an arbitrary string payload; returns hex signature. */
48+export const sign = async (payload: string): Promise<string> =>
49+ toHex(await secp.schnorr.signAsync(await sha256(payload), secretKey))
50+
51+/** Verify a hex signature over a string payload against a peer's ID (pubkey hex). */
52+export const verify = async (
53+ payload: string,
54+ sigHex: string,
55+ pubkeyHex: string
56+): Promise<boolean> => {
57+ try {
58+ return await secp.schnorr.verifyAsync(
59+ fromHex(sigHex),
60+ await sha256(payload),
61+ fromHex(pubkeyHex)
62+ )
63+ } catch {
64+ return false
65+ }
66+}
67+
68+// ---- nostr event signing (schnorr over the nostr event id) ----
69+
70+export interface NostrEvent {
71+ id: string
72+ pubkey: string
73+ created_at: number
74+ kind: number
75+ tags: string[][]
76+ content: string
77+ sig: string
78+}
79+
80+/** Build and sign a nostr event with this peer's key. */
81+export const makeNostrEvent = async (
82+ kind: number,
83+ tags: string[][],
84+ content: string
85+): Promise<NostrEvent> => {
86+ const created_at = Math.floor(Date.now() / 1000)
87+ const serialized = JSON.stringify([
88+ 0,
89+ selfId,
90+ created_at,
91+ kind,
92+ tags,
93+ content
94+ ])
95+ const id = toHex(await sha256(serialized))
96+ const sig = toHex(await secp.schnorr.signAsync(fromHex(id), secretKey))
97+ return {id, pubkey: selfId, created_at, kind, tags, content, sig}
98+}
99+
100+export {toHex, fromHex}
src/p2p/network.tsadded+717−0View file
@@ -0,0 +1,717 @@
1+import {selfId, sign, verify, sha256HexBytes} from './identity'
2+import {Nostr, peerTopic, rootTopic} from './nostr'
3+import {Peer, type Signal} from './peer'
4+import {BlobReceiver, decodeSamples, encodeSamples} from './blob'
5+import type {Params, Points} from '../types'
6+
7+// ---------------------------------------------------------------------------
8+// Shared state. Everyone sees the same figure: the parameter selections, the
9+// region, and the samples. Only the CENTRAL peer (oldest in the room) runs the
10+// numbl engine; commands from any viewer are forwarded to it, it computes, and
11+// it broadcasts the result.
12+//
13+// The state travels in two parts:
14+// - the "view" (params/region/status/movie): a small JSON message, broadcast
15+// on every change;
16+// - the samples: a Float32 blob (up to ~800 KB at n=100k), streamed in
17+// chunks and announced by a signed header carrying its SHA-256. The view
18+// references the blob by `samplesId`.
19+// Per-connection sends are serialized (a promise chain), so on the ordered
20+// data channel a peer always receives a blob before the view that points at
21+// it.
22+// ---------------------------------------------------------------------------
23+
24+export type EngineStatus = 'none' | 'starting' | 'ready' | 'error'
25+
26+export interface ViewState {
27+ params: Params
28+ region: Points | null
29+ /** The central peer is computing new samples. */
30+ busy: boolean
31+ /** Status of the numbl engine on the central peer. */
32+ engine: EngineStatus
33+ engineError: string | null
34+ /** Shared sampling movie: index of the point being sampled, null = off. */
35+ movieStep: number | null
36+ /** Identifies the sample blob this view belongs to. */
37+ samplesId: number
38+ samplesN: number
39+}
40+
41+export type Command =
42+ | {op: 'resample'; n: number; local: boolean}
43+ | {op: 'newRegion'; n: number; convex: boolean; local: boolean}
44+ | {op: 'movie'; play: boolean}
45+
46+const initialView = (): ViewState => ({
47+ params: {n: 10000, convex: true, local: false},
48+ region: null,
49+ busy: false,
50+ engine: 'none',
51+ engineError: null,
52+ movieStep: null,
53+ samplesId: 0,
54+ samplesN: 0
55+})
56+
57+/** What the network needs from the numbl engine (implemented in ../engine).
58+ * Kept abstract so this layer stays independent of the runtime. */
59+export interface EngineLike {
60+ start(): Promise<{region: Points; samples: Points; n: number; convex: boolean}>
61+ resample(req: {params: Params; region: Points}): Promise<{samples: Points; n: number}>
62+ newRegion(req: {params: Params}): Promise<{region: Points; samples: Points; n: number; convex: boolean}>
63+ dispose(): void
64+}
65+
66+// ---------------------------------------------------------------------------
67+// Wire protocol (over the WebRTC data channel). Every JSON message is a signed
68+// envelope; binary frames are the chunks of the blob most recently announced
69+// by a 'blob' header on the same channel (integrity via the header's SHA-256).
70+// ---------------------------------------------------------------------------
71+
72+type Message =
73+ | {t: 'hello'; connectedAt: number; engineFailed: boolean}
74+ | {t: 'command'; cmd: Command; forwarded?: boolean}
75+ | {t: 'view'; view: ViewState; version: number}
76+ | {t: 'blob'; id: number; bytes: number; hash: string}
77+
78+interface Envelope {
79+ data: string
80+ from: string
81+ sig: string
82+}
83+
84+// ---------------------------------------------------------------------------
85+
86+export interface RosterEntry {
87+ peerId: string
88+ connectedAt: number
89+ isSelf: boolean
90+ isCentral: boolean
91+ engineFailed: boolean
92+}
93+
94+export interface Snapshot {
95+ selfId: string
96+ connectedAt: number
97+ centralId: string | null
98+ amCentral: boolean
99+ roster: RosterEntry[]
100+ view: ViewState
101+ version: number
102+ samples: Points | null
103+ /** True when `samples` is the set the current view refers to. */
104+ samplesSynced: boolean
105+}
106+
107+const ANNOUNCE_INTERVAL_MS = 5000
108+const ROOM_ID = 'default'
109+// A connection attempt that hasn't opened after this long is torn down and
110+// retried on the peer's next announcement. Signaling events are ephemeral, so
111+// an offer published before the other side was listening is simply lost —
112+// without a retry the pair would deadlock forever.
113+const CONNECT_RETRY_MS = 15000
114+// Wait for discovery before assuming we're the first (and therefore central)
115+// peer, to avoid booting an engine just to resign seconds later.
116+const INITIAL_ELECTION_DELAY_MS = 4000
117+
118+// Shared sampling movie (mirrors the original figure's constants).
119+const MOVIE_STEP_MS = 750
120+const MOVIE_LAST_INDEX = 41
121+
122+interface Connection {
123+ peer: Peer
124+ /** When this connection attempt started (local clock), for retry pacing. */
125+ createdAt: number
126+ connectedAt: number | null // self-reported timestamp from the remote peer
127+ engineFailed: boolean
128+ /** Serializes everything we send on this channel (JSON + blob chunks). */
129+ sendChain: Promise<void>
130+ /** Serializes inbound processing too: envelope verification is async, and a
131+ * blob header must finish verifying (arming `recv`) before the binary
132+ * chunks right behind it are handled. */
133+ recvChain: Promise<void>
134+ /** Blob transfer in progress from this peer, if any. */
135+ recv: BlobReceiver | null
136+}
137+
138+export class Network {
139+ private nostr = new Nostr()
140+ private root = ''
141+ private connections = new Map<string, Connection>()
142+
143+ private connectedAt = Date.now()
144+ private view: ViewState = initialView()
145+ private version = 0
146+
147+ // The current sample set (kept by every peer, so any of them can serve it
148+ // if it becomes central).
149+ private samples: Points | null = null
150+ private samplesBuf: ArrayBuffer | null = null
151+ private samplesHash: string | null = null
152+ private samplesLocalId = 0
153+
154+ // Engine (central peer only).
155+ private engine: EngineLike | null = null
156+ private selfEngineFailed = false
157+ private pendingCmd: Command | null = null
158+ private computing = false
159+ private movieTimer: ReturnType<typeof setInterval> | null = null
160+ private electionArmed = false
161+
162+ private snapshot!: Snapshot
163+ private listeners = new Set<() => void>()
164+
165+ constructor(private engineFactory: () => EngineLike) {
166+ this.rebuildSnapshot()
167+ void this.start()
168+
169+ setTimeout(() => {
170+ this.electionArmed = true
171+ this.recompute()
172+ }, INITIAL_ELECTION_DELAY_MS)
173+
174+ window.addEventListener('online', () => {
175+ // Regaining a connection counts as a reconnect: new timestamp.
176+ this.connectedAt = Date.now()
177+ this.broadcast({
178+ t: 'hello',
179+ connectedAt: this.connectedAt,
180+ engineFailed: this.selfEngineFailed
181+ })
182+ this.recompute()
183+ })
184+ }
185+
186+ private async start() {
187+ this.root = await rootTopic(ROOM_ID)
188+
189+ // Receive WebRTC signaling addressed to us.
190+ const selfSignalTopic = await peerTopic(this.root, selfId)
191+ this.nostr.subscribe(selfSignalTopic, (content, from) => {
192+ if (from === selfId) return
193+ let signal: Signal
194+ try {
195+ signal = JSON.parse(content)
196+ } catch {
197+ return
198+ }
199+ this.handleSignal(from, signal)
200+ })
201+
202+ // Discover peers via announcements on the root topic.
203+ this.nostr.subscribe(this.root, (content, from) => {
204+ if (from === selfId) return
205+ let ann: {peerId?: string}
206+ try {
207+ ann = JSON.parse(content)
208+ } catch {
209+ return
210+ }
211+ if (ann.peerId && ann.peerId === from) this.maybeConnect(from)
212+ })
213+
214+ const announce = () =>
215+ void this.nostr.publish(this.root, JSON.stringify({peerId: selfId}))
216+ announce()
217+ setInterval(announce, ANNOUNCE_INTERVAL_MS)
218+ }
219+
220+ // ---- connection setup -------------------------------------------------
221+
222+ private maybeConnect(peerId: string) {
223+ if (peerId === selfId) return
224+ const existing = this.connections.get(peerId)
225+ if (existing) {
226+ const stalled =
227+ !existing.peer.isConnected &&
228+ Date.now() - existing.createdAt > CONNECT_RETRY_MS
229+ if (!stalled) return
230+ // destroy() fires the close handler, which removes it from the map.
231+ existing.peer.destroy()
232+ this.connections.delete(peerId)
233+ }
234+ // Deterministic initiator: the peer with the smaller ID makes the offer.
235+ const initiator = selfId < peerId
236+ this.createPeer(peerId, initiator)
237+ }
238+
239+ private createPeer(peerId: string, initiator: boolean): Connection {
240+ const peer = new Peer(initiator)
241+ const conn: Connection = {
242+ peer,
243+ createdAt: Date.now(),
244+ connectedAt: null,
245+ engineFailed: false,
246+ sendChain: Promise.resolve(),
247+ recvChain: Promise.resolve(),
248+ recv: null
249+ }
250+ this.connections.set(peerId, conn)
251+
252+ peer.setHandlers({
253+ signal: signal => {
254+ void this.sendSignal(peerId, signal)
255+ },
256+ connect: () => {
257+ // Tell the new peer our self-reported connect time; if we're central,
258+ // sync it immediately (blob first, then the view that references it —
259+ // the chain keeps that order on the wire).
260+ this.sendTo(peerId, {
261+ t: 'hello',
262+ connectedAt: this.connectedAt,
263+ engineFailed: this.selfEngineFailed
264+ })
265+ if (this.snapshot.amCentral) {
266+ this.sendBlobTo(conn)
267+ this.sendViewTo(conn)
268+ }
269+ this.recompute()
270+ },
271+ data: raw => {
272+ conn.recvChain = conn.recvChain
273+ .then(() => this.handleData(peerId, raw))
274+ .catch(() => {})
275+ },
276+ binary: chunk => {
277+ conn.recvChain = conn.recvChain
278+ .then(() => this.handleBinary(peerId, chunk))
279+ .catch(() => {})
280+ },
281+ close: () => {
282+ if (this.connections.get(peerId)?.peer === peer) {
283+ this.connections.delete(peerId)
284+ this.recompute()
285+ }
286+ }
287+ })
288+
289+ return conn
290+ }
291+
292+ private async sendSignal(peerId: string, signal: Signal) {
293+ const topic = await peerTopic(this.root, peerId)
294+ void this.nostr.publish(topic, JSON.stringify(signal))
295+ }
296+
297+ private handleSignal(from: string, signal: Signal) {
298+ let conn = this.connections.get(from)
299+ if (!conn) {
300+ if (signal.type !== 'offer') return // nothing to attach it to yet
301+ conn = this.createPeer(from, false)
302+ }
303+ void conn.peer.signal(signal)
304+ }
305+
306+ // ---- sending ------------------------------------------------------------
307+
308+ private async envelope(msg: Message): Promise<string> {
309+ const data = JSON.stringify(msg)
310+ const sig = await sign(data)
311+ const env: Envelope = {data, from: selfId, sig}
312+ return JSON.stringify(env)
313+ }
314+
315+ /** Queue a send task on a connection; tasks run strictly in order. */
316+ private chain(conn: Connection, task: () => Promise<void> | void) {
317+ conn.sendChain = conn.sendChain.then(task).catch(() => {})
318+ }
319+
320+ private sendTo(peerId: string, msg: Message) {
321+ const conn = this.connections.get(peerId)
322+ if (!conn) return
323+ const payload = this.envelope(msg)
324+ this.chain(conn, async () => conn.peer.send(await payload))
325+ }
326+
327+ private broadcast(msg: Message) {
328+ const payload = this.envelope(msg) // sign once, share across connections
329+ for (const conn of this.connections.values()) {
330+ this.chain(conn, async () => conn.peer.send(await payload))
331+ }
332+ }
333+
334+ private broadcastView() {
335+ this.version++
336+ this.broadcast({t: 'view', view: this.view, version: this.version})
337+ this.rebuildSnapshot()
338+ }
339+
340+ private sendViewTo(conn: Connection) {
341+ const payload = this.envelope({t: 'view', view: this.view, version: this.version})
342+ this.chain(conn, async () => conn.peer.send(await payload))
343+ }
344+
345+ /** Stream the current sample blob to one connection: signed header, then the
346+ * raw chunks. */
347+ private sendBlobTo(conn: Connection) {
348+ const buf = this.samplesBuf
349+ const hash = this.samplesHash
350+ const id = this.samplesLocalId
351+ if (!buf || !hash) return
352+ const header = this.envelope({t: 'blob', id, bytes: buf.byteLength, hash})
353+ this.chain(conn, async () => {
354+ conn.peer.send(await header)
355+ await conn.peer.sendBinary(buf)
356+ })
357+ }
358+
359+ /** Adopt a fresh sample set (central only) and stream it to everyone. The
360+ * caller broadcasts the updated view afterwards. */
361+ private async setSamples(samples: Points) {
362+ this.samples = samples
363+ this.samplesBuf = encodeSamples(samples)
364+ this.samplesHash = await sha256HexBytes(this.samplesBuf)
365+ this.samplesLocalId = ++this.view.samplesId
366+ this.view.samplesN = Math.min(samples.x.length, samples.y.length)
367+ for (const conn of this.connections.values()) this.sendBlobTo(conn)
368+ }
369+
370+ // ---- receiving ----------------------------------------------------------
371+
372+ private async handleData(from: string, raw: string) {
373+ let env: Envelope
374+ try {
375+ env = JSON.parse(raw)
376+ } catch {
377+ return
378+ }
379+ // The envelope must be signed by the peer we received it from.
380+ if (env.from !== from) return
381+ if (!(await verify(env.data, env.sig, env.from))) return
382+
383+ let msg: Message
384+ try {
385+ msg = JSON.parse(env.data)
386+ } catch {
387+ return
388+ }
389+
390+ switch (msg.t) {
391+ case 'hello': {
392+ const conn = this.connections.get(from)
393+ if (conn) {
394+ conn.connectedAt = msg.connectedAt
395+ conn.engineFailed = !!msg.engineFailed
396+ this.recompute()
397+ }
398+ break
399+ }
400+ case 'command': {
401+ if (this.snapshot.amCentral) {
402+ this.acceptCommand(msg.cmd)
403+ } else if (!msg.forwarded) {
404+ // Not central; forward once toward the central peer.
405+ const central = this.snapshot.centralId
406+ if (central && this.connections.has(central)) {
407+ this.sendTo(central, {...msg, forwarded: true})
408+ }
409+ }
410+ break
411+ }
412+ case 'view': {
413+ // Only trust state from the current central peer.
414+ if (from === this.snapshot.centralId && !this.snapshot.amCentral) {
415+ this.view = msg.view
416+ this.version = msg.version
417+ this.rebuildSnapshot()
418+ }
419+ break
420+ }
421+ case 'blob': {
422+ if (from !== this.snapshot.centralId || this.snapshot.amCentral) break
423+ const conn = this.connections.get(from)
424+ if (conn) conn.recv = new BlobReceiver(msg.id, msg.bytes, msg.hash)
425+ break
426+ }
427+ }
428+ }
429+
430+ private handleBinary(from: string, chunk: ArrayBuffer) {
431+ const conn = this.connections.get(from)
432+ if (!conn?.recv) return
433+ const buf = conn.recv.append(chunk)
434+ if (!buf) return
435+ const recv = conn.recv
436+ conn.recv = null
437+ void this.adoptBlob(recv, buf)
438+ }
439+
440+ private async adoptBlob(recv: BlobReceiver, buf: ArrayBuffer) {
441+ if ((await sha256HexBytes(buf)) !== recv.hash) return
442+ if (this.samples && recv.id <= this.samplesLocalId) return // stale
443+ this.samples = decodeSamples(buf)
444+ this.samplesBuf = buf
445+ this.samplesHash = recv.hash
446+ this.samplesLocalId = recv.id
447+ this.rebuildSnapshot()
448+ }
449+
450+ // ---- central-peer election -------------------------------------------
451+
452+ /** All peers we know about, with a reported connect time, plus ourselves. */
453+ private participants(): {
454+ peerId: string
455+ connectedAt: number
456+ engineFailed: boolean
457+ }[] {
458+ const list = [
459+ {peerId: selfId, connectedAt: this.connectedAt, engineFailed: this.selfEngineFailed}
460+ ]
461+ for (const [peerId, conn] of this.connections) {
462+ if (conn.peer.isConnected && conn.connectedAt !== null) {
463+ list.push({
464+ peerId,
465+ connectedAt: conn.connectedAt,
466+ engineFailed: conn.engineFailed
467+ })
468+ }
469+ }
470+ return list
471+ }
472+
473+ /** The oldest peer (smallest connect time; ties broken by peer ID) whose
474+ * engine hasn't failed is central. If every engine failed, fall back to the
475+ * oldest overall so the room still has an authority for its state. */
476+ private centralId(): string | null {
477+ const list = this.participants()
478+ if (list.length === 0) return null
479+ const healthy = list.filter(p => !p.engineFailed)
480+ const pool = healthy.length > 0 ? healthy : list
481+ return pool.reduce((oldest, p) =>
482+ p.connectedAt < oldest.connectedAt ||
483+ (p.connectedAt === oldest.connectedAt && p.peerId < oldest.peerId)
484+ ? p
485+ : oldest
486+ ).peerId
487+ }
488+
489+ private recompute() {
490+ const wasCentral = this.snapshot?.amCentral ?? false
491+ this.rebuildSnapshot()
492+ if (!this.electionArmed) return
493+ const isCentral = this.snapshot.amCentral
494+ if (isCentral && !this.engine && !this.selfEngineFailed) {
495+ void this.becomeCentral()
496+ } else if (!isCentral && wasCentral) {
497+ this.resignCentral()
498+ }
499+ }
500+
501+ // ---- central role: engine lifecycle ------------------------------------
502+
503+ private async becomeCentral() {
504+ this.stopMovieTicker()
505+ this.view = {
506+ ...this.view,
507+ busy: false,
508+ movieStep: null,
509+ engine: 'starting',
510+ engineError: null
511+ }
512+ this.broadcastView()
513+
514+ const engine = this.engineFactory()
515+ this.engine = engine
516+ try {
517+ const init = await engine.start()
518+ if (this.engine !== engine) return // resigned while booting
519+ const held = this.samples
520+ if (!held || !this.view.region) {
521+ // Fresh room — or we never received the previous central's samples
522+ // (it died mid-transfer), in which case the inherited region can't be
523+ // served. Adopt the script's initial region + samples.
524+ this.view.params = {n: init.n, convex: init.convex, local: false}
525+ this.view.region = init.region
526+ await this.setSamples(init.samples)
527+ } else {
528+ // Reconcile the inherited view with the blob we actually hold (they
529+ // can differ if the old central died between blob and view). Relabel
530+ // our blob as the view's current one — ids must never regress.
531+ this.samplesLocalId = this.view.samplesId
532+ this.view.samplesN = Math.min(held.x.length, held.y.length)
533+ }
534+ this.view = {...this.view, engine: 'ready', engineError: null}
535+ this.selfEngineFailed = false
536+ this.broadcastView()
537+ void this.processCompute()
538+ } catch (err) {
539+ if (this.engine !== engine) return
540+ this.engineFailure(err instanceof Error ? err.message : String(err))
541+ }
542+ }
543+
544+ private resignCentral() {
545+ this.stopMovieTicker()
546+ this.engine?.dispose()
547+ this.engine = null
548+ this.pendingCmd = null
549+ this.computing = false
550+ // Our sample lineage is no longer authoritative; make sure the real
551+ // central's next blob is accepted even if its ids overlap ours (e.g. two
552+ // solo-started rooms merging). The samples stay visible until replaced.
553+ this.samplesLocalId = 0
554+ }
555+
556+ /** The engine could not boot or compute. Step down: announce the failure so
557+ * the room elects the next-oldest healthy peer, whose last-known state
558+ * becomes the source of truth. */
559+ private engineFailure(message: string) {
560+ this.engine?.dispose()
561+ this.engine = null
562+ this.pendingCmd = null
563+ this.computing = false
564+ this.stopMovieTicker()
565+ this.selfEngineFailed = true
566+ this.view = {
567+ ...this.view,
568+ busy: false,
569+ movieStep: null,
570+ engine: 'error',
571+ engineError: message
572+ }
573+ // Still central at this instant, so receivers accept this view; the hello
574+ // that follows triggers the re-election.
575+ this.broadcastView()
576+ this.broadcast({
577+ t: 'hello',
578+ connectedAt: this.connectedAt,
579+ engineFailed: true
580+ })
581+ this.recompute()
582+ }
583+
584+ // ---- central role: commands --------------------------------------------
585+
586+ private acceptCommand(cmd: Command) {
587+ if (cmd.op === 'movie') {
588+ this.handleMovie(cmd.play)
589+ return
590+ }
591+ if (!this.engine || this.view.engine !== 'ready') return
592+ // Latest wins: a newer request supersedes one still waiting its turn.
593+ this.pendingCmd = cmd
594+ void this.processCompute()
595+ }
596+
597+ private async processCompute() {
598+ if (this.computing) return
599+ const cmd = this.pendingCmd
600+ const engine = this.engine
601+ if (!cmd || !engine || cmd.op === 'movie') return
602+ this.pendingCmd = null
603+ if (cmd.op === 'resample' && !this.view.region) return
604+
605+ this.computing = true
606+ this.stopMovieTicker()
607+ this.view = {...this.view, busy: true, movieStep: null}
608+ this.broadcastView()
609+
610+ try {
611+ if (cmd.op === 'resample') {
612+ const params: Params = {...this.view.params, n: cmd.n, local: cmd.local}
613+ const r = await engine.resample({params, region: this.view.region!})
614+ if (this.engine !== engine) return
615+ this.view.params = {...params, n: r.n}
616+ await this.setSamples(r.samples)
617+ } else {
618+ const params: Params = {n: cmd.n, convex: cmd.convex, local: cmd.local}
619+ const r = await engine.newRegion({params})
620+ if (this.engine !== engine) return
621+ this.view.params = {...params, n: r.n, convex: r.convex}
622+ this.view.region = r.region
623+ await this.setSamples(r.samples)
624+ }
625+ this.view = {...this.view, busy: false}
626+ this.broadcastView()
627+ } catch (err) {
628+ if (this.engine === engine) {
629+ this.engineFailure(err instanceof Error ? err.message : String(err))
630+ }
631+ return
632+ } finally {
633+ this.computing = false
634+ }
635+ if (this.pendingCmd) void this.processCompute()
636+ }
637+
638+ // ---- central role: the shared movie --------------------------------------
639+
640+ private handleMovie(play: boolean) {
641+ if (!play) {
642+ this.stopMovieTicker()
643+ if (this.view.movieStep !== null) {
644+ this.view = {...this.view, movieStep: null}
645+ this.broadcastView()
646+ }
647+ return
648+ }
649+ if (this.view.busy || this.view.movieStep !== null || this.view.samplesN < 3) {
650+ return
651+ }
652+ const lastIndex = Math.min(this.view.samplesN - 1, MOVIE_LAST_INDEX)
653+ this.view = {...this.view, movieStep: 2}
654+ this.broadcastView()
655+ this.movieTimer = setInterval(() => {
656+ const next = (this.view.movieStep ?? lastIndex) + 1
657+ if (next > lastIndex) {
658+ this.stopMovieTicker()
659+ this.view = {...this.view, movieStep: null}
660+ } else {
661+ this.view = {...this.view, movieStep: next}
662+ }
663+ this.broadcastView()
664+ }, MOVIE_STEP_MS)
665+ }
666+
667+ private stopMovieTicker() {
668+ if (this.movieTimer !== null) clearInterval(this.movieTimer)
669+ this.movieTimer = null
670+ }
671+
672+ // ---- public API -------------------------------------------------------
673+
674+ dispatch(cmd: Command) {
675+ if (this.snapshot.amCentral) {
676+ this.acceptCommand(cmd)
677+ } else {
678+ const central = this.snapshot.centralId
679+ if (central && this.connections.has(central)) {
680+ this.sendTo(central, {t: 'command', cmd})
681+ }
682+ }
683+ }
684+
685+ getSnapshot = (): Snapshot => this.snapshot
686+
687+ subscribe = (listener: () => void): (() => void) => {
688+ this.listeners.add(listener)
689+ return () => this.listeners.delete(listener)
690+ }
691+
692+ private rebuildSnapshot() {
693+ const centralId = this.centralId()
694+ const roster: RosterEntry[] = this.participants()
695+ .map(p => ({
696+ peerId: p.peerId,
697+ connectedAt: p.connectedAt,
698+ isSelf: p.peerId === selfId,
699+ isCentral: p.peerId === centralId,
700+ engineFailed: p.engineFailed
701+ }))
702+ .sort((a, b) => a.connectedAt - b.connectedAt)
703+
704+ this.snapshot = {
705+ selfId,
706+ connectedAt: this.connectedAt,
707+ centralId,
708+ amCentral: centralId === selfId,
709+ roster,
710+ view: this.view,
711+ version: this.version,
712+ samples: this.samples,
713+ samplesSynced: this.samples !== null && this.samplesLocalId === this.view.samplesId
714+ }
715+ for (const l of this.listeners) l()
716+ }
717+}
src/p2p/nostr.tsadded+137−0View file
@@ -0,0 +1,137 @@
1+import {makeNostrEvent, type NostrEvent} from './identity'
2+
3+// Minimal nostr client, modeled on trystero's nostr strategy but trimmed to
4+// only what we need: publish to a topic, and subscribe to a topic. Topics are
5+// carried in an 'x' tag; each topic maps to an ephemeral event kind (20000+)
6+// so relays don't store the messages.
7+
8+const RELAYS = [
9+ 'wss://relay.damus.io',
10+ 'wss://nos.lol',
11+ 'wss://relay.mostr.pub',
12+ 'wss://purplerelay.com'
13+]
14+
15+const TAG = 'x'
16+
17+const strToNum = (str: string, limit: number): number => {
18+ let sum = 0
19+ for (let i = 0; i < str.length; i++) sum += str.charCodeAt(i)
20+ return sum % limit
21+}
22+
23+const kindForTopic = (topic: string): number => strToNum(topic, 10000) + 20000
24+
25+const nowSec = (): number => Math.floor(Date.now() / 1000)
26+
27+const genSubId = (): string =>
28+ Array.from({length: 16}, () =>
29+ Math.floor(Math.random() * 16).toString(16)
30+ ).join('')
31+
32+type TopicHandler = (content: string, fromPubkey: string) => void
33+
34+export class Nostr {
35+ private sockets: WebSocket[] = []
36+ private subs = new Map<string, {topic: string; handler: TopicHandler}>()
37+
38+ constructor() {
39+ for (const url of RELAYS) this.connect(url)
40+ }
41+
42+ private connect(url: string) {
43+ let ws: WebSocket
44+ try {
45+ ws = new WebSocket(url)
46+ } catch {
47+ return
48+ }
49+ this.sockets.push(ws)
50+
51+ ws.onopen = () => {
52+ // (re)send all active subscriptions on this socket
53+ for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
54+ }
55+
56+ ws.onmessage = ev => {
57+ let msg: unknown
58+ try {
59+ msg = JSON.parse(ev.data as string)
60+ } catch {
61+ return
62+ }
63+ if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
64+ const subId = msg[1] as string
65+ const event = msg[2] as NostrEvent
66+ const sub = this.subs.get(subId)
67+ if (sub && event && typeof event.content === 'string') {
68+ sub.handler(event.content, event.pubkey)
69+ }
70+ }
71+
72+ ws.onclose = () => {
73+ this.sockets = this.sockets.filter(s => s !== ws)
74+ // reconnect after a short delay
75+ setTimeout(() => this.connect(url), 3000)
76+ }
77+
78+ ws.onerror = () => ws.close()
79+ }
80+
81+ private sendReq(ws: WebSocket, subId: string, topic: string) {
82+ if (ws.readyState !== WebSocket.OPEN) return
83+ ws.send(
84+ JSON.stringify([
85+ 'REQ',
86+ subId,
87+ {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
88+ ])
89+ )
90+ }
91+
92+ /** Subscribe to a topic. Handler fires for each incoming event. */
93+ subscribe(topic: string, handler: TopicHandler): () => void {
94+ const subId = genSubId()
95+ this.subs.set(subId, {topic, handler})
96+ for (const ws of this.sockets) this.sendReq(ws, subId, topic)
97+ return () => {
98+ this.subs.delete(subId)
99+ for (const ws of this.sockets) {
100+ if (ws.readyState === WebSocket.OPEN) {
101+ ws.send(JSON.stringify(['CLOSE', subId]))
102+ }
103+ }
104+ }
105+ }
106+
107+ /** Publish a signed event to a topic. */
108+ async publish(topic: string, content: string): Promise<void> {
109+ const event = await makeNostrEvent(
110+ kindForTopic(topic),
111+ [[TAG, topic]],
112+ content
113+ )
114+ const payload = JSON.stringify(['EVENT', event])
115+ for (const ws of this.sockets) {
116+ if (ws.readyState === WebSocket.OPEN) ws.send(payload)
117+ }
118+ }
119+}
120+
121+const sha256Hex = async (str: string): Promise<string> => {
122+ const buf = await crypto.subtle.digest(
123+ 'SHA-256',
124+ new TextEncoder().encode(str)
125+ )
126+ return Array.from(new Uint8Array(buf))
127+ .map(b => b.toString(16).padStart(2, '0'))
128+ .join('')
129+}
130+
131+/** Topic everyone in a room announces on / listens to for discovery. */
132+export const rootTopic = (roomId: string): Promise<string> =>
133+ sha256Hex(`hitandrun-commonview:${roomId}`)
134+
135+/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
136+export const peerTopic = (root: string, peerId: string): Promise<string> =>
137+ sha256Hex(`${root}:${peerId}`)
src/p2p/peer.tsadded+200−0View file
@@ -0,0 +1,200 @@
1+// A thin WebRTC wrapper, distilled from trystero's peer.ts. We only need a
2+// reliable ordered data channel plus offer/answer/ICE signaling. To keep things
3+// simple we avoid "perfect negotiation" glare handling by ensuring only ONE
4+// side (a deterministically chosen initiator) ever creates the offer.
5+//
6+// Unlike commonview's original, the channel carries two kinds of frames:
7+// strings (JSON control messages) and ArrayBuffers (chunks of large binary
8+// payloads, e.g. sample sets). The wrapper keeps them apart and applies
9+// backpressure when streaming binary data.
10+
11+export type Signal =
12+ | {type: 'offer'; sdp: string}
13+ | {type: 'answer'; sdp: string}
14+ | {type: 'candidate'; candidate: RTCIceCandidateInit}
15+
16+export interface PeerHandlers {
17+ signal: (signal: Signal) => void
18+ connect: () => void
19+ data: (data: string) => void
20+ binary: (data: ArrayBuffer) => void
21+ close: () => void
22+}
23+
24+const ICE_SERVERS: RTCIceServer[] = [
25+ {urls: 'stun:stun.l.google.com:19302'},
26+ {urls: 'stun:stun1.l.google.com:19302'},
27+ {urls: 'stun:stun.cloudflare.com:3478'}
28+]
29+
30+// Keep binary frames well under the ~256 KB cross-browser SCTP message limit.
31+export const BINARY_CHUNK_BYTES = 64 * 1024
32+
33+// While streaming a large payload, pause whenever this much is queued in the
34+// channel and resume once it drains below the low-water mark.
35+const HIGH_WATER = 1 << 20 // 1 MiB
36+const LOW_WATER = 1 << 18 // 256 KiB
37+
38+export class Peer {
39+ private pc: RTCPeerConnection
40+ private channel: RTCDataChannel | null = null
41+ private handlers: Partial<PeerHandlers> = {}
42+ private pendingCandidates: RTCIceCandidateInit[] = []
43+ private closed = false
44+
45+ constructor(private initiator: boolean) {
46+ this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
47+
48+ this.pc.onicecandidate = ({candidate}) => {
49+ if (candidate) {
50+ this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
51+ }
52+ }
53+
54+ this.pc.onconnectionstatechange = () => {
55+ const s = this.pc.connectionState
56+ if (s === 'failed' || s === 'closed' || s === 'disconnected') {
57+ this.destroy()
58+ }
59+ }
60+
61+ if (initiator) {
62+ this.setupChannel(this.pc.createDataChannel('data'))
63+ this.pc.onnegotiationneeded = () => void this.makeOffer()
64+ } else {
65+ this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
66+ }
67+ }
68+
69+ setHandlers(handlers: Partial<PeerHandlers>) {
70+ Object.assign(this.handlers, handlers)
71+ }
72+
73+ private setupChannel(channel: RTCDataChannel) {
74+ this.channel = channel
75+ channel.binaryType = 'arraybuffer'
76+ channel.bufferedAmountLowThreshold = LOW_WATER
77+ channel.onopen = () => this.handlers.connect?.()
78+ channel.onclose = () => this.destroy()
79+ channel.onmessage = e => {
80+ if (typeof e.data === 'string') this.handlers.data?.(e.data)
81+ else this.handlers.binary?.(e.data as ArrayBuffer)
82+ }
83+ }
84+
85+ private async makeOffer() {
86+ if (this.closed) return
87+ try {
88+ await this.pc.setLocalDescription(await this.pc.createOffer())
89+ this.handlers.signal?.({
90+ type: 'offer',
91+ sdp: this.pc.localDescription!.sdp
92+ })
93+ } catch {
94+ /* ignore */
95+ }
96+ }
97+
98+ async signal(signal: Signal) {
99+ if (this.closed) return
100+ try {
101+ if (signal.type === 'candidate') {
102+ if (this.pc.remoteDescription) {
103+ await this.pc.addIceCandidate(signal.candidate)
104+ } else {
105+ this.pendingCandidates.push(signal.candidate)
106+ }
107+ return
108+ }
109+
110+ if (signal.type === 'offer') {
111+ if (this.initiator) return // initiators never accept remote offers
112+ await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
113+ await this.flushCandidates()
114+ await this.pc.setLocalDescription(await this.pc.createAnswer())
115+ this.handlers.signal?.({
116+ type: 'answer',
117+ sdp: this.pc.localDescription!.sdp
118+ })
119+ return
120+ }
121+
122+ if (signal.type === 'answer') {
123+ await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
124+ await this.flushCandidates()
125+ }
126+ } catch {
127+ /* ignore transient signaling errors */
128+ }
129+ }
130+
131+ private async flushCandidates() {
132+ const queued = this.pendingCandidates.splice(0)
133+ for (const c of queued) {
134+ try {
135+ await this.pc.addIceCandidate(c)
136+ } catch {
137+ /* ignore */
138+ }
139+ }
140+ }
141+
142+ send(data: string) {
143+ if (this.channel?.readyState === 'open') this.channel.send(data)
144+ }
145+
146+ /** Stream a large binary payload as sequential chunks, respecting channel
147+ * backpressure. Resolves when everything is handed to the channel; resolves
148+ * false if the channel closed part-way. */
149+ async sendBinary(payload: ArrayBuffer): Promise<boolean> {
150+ for (let off = 0; off < payload.byteLength; off += BINARY_CHUNK_BYTES) {
151+ const ch = this.channel
152+ if (!ch || ch.readyState !== 'open') return false
153+ if (ch.bufferedAmount > HIGH_WATER) {
154+ const ok = await this.drain(ch)
155+ if (!ok) return false
156+ }
157+ try {
158+ ch.send(payload.slice(off, off + BINARY_CHUNK_BYTES))
159+ } catch {
160+ return false
161+ }
162+ }
163+ return true
164+ }
165+
166+ private drain(ch: RTCDataChannel): Promise<boolean> {
167+ return new Promise(resolve => {
168+ const done = (ok: boolean) => {
169+ ch.removeEventListener('bufferedamountlow', onLow)
170+ ch.removeEventListener('close', onClose)
171+ resolve(ok)
172+ }
173+ const onLow = () => done(true)
174+ const onClose = () => done(false)
175+ ch.addEventListener('bufferedamountlow', onLow)
176+ ch.addEventListener('close', onClose)
177+ if (ch.readyState !== 'open') done(false)
178+ })
179+ }
180+
181+ get isConnected(): boolean {
182+ return this.channel?.readyState === 'open'
183+ }
184+
185+ destroy() {
186+ if (this.closed) return
187+ this.closed = true
188+ try {
189+ this.channel?.close()
190+ } catch {
191+ /* ignore */
192+ }
193+ try {
194+ this.pc.close()
195+ } catch {
196+ /* ignore */
197+ }
198+ this.handlers.close?.()
199+ }
200+}
src/render/RegionView.tsxadded+196−0View file
@@ -0,0 +1,196 @@
1+import { useEffect, useRef, type CSSProperties } from "react";
2+import type { Points } from "../types";
3+
4+export type { Points };
5+
6+export interface Segment {
7+ x0: number;
8+ y0: number;
9+ x1: number;
10+ y1: number;
11+}
12+
13+export interface Pt {
14+ x: number;
15+ y: number;
16+}
17+
18+interface RegionViewProps {
19+ /** Convex region boundary, in counterclockwise world coordinates. */
20+ region: Points;
21+ /** Sample points to scatter inside the region. */
22+ samples: Points;
23+ /** Animation overlay: the in-region segments the current step samples along
24+ * (one for a convex region; possibly several for a non-convex one). */
25+ segments?: Segment[] | null;
26+ /** Animation overlay: the current point the step starts from (ringed). */
27+ from?: Pt | null;
28+ /** Animation overlay: the freshly sampled point (highlighted). */
29+ newPoint?: Pt | null;
30+}
31+
32+const FILL = "rgba(37, 99, 235, 0.08)";
33+const STROKE = "#2563eb";
34+const DOT = "rgba(15, 23, 42, 0.55)";
35+const DOT_RADIUS = 1.6;
36+const MARGIN = 28;
37+
38+const CHORD = "#f59e0b"; // amber: the candidate segment + its boundary hits
39+const PREV = "#d97706"; // deeper amber dot: the sample the chord starts from
40+const NEW = "#0f172a"; // near-black: the freshly sampled point (circled)
41+
42+/** Renders the region outline and the samples on a 2D canvas, fitting the
43+ * region to the available space (aspect-ratio preserved, y pointing up). The
44+ * optional `chord` / `from` / `newPoint` overlay drives the sampling movie.
45+ * The canvas is redrawn on data change and on resize, and is devicePixelRatio
46+ * aware so dots and edges stay crisp. */
47+export function RegionView({
48+ region,
49+ samples,
50+ segments,
51+ from,
52+ newPoint,
53+}: RegionViewProps) {
54+ const canvasRef = useRef<HTMLCanvasElement>(null);
55+ const containerRef = useRef<HTMLDivElement>(null);
56+
57+ useEffect(() => {
58+ const canvas = canvasRef.current;
59+ const container = containerRef.current;
60+ if (!canvas || !container) return;
61+
62+ const draw = () => {
63+ const ctx = canvas.getContext("2d");
64+ if (!ctx) return;
65+
66+ const dpr = window.devicePixelRatio || 1;
67+ const cssW = container.clientWidth;
68+ const cssH = container.clientHeight;
69+ if (cssW === 0 || cssH === 0) return;
70+
71+ canvas.width = Math.round(cssW * dpr);
72+ canvas.height = Math.round(cssH * dpr);
73+ canvas.style.width = `${cssW}px`;
74+ canvas.style.height = `${cssH}px`;
75+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
76+ ctx.clearRect(0, 0, cssW, cssH);
77+
78+ if (region.x.length < 3) return;
79+
80+ // World bounds from the region (samples lie inside it).
81+ let minX = Infinity;
82+ let maxX = -Infinity;
83+ let minY = Infinity;
84+ let maxY = -Infinity;
85+ for (let i = 0; i < region.x.length; i++) {
86+ minX = Math.min(minX, region.x[i]);
87+ maxX = Math.max(maxX, region.x[i]);
88+ minY = Math.min(minY, region.y[i]);
89+ maxY = Math.max(maxY, region.y[i]);
90+ }
91+ const worldW = maxX - minX || 1;
92+ const worldH = maxY - minY || 1;
93+
94+ // Fit preserving aspect ratio; center within the margins.
95+ const scale = Math.min(
96+ (cssW - 2 * MARGIN) / worldW,
97+ (cssH - 2 * MARGIN) / worldH
98+ );
99+ const offX = (cssW - worldW * scale) / 2;
100+ const offY = (cssH - worldH * scale) / 2;
101+ // World (x right, y up) → pixel (x right, y down).
102+ const toPx = (x: number) => offX + (x - minX) * scale;
103+ const toPy = (y: number) => cssH - (offY + (y - minY) * scale);
104+
105+ // Region: translucent fill + crisp outline.
106+ ctx.beginPath();
107+ ctx.moveTo(toPx(region.x[0]), toPy(region.y[0]));
108+ for (let i = 1; i < region.x.length; i++) {
109+ ctx.lineTo(toPx(region.x[i]), toPy(region.y[i]));
110+ }
111+ ctx.closePath();
112+ ctx.fillStyle = FILL;
113+ ctx.fill();
114+ ctx.lineWidth = 2;
115+ ctx.strokeStyle = STROKE;
116+ ctx.stroke();
117+
118+ // Samples: small filled dots.
119+ ctx.fillStyle = DOT;
120+ const n = Math.min(samples.x.length, samples.y.length);
121+ for (let i = 0; i < n; i++) {
122+ const cx = toPx(samples.x[i]);
123+ const cy = toPy(samples.y[i]);
124+ ctx.beginPath();
125+ ctx.arc(cx, cy, DOT_RADIUS, 0, 2 * Math.PI);
126+ ctx.fill();
127+ }
128+
129+ // Animation overlay (movie mode): the in-region segment(s) of the line.
130+ if (segments) {
131+ for (const seg of segments) {
132+ const x0 = toPx(seg.x0);
133+ const y0 = toPy(seg.y0);
134+ const x1 = toPx(seg.x1);
135+ const y1 = toPy(seg.y1);
136+ ctx.beginPath();
137+ ctx.moveTo(x0, y0);
138+ ctx.lineTo(x1, y1);
139+ ctx.strokeStyle = CHORD;
140+ ctx.lineWidth = 1.5;
141+ ctx.setLineDash([4, 3]);
142+ ctx.stroke();
143+ ctx.setLineDash([]);
144+ // Open circles where the line crosses the region boundary.
145+ ctx.lineWidth = 1.25;
146+ for (const [ex, ey] of [
147+ [x0, y0],
148+ [x1, y1],
149+ ]) {
150+ ctx.beginPath();
151+ ctx.arc(ex, ey, 3, 0, 2 * Math.PI);
152+ ctx.stroke();
153+ }
154+ }
155+ }
156+ // Previous sample: the point the chord starts from — a filled amber dot.
157+ if (from) {
158+ ctx.beginPath();
159+ ctx.arc(toPx(from.x), toPy(from.y), 4, 0, 2 * Math.PI);
160+ ctx.fillStyle = PREV;
161+ ctx.fill();
162+ }
163+ // New sample: a circled black point.
164+ if (newPoint) {
165+ const cx = toPx(newPoint.x);
166+ const cy = toPy(newPoint.y);
167+ ctx.beginPath();
168+ ctx.arc(cx, cy, 6.5, 0, 2 * Math.PI);
169+ ctx.strokeStyle = NEW;
170+ ctx.lineWidth = 1.5;
171+ ctx.stroke();
172+ ctx.beginPath();
173+ ctx.arc(cx, cy, 3.2, 0, 2 * Math.PI);
174+ ctx.fillStyle = NEW;
175+ ctx.fill();
176+ }
177+ };
178+
179+ draw();
180+ const ro = new ResizeObserver(draw);
181+ ro.observe(container);
182+ return () => ro.disconnect();
183+ }, [region, samples, segments, from, newPoint]);
184+
185+ return (
186+ <div ref={containerRef} style={containerStyle}>
187+ <canvas ref={canvasRef} style={{ display: "block" }} />
188+ </div>
189+ );
190+}
191+
192+const containerStyle: CSSProperties = {
193+ position: "absolute",
194+ inset: 0,
195+ overflow: "hidden",
196+};
src/types.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+/** A set of 2D points as parallel coordinate arrays (region vertices or
2+ * samples). Mirrors what hitandrun_sampler.m packs into its payloads. */
3+export interface Points {
4+ x: number[]
5+ y: number[]
6+}
7+
8+/** The figure's parameter selections — part of the shared state. */
9+export interface Params {
10+ n: number
11+ convex: boolean
12+ local: boolean
13+}
src/useNetwork.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+import {useSyncExternalStore} from 'react'
2+import {Network, type Command, type Snapshot} from './p2p/network'
3+import {Engine} from './engine/engine'
4+
5+// A single Network instance for the whole app (module-level so React StrictMode
6+// double-mounting doesn't create two peer networks). The engine factory is only
7+// invoked if/when this peer becomes central.
8+const network = new Network(() => new Engine())
9+
10+export const useNetwork = (): {
11+ snapshot: Snapshot
12+ dispatch: (cmd: Command) => void
13+} => {
14+ const snapshot = useSyncExternalStore(network.subscribe, network.getSnapshot)
15+ return {snapshot, dispatch: cmd => network.dispatch(cmd)}
16+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.jsonadded+20−0View file
@@ -0,0 +1,20 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "isolatedModules": true,
11+ "moduleDetection": "force",
12+ "noEmit": true,
13+ "jsx": "react-jsx",
14+ "strict": true,
15+ "noUnusedLocals": true,
16+ "noUnusedParameters": true,
17+ "noFallthroughCasesInSwitch": true
18+ },
19+ "include": ["src"]
20+}
vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+import {defineConfig} from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+export default defineConfig({
5+ // Project pages are served from https://<org>.github.io/hitandrun-commonview/
6+ base: '/hitandrun-commonview/',
7+ plugins: [react()]
8+})