2<html lang="en">
3<head>
4<meta charset="utf-8" />
5<meta name="viewport" content="width=device-width, initial-scale=1" />
6<title>Random points in a disk — how many do you need?</title>
7<meta name="description" content="Voxel-density heatmaps of N uniform random points in a disk: how many samples it takes before the disk looks smooth at a given resolution." />
8<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230b0e14'/%3E%3Ccircle cx='16' cy='16' r='11' fill='%23dd5136'/%3E%3C/svg%3E" />
9<style>
10 :root {
11 --bg: #0b0e14;
12 --panel: #141924;
13 --panel-2: #1b2130;
14 --line: #2a3243;
15 --fg: #e7ecf5;
16 --dim: #97a3b8;
17 --accent: #f08a2a;
18 }
19 * { box-sizing: border-box; }
20 body {
21 margin: 0;
22 background: var(--bg);
23 color: var(--fg);
24 font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
25 -webkit-font-smoothing: antialiased;
26 }
27 .wrap { max-width: 1140px; margin: 0 auto; padding: 28px 20px 56px; }
28 h1 { font-size: 1.5rem; margin: 0 0 .4rem; letter-spacing: -0.01em; }
29 .lede { color: var(--dim); margin: 0 0 1.6rem; max-width: 70ch; }
30 .lede code { color: var(--fg); background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
32 .card {
33 background: var(--panel);
34 border: 1px solid var(--line);
35 border-radius: 10px;
36 }
38 /* ---- controls ---- */
39 .controls { padding: 16px 18px; display: grid; gap: 18px; grid-template-columns: 2fr 1fr auto; align-items: end; }
40 @media (max-width: 720px) { .controls { grid-template-columns: 1fr; } }
41 label { display: block; font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin-bottom: 6px; }
42 .val { color: var(--fg); text-transform: none; letter-spacing: 0; font-size: .95rem; font-variant-numeric: tabular-nums; }
43 input[type=range] { width: 100%; accent-color: var(--accent); margin: 0; }
44 select, button {
45 font: inherit; color: var(--fg); background: var(--panel-2);
46 border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px;
47 }
48 select { width: 100%; }
49 button { cursor: pointer; white-space: nowrap; }
50 button:hover { border-color: var(--accent); }
51 .badge {
52 font-size: .72rem; letter-spacing: .06em; text-transform: uppercase;
53 padding: 3px 8px; border-radius: 20px; white-space: nowrap;
54 border: 1px solid var(--line); color: var(--dim);
55 }
56 .badge.on { border-color: #2e7d5b; color: #6ddba4; }
58 /* ---- panels ---- */
59 .panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 14px 0; }
60 @media (max-width: 900px) { .panels { grid-template-columns: 1fr; max-width: 380px; } }
61 .panel { padding: 14px; }
62 .panel h2 { font-size: .95rem; margin: 0 0 2px; }
63 .panel p { margin: 0 0 10px; font-size: .82rem; color: var(--dim); min-height: 2.6em; }
64 canvas {
65 width: 100%; aspect-ratio: 1; display: block; border-radius: 6px;
66 image-rendering: pixelated; image-rendering: crisp-edges;
67 background: #000;
68 }
69 .bar { height: 9px; border-radius: 3px; margin-top: 10px; border: 1px solid var(--line); }
70 .ticks { display: flex; justify-content: space-between; font-size: .72rem; color: var(--dim); font-variant-numeric: tabular-nums; margin-top: 3px; }
72 /* ---- stats ---- */
73 .stats { padding: 6px 4px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border-radius: 10px; overflow: hidden; padding: 0; }
74 @media (max-width: 820px) { .stats { grid-template-columns: repeat(2, 1fr); } }
75 .stat { background: var(--panel); padding: 12px 14px; }
76 .stat .k { font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
77 .stat .v { font-size: 1.15rem; font-variant-numeric: tabular-nums; margin-top: 2px; }
78 .stat .v small { font-size: .72rem; color: var(--dim); font-variant-numeric: normal; }
80 .note { color: var(--dim); font-size: .88rem; margin-top: 22px; max-width: 78ch; }
81 .note b { color: var(--fg); font-weight: 600; }
82 a { color: var(--accent); }
83</style>
84</head>
85<body>
86<div class="wrap">
88 <h1>How many random points does a disk need?</h1>
89 <p class="lede">
90 Draw <em>N</em> points uniformly at random from a disk and bin them into a grid of
91 voxels. Each voxel's count is a Poisson-ish random variable with mean
92 <code>μ</code> = points per voxel, so its relative noise is
93 <code>1/√μ</code> — independent of how big the disk is. Sweep <em>N</em> and the
94 resolution below to see when the sampled disk stops looking speckled and starts
95 looking like a disk.
96 </p>
98 <div class="card controls">
99 <div>
100 <label for="n">Points <span class="val" id="nVal"></span></label>
101 <input type="range" id="n" min="200" max="750" step="1" value="500" />
102 </div>
103 <div>
104 <label for="res">Resolution (voxels across diameter)</label>
105 <select id="res">
106 <option value="8">8 × 8</option>
107 <option value="16">16 × 16</option>
108 <option value="32">32 × 32</option>
109 <option value="64" selected>64 × 64</option>
110 <option value="128">128 × 128</option>
111 <option value="256">256 × 256</option>
112 <option value="512">512 × 512</option>
113 </select>
114 </div>
115 <div style="display:flex;align-items:center;gap:10px">
116 <button id="resample">New sample</button>
117 <span class="badge" id="backend" title="Points are binned by a WebGPU compute shader when available, otherwise on the CPU">CPU</span>
118 </div>
119 </div>
121 <div class="panels">
122 <div class="card panel">
123 <h2>Ideal</h2>
124 <p>Exact density: each voxel shaded by the fraction of it inside the disk.</p>
125 <canvas id="cIdeal"></canvas>
126 <div class="bar" id="barSeq"></div>
127 <div class="ticks"><span>0</span><span>0.8×</span><span>1.6× ideal</span></div>
128 </div>
129 <div class="card panel">
130 <h2>Sampled</h2>
131 <p>Counts from <span id="nInline"></span> random points, on the same color scale.</p>
132 <canvas id="cSampled"></canvas>
133 <div class="bar" id="barSeq2"></div>
134 <div class="ticks"><span>0</span><span>0.8×</span><span>1.6× ideal</span></div>
135 </div>
136 <div class="card panel">
137 <h2>Noise</h2>
138 <p>Error in units of the expected standard deviation, <span style="white-space:nowrap">(count − ideal) / √ideal</span>.</p>
139 <canvas id="cNoise"></canvas>
140 <div class="bar" id="barDiv"></div>
141 <div class="ticks"><span>−3σ</span><span>0</span><span>+3σ</span></div>
142 </div>
143 </div>
145 <div class="stats">
146 <div class="stat"><div class="k">Points per voxel μ</div><div class="v" id="sMu"></div></div>
147 <div class="stat"><div class="k">Measured noise</div><div class="v" id="sCv"></div></div>
148 <div class="stat"><div class="k">Predicted 1/√μ</div><div class="v" id="sPred"></div></div>
149 <div class="stat"><div class="k">Interior voxels</div><div class="v" id="sK"></div></div>
150 <div class="stat"><div class="k">Count range (interior)</div><div class="v" id="sRange"></div></div>
151 <div class="stat"><div class="k">Voxel width</div><div class="v" id="sVox"></div></div>
152 <div class="stat">
153 <div class="k">Points for <select id="target" style="width:auto;padding:1px 4px;font-size:.74rem;background:transparent;border:none;color:var(--accent)"><option value="0.10">10%</option><option value="0.05" selected>5%</option><option value="0.02">2%</option><option value="0.01">1%</option></select> noise</div>
154 <div class="v" id="sNeed"></div>
155 </div>
156 <div class="stat"><div class="k">Sample time</div><div class="v" id="sTime"></div></div>
157 </div>
159 <p class="note">
160 <b>Why this matters for MRI simulation.</b> Discretizing a continuous object into
161 isochromats is exactly this experiment: the signal in a voxel is a sum over the
162 isochromats that landed in it, so random placement injects noise of order
163 <code>1/√μ</code> on top of the physics. Statistics are computed only over
164 voxels lying <em>entirely</em> inside the disk — boundary voxels have a smaller
165 expected count and would otherwise inflate the measured spread. Note that the noise
166 panel looks like white noise at every setting: what improves with more points is not
167 its character but its size relative to the signal.
168 </p>
170</div>
172<script>
173const TAU = Math.PI * 2;
175// ---------- colormaps ----------
176const INFERNO = [
177 [0,0,4],[20,11,53],[66,10,104],[106,23,110],[147,38,103],
178 [188,55,84],[221,81,58],[249,142,9],[252,255,164],
179];
180const DIVERGING = [
181 [33,102,172],[103,169,207],[236,238,241],[239,138,98],[178,24,43],
182];
184function makeLut(stops) {
185 const lut = new Uint8Array(256 * 3);
186 const n = stops.length - 1;
187 for (let i = 0; i < 256; i++) {
188 const s = (i / 255) * n;
189 const j = Math.min(n - 1, Math.floor(s));
190 const f = s - j;
191 const a = stops[j], b = stops[j + 1];
192 lut[i * 3 + 0] = a[0] + (b[0] - a[0]) * f;
193 lut[i * 3 + 1] = a[1] + (b[1] - a[1]) * f;
194 lut[i * 3 + 2] = a[2] + (b[2] - a[2]) * f;
195 }
196 return lut;
197}
198const LUT_SEQ = makeLut(INFERNO);
199const LUT_DIV = makeLut(DIVERGING);
201function cssGradient(lut) {
202 const parts = [];
203 for (let i = 0; i <= 16; i++) {
204 const k = Math.round((i / 16) * 255) * 3;
205 parts.push(`rgb(${lut[k]},${lut[k+1]},${lut[k+2]}) ${(i / 16 * 100).toFixed(1)}%`);
206 }
207 return `linear-gradient(to right, ${parts.join(',')})`;
208}
210// ---------- geometry: exact-ish area fraction of each voxel inside the unit disk ----------
211// Domain is [-1,1]^2, so the grid spans the full diameter. Sub-sampled per voxel;
212// cached because it only depends on the resolution.
213const fracCache = new Map();
214function areaFractions(G) {
215 const hit = fracCache.get(G);
216 if (hit) return hit;
217 const S = G >= 256 ? 4 : 8; // sub-samples per axis
218 const frac = new Float32Array(G * G);
219 const h = 2 / G, sh = h / S;
220 for (let iy = 0; iy < G; iy++) {
221 const y0 = -1 + iy * h;
222 for (let ix = 0; ix < G; ix++) {
223 const x0 = -1 + ix * h;
224 let inside = 0;
225 for (let sy = 0; sy < S; sy++) {
226 const y = y0 + (sy + 0.5) * sh;
227 for (let sx = 0; sx < S; sx++) {
228 const x = x0 + (sx + 0.5) * sh;
229 if (x * x + y * y <= 1) inside++;
230 }
231 }
232 frac[iy * G + ix] = inside / (S * S);
233 }
234 }
235 fracCache.set(G, frac);
236 return frac;
237}
239// ---------- sampling ----------
240function sampleCounts(N, G) {
241 const counts = new Uint32Array(G * G);
242 const half = G / 2;
243 for (let i = 0; i < N; i++) {
244 const r = Math.sqrt(Math.random()); // sqrt for uniform area density
245 const t = TAU * Math.random();
246 let ix = (r * Math.cos(t) + 1) * half | 0;
247 let iy = (r * Math.sin(t) + 1) * half | 0;
248 if (ix >= G) ix = G - 1;
249 if (iy >= G) iy = G - 1;
250 counts[iy * G + ix]++;
251 }
252 return counts;
253}
255// ---------- drawing ----------
256const SCALE_MAX = 1.6; // top of the sequential color scale, in units of the ideal density
258function paint(canvas, G, valueAt) {
259 canvas.width = G;
260 canvas.height = G;
261 const ctx = canvas.getContext('2d');
262 const img = ctx.createImageData(G, G);
263 const d = img.data;
264 for (let p = 0; p < G * G; p++) {
265 const rgb = valueAt(p);
266 d[p * 4 + 0] = rgb[0];
267 d[p * 4 + 1] = rgb[1];
268 d[p * 4 + 2] = rgb[2];
269 d[p * 4 + 3] = 255;
270 }
271 ctx.putImageData(img, 0, 0);
272}
274const OUTSIDE = [22, 26, 35]; // neutral fill for voxels with no expected signal
275const rgbSeq = (t) => {
276 const k = Math.max(0, Math.min(255, Math.round(t * 255))) * 3;
277 return [LUT_SEQ[k], LUT_SEQ[k + 1], LUT_SEQ[k + 2]];
278};
279const rgbDiv = (t) => {
280 const k = Math.max(0, Math.min(255, Math.round(t * 255))) * 3;
281 return [LUT_DIV[k], LUT_DIV[k + 1], LUT_DIV[k + 2]];
282};
284// ---------- formatting ----------
285function fmtCount(n) {
286 if (n >= 1e6) {
287 const m = n / 1e6;
288 return (m >= 100 ? m.toFixed(0) : m.toFixed(m >= 10 ? 1 : 2)) + 'M';
289 }
290 if (n >= 1e4) return (n / 1e3).toFixed(0) + 'k';
291 return n.toLocaleString('en-US');
292}
293function fmtMu(mu) {
294 if (mu >= 1000) return fmtCount(Math.round(mu));
295 if (mu >= 10) return mu.toFixed(0);
296 if (mu >= 1) return mu.toFixed(2);
297 return mu.toFixed(3);
298}
299function fmtPct(x) {
300 if (!isFinite(x)) return '—';
301 if (x >= 1) return (x * 100).toFixed(0) + '%';
302 if (x >= 0.1) return (x * 100).toFixed(1) + '%';
303 return (x * 100).toFixed(2) + '%';
304}
306// ---------- GPU sampling ----------
307// The histogram is the whole cost of this demo, and it is embarrassingly parallel: one
308// compute shader draws the points and atomically bins them, so N can reach a billion.
309// The CPU path above stays as the fallback.
310const MAX_G = 512, MAX_BINS = MAX_G * MAX_G;
311const WG_SIZE = 256, WORKGROUPS = 1024; // 262144 grid-stride threads
312const LOCAL_BINS = 1024; // workgroup-private histogram, for G <= 32
314const WGSL = `
315struct Params { g: u32, n: u32, seed: u32, use_local: u32 }
316@group(0) @binding(0) var<uniform> P: Params;
317@group(0) @binding(1) var<storage, read_write> counts: array<atomic<u32>>;
319// Coarse grids would serialize on a handful of global counters, so each workgroup
320// accumulates privately and flushes once per bin at the end.
321var<workgroup> local_hist: array<atomic<u32>, ${LOCAL_BINS}>;
323// PCG (rxs-m-xs). Validated against the Poisson prediction before being trusted here:
324// a hash with visible structure would paint that structure straight into the heatmap.
325fn hash(x: u32) -> u32 {
326 var s = x * 747796405u + 2891336453u;
327 s = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
328 return (s >> 22u) ^ s;
329}
330fn pcg(state: ptr<function, u32>) -> u32 {
331 let old = *state;
332 *state = old * 747796405u + 2891336453u;
333 let word = ((old >> ((old >> 28u) + 4u)) ^ old) * 277803737u;
334 return (word >> 22u) ^ word;
335}
337const INV32: f32 = 2.3283064365386963e-10;
338const TAU: f32 = 6.283185307179586;
340@compute @workgroup_size(${WG_SIZE})
341fn main(@builtin(global_invocation_id) gid: vec3<u32>,
342 @builtin(local_invocation_id) lid: vec3<u32>,
343 @builtin(num_workgroups) nwg: vec3<u32>) {
344 let nbins = P.g * P.g;
345 let use_local = P.use_local == 1u;
347 if (use_local) {
348 for (var i = lid.x; i < nbins; i = i + ${WG_SIZE}u) {
349 atomicStore(&local_hist[i], 0u);
350 }
351 }
352 workgroupBarrier();
354 var state = hash(gid.x ^ hash(P.seed));
355 let stride = nwg.x * ${WG_SIZE}u;
356 let half = f32(P.g) * 0.5;
357 let top = f32(P.g) - 1.0;
359 for (var idx = gid.x; idx < P.n; idx = idx + stride) {
360 let r = sqrt(f32(pcg(&state)) * INV32); // sqrt for uniform area density
361 let t = TAU * f32(pcg(&state)) * INV32;
362 let ix = u32(clamp((r * cos(t) + 1.0) * half, 0.0, top));
363 let iy = u32(clamp((r * sin(t) + 1.0) * half, 0.0, top));
364 let bin = iy * P.g + ix;
365 if (use_local) { atomicAdd(&local_hist[bin], 1u); }
366 else { atomicAdd(&counts[bin], 1u); }
367 }
369 workgroupBarrier();
370 if (use_local) {
371 for (var i = lid.x; i < nbins; i = i + ${WG_SIZE}u) {
372 let v = atomicLoad(&local_hist[i]);
373 if (v > 0u) { atomicAdd(&counts[i], v); }
374 }
375 }
376}
377`;
379let gpu = null;
380let gpuReason = ''; // why the GPU path is unavailable, surfaced on the badge tooltip
381let seed = 1;
383async function initGPU() {
384 if (!navigator.gpu) {
385 gpuReason = 'navigator.gpu is undefined — this browser exposes no WebGPU.';
386 console.warn('WebGPU: ' + gpuReason);
387 return null;
388 }
389 try {
390 // A software adapter is still much faster than the JS path, so take one if offered.
391 let adapter = await navigator.gpu.requestAdapter();
392 if (!adapter) adapter = await navigator.gpu.requestAdapter({ forceFallbackAdapter: true });
393 if (!adapter) {
394 gpuReason = 'requestAdapter() returned null — no WebGPU adapter available. '
395 + 'On Linux, Chrome needs its Vulkan backend: try enabling chrome://flags/#enable-unsafe-webgpu, '
396 + 'and check chrome://gpu for the WebGPU and Vulkan rows.';
397 console.warn('WebGPU: ' + gpuReason);
398 return null;
399 }
400 const device = await adapter.requestDevice();
401 const module = device.createShaderModule({ code: WGSL });
402 if (module.getCompilationInfo) {
403 const info = await module.getCompilationInfo();
404 const errs = info.messages.filter((m) => m.type === 'error');
405 if (errs.length) {
406 gpuReason = 'the compute shader failed to compile; see the console.';
407 console.error('WGSL compilation failed', errs);
408 return null;
409 }
410 }
411 const pipeline = device.createComputePipeline({
412 layout: 'auto', compute: { module, entryPoint: 'main' },
413 });
414 const U = GPUBufferUsage;
415 const params = device.createBuffer({ size: 16, usage: U.UNIFORM | U.COPY_DST });
416 const counts = device.createBuffer({ size: MAX_BINS * 4, usage: U.STORAGE | U.COPY_SRC | U.COPY_DST });
417 const stage = device.createBuffer({ size: MAX_BINS * 4, usage: U.COPY_DST | U.MAP_READ });
418 const bind = device.createBindGroup({
419 layout: pipeline.getBindGroupLayout(0),
420 entries: [
421 { binding: 0, resource: { buffer: params } },
422 { binding: 1, resource: { buffer: counts } },
423 ],
424 });
425 device.addEventListener('uncapturederror', (e) => console.error('WebGPU error', e.error));
426 device.lost.then(() => { gpu = null; showBackend(); });
427 return { device, pipeline, params, counts, stage, bind, host: new Uint32Array(MAX_BINS) };
428 } catch (e) {
429 gpuReason = 'WebGPU initialization threw: ' + e;
430 console.warn('WebGPU unavailable', e);
431 return null;
432 }
433}
435async function gpuCounts(N, G) {
436 const g = gpu;
437 const bytes = G * G * 4;
438 g.device.queue.writeBuffer(g.params, 0,
439 new Uint32Array([G, N, seed, G * G <= LOCAL_BINS ? 1 : 0]));
440 const enc = g.device.createCommandEncoder();
441 enc.clearBuffer(g.counts, 0, bytes);
442 const pass = enc.beginComputePass();
443 pass.setPipeline(g.pipeline);
444 pass.setBindGroup(0, g.bind);
445 pass.dispatchWorkgroups(WORKGROUPS);
446 pass.end();
447 enc.copyBufferToBuffer(g.counts, 0, g.stage, 0, bytes);
448 g.device.queue.submit([enc.finish()]);
449 await g.stage.mapAsync(GPUMapMode.READ, 0, bytes);
450 g.host.set(new Uint32Array(g.stage.getMappedRange(0, bytes)));
451 g.stage.unmap();
452 return g.host; // only [0, G*G) is meaningful
453}
455// Falls back permanently to the CPU if the GPU path ever throws mid-session. Returns null
456// in that case rather than sampling: N may be far above what the CPU can take, so the
457// caller re-runs after showBackend() has clamped the slider back to the CPU range.
458async function getCounts(N, G) {
459 if (gpu) {
460 try { return await gpuCounts(N, G); }
461 catch (e) {
462 console.warn('GPU sampling failed, falling back to CPU', e);
463 gpu = null;
464 showBackend();
465 return null;
466 }
467 }
468 return sampleCounts(N, G);
469}
471// ---------- app ----------
472const el = (id) => document.getElementById(id);
473const nSlider = el('n'), resSel = el('res'), targetSel = el('target');
475document.getElementById('barSeq').style.background = cssGradient(LUT_SEQ);
476document.getElementById('barSeq2').style.background = cssGradient(LUT_SEQ);
477document.getElementById('barDiv').style.background = cssGradient(LUT_DIV);
479const sliderToN = (v) => {
480 const n = Math.pow(10, v / 100);
481 // round to 3 significant figures so the readout is stable while dragging
482 const mag = Math.pow(10, Math.floor(Math.log10(n)) - 2);
483 return Math.max(1, Math.round(n / mag) * mag);
484};
486function showN(N) {
487 el('nVal').textContent = '= ' + N.toLocaleString('en-US');
488 el('nInline').textContent = fmtCount(N);
489}
491// The CPU tops out around 30M points before a drag becomes unpleasant; the GPU
492// comfortably reaches a billion, which is where fine grids get interesting.
493const CPU_MAX = 750, GPU_MAX = 900; // slider units, = log10(N) * 100
495function showBackend() {
496 const b = el('backend');
497 b.textContent = gpu ? 'WebGPU' : 'CPU';
498 b.className = 'badge' + (gpu ? ' on' : '');
499 b.title = gpu
500 ? 'Points are binned by a WebGPU compute shader.'
501 : 'Binning on the CPU because ' + (gpuReason || 'the GPU path has not initialized yet.');
502 const max = gpu ? GPU_MAX : CPU_MAX;
503 nSlider.max = String(max);
504 if (+nSlider.value > max) nSlider.value = String(max);
505}
507let lastElapsed = 0;
509async function draw() {
510 const N = sliderToN(+nSlider.value);
511 const G = +resSel.value;
513 showN(N);
515 const frac = areaFractions(G);
516 const t0 = performance.now();
517 const counts = await getCounts(N, G);
518 if (!counts) { queued = true; return; } // backend just changed; redraw within the new limits
519 const elapsed = lastElapsed = performance.now() - t0;
521 // Expected count in a fully interior voxel: N * (voxel area / disk area).
522 const mu = N * (4 / (G * G)) / Math.PI;
524 paint(el('cIdeal'), G, (p) => rgbSeq(frac[p] / SCALE_MAX));
525 paint(el('cSampled'), G, (p) => rgbSeq(counts[p] / mu / SCALE_MAX));
526 paint(el('cNoise'), G, (p) => {
527 const expected = mu * frac[p];
528 if (expected <= 0) return OUTSIDE;
529 const z = (counts[p] - expected) / Math.sqrt(expected);
530 return rgbDiv(z / 6 + 0.5); // ±3σ across the scale
531 });
533 // Statistics over voxels entirely inside the disk.
534 let k = 0, sum = 0, sumSq = 0, lo = Infinity, hi = -Infinity;
535 for (let p = 0; p < G * G; p++) {
536 if (frac[p] < 1) continue;
537 const c = counts[p];
538 k++; sum += c; sumSq += c * c;
539 if (c < lo) lo = c;
540 if (c > hi) hi = c;
541 }
542 const mean = k ? sum / k : 0;
543 const variance = k > 1 ? Math.max(0, sumSq / k - mean * mean) * k / (k - 1) : 0;
544 const cv = mean > 0 ? Math.sqrt(variance) / mean : Infinity;
546 el('sMu').innerHTML = fmtMu(mu) + ' <small>expected</small>';
547 el('sCv').textContent = k ? fmtPct(cv) : '—';
548 el('sPred').textContent = mu > 0 ? fmtPct(1 / Math.sqrt(mu)) : '—';
549 el('sK').innerHTML = k.toLocaleString('en-US') + ' <small>of ' + (G * G).toLocaleString('en-US') + '</small>';
550 el('sRange').textContent = k ? lo.toLocaleString('en-US') + ' – ' + hi.toLocaleString('en-US') : '—';
551 el('sVox').innerHTML = (2 / G).toPrecision(3).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') + ' <small>disk radii</small>';
553 const c = +targetSel.value;
554 const need = Math.PI * G * G / (4 * c * c);
555 el('sNeed').innerHTML = fmtCount(Math.round(need)) + ' <small>points</small>';
557 el('sTime').innerHTML = (elapsed < 10 ? elapsed.toFixed(1) : elapsed.toFixed(0))
558 + ' <small>ms · ' + (gpu ? 'GPU' : 'CPU') + '</small>';
559}
561// Sampling is async now, so serialize runs and collapse anything that arrives while one
562// is in flight down to a single trailing update.
563let running = false, queued = false;
564async function update() {
565 if (running) { queued = true; return; }
566 running = true;
567 try { await draw(); }
568 catch (e) { console.error(e); }
569 finally {
570 running = false;
571 if (queued) { queued = false; schedule(); }
572 }
573}
575// Coalesce rapid input into one update per frame; once a sample gets slow enough to
576// stutter a drag, wait for the slider to settle instead.
577let pending = false, timer = 0;
578function schedule() {
579 clearTimeout(timer);
580 if (lastElapsed > 120) {
581 timer = setTimeout(update, 180);
582 return;
583 }
584 if (pending) return;
585 pending = true;
586 requestAnimationFrame(() => { pending = false; update(); });
587}
589// The readout is cheap, so keep it live even while a heavy recompute is deferred.
590nSlider.addEventListener('input', () => { showN(sliderToN(+nSlider.value)); schedule(); });
591resSel.addEventListener('change', schedule);
592targetSel.addEventListener('change', schedule);
593el('resample').addEventListener('click', () => { seed++; update(); });
595// Draw immediately on the CPU so the page is never blank, then upgrade to the GPU.
596showBackend();
597update();
598initGPU().then((g) => {
599 if (!g) return;
600 gpu = g;
601 showBackend();
602 update();
603});
604</script>
605</body>
606</html>