| 20 | }; |
| 21 | |
| 22 | const createPointsChunked = async ( |
| 23 | count: number, |
| 24 | seed: number, |
| 25 | onProgress?: (done: number, total: number) => void |
| 26 | ): Promise<ReadonlyArray<ScatterPointTuple>> => { |
| 27 | const n = Math.max(0, Math.floor(count)); |
| 28 | const rng = mulberry32(seed); |
| 29 | const out: ScatterPointTuple[] = new Array(n); |
| 30 | |
| 31 | const chunk = 50_000; |
| 32 | for (let base = 0; base < n; base += chunk) { |
| 33 | const end = Math.min(n, base + chunk); |
| 34 | for (let i = base; i < end; i++) { |
| 35 | // Two Gaussian-ish blobs + background noise, then sort by x for efficient visible-range binning. |
| 36 | const t = rng(); |
| 37 | const blob = t < 0.6 ? 0 : 1; |
| 38 | const cx = blob === 0 ? 0.35 : 0.7; |
| 39 | const cy = blob === 0 ? 0.55 : 0.35; |
| 40 | const sx = blob === 0 ? 0.08 : 0.05; |
| 41 | const sy = blob === 0 ? 0.10 : 0.07; |
| 42 | |
| 43 | // Box-Muller-ish |
| 44 | const u1 = Math.max(1e-12, rng()); |
| 45 | const u2 = rng(); |
| 46 | const r = Math.sqrt(-2.0 * Math.log(u1)); |
| 47 | const theta = 2.0 * Math.PI * u2; |
| 48 | const gx = r * Math.cos(theta); |
| 49 | const gy = r * Math.sin(theta); |
| 50 | |
| 51 | const x = cx + gx * sx + (rng() - 0.5) * 0.03; |
| 52 | const y = cy + gy * sy + (rng() - 0.5) * 0.03; |
| 53 | out[i] = [x, y] as const; |
| 54 | } |
| 55 | onProgress?.(end, n); |
| 56 | await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); |
| 57 | } |
| 58 | |
| 59 | out.sort((a, b) => a[0] - b[0]); |
| 60 | return out; |
| 61 | }; |
| 62 | |
| 63 | const getEl = <T extends HTMLElement>(id: string): T => { |
| 64 | const el = document.getElementById(id); |