| [084b046] | 1 | /**
|
|---|
| 2 | * De avatarhemel: het model en de tekenlaag, met een naad ertussen.
|
|---|
| 3 | *
|
|---|
| 4 | * DE NAAD IS HET PUNT. `orbitPlacement` en `nearestFace` zijn pure wiskunde en
|
|---|
| 5 | * bestaan al in Swift en Kotlin; dit is de derde schrijving en dus de derde
|
|---|
| 6 | * kans op drift. Daarom staan ze hier apart, met dezelfde tests als daar, en
|
|---|
| 7 | * raakt de renderer ze niet aan. Blijkt Canvas 2D ooit te krap bij duizenden
|
|---|
| 8 | * gezichten, dan vervangt een WebGL-variant alleen `SkyRenderer` en merkt de
|
|---|
| 9 | * rest er niets van.
|
|---|
| 10 | *
|
|---|
| 11 | * Waarom Canvas en (nog) geen engine: de rem is niet het aantal sprites maar
|
|---|
| 12 | * het aantal texturen. Alle avatars gaan één keer in een atlas (één offscreen
|
|---|
| 13 | * canvas), en daarna is elk gezicht een `drawImage` uit datzelfde plaatje.
|
|---|
| 14 | */
|
|---|
| 15 | 'use strict';
|
|---|
| 16 |
|
|---|
| 17 | const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|---|
| 18 |
|
|---|
| 19 | /**
|
|---|
| 20 | * Waar elk gezicht hangt, in eenheden van één avatarcel, met de oorsprong in
|
|---|
| 21 | * het midden. Hoe verder van het midden, hoe langer geleden je iets van
|
|---|
| 22 | * diegene hoorde: log-geschaald, want het verschil tussen een uur en een dag
|
|---|
| 23 | * telt zwaarder dan tussen een jaar en een jaar-en-een-dag.
|
|---|
| 24 | *
|
|---|
| 25 | * Letterlijke port van de Kotlin- en Swift-versie, inclusief de push-apart.
|
|---|
| 26 | */
|
|---|
| 27 | export function orbitPlacement(agesMs) {
|
|---|
| 28 | const n = agesMs.length;
|
|---|
| 29 | if (!n) return [];
|
|---|
| 30 | const logs = agesMs.map((a) => Math.log(1 + Math.max(0, a) / 3600000));
|
|---|
| 31 | const lo = Math.min(...logs);
|
|---|
| 32 | const hi = Math.max(...logs);
|
|---|
| 33 | const norm = logs.map((l) => (hi > lo ? (l - lo) / (hi - lo) : 0.5));
|
|---|
| 34 | // De ruimte groeit met de drukte, zodat de push-apart convergeert in plaats
|
|---|
| 35 | // van tegen zichzelf te duwen.
|
|---|
| 36 | const spread = Math.max(3, Math.sqrt(n) * 1.1);
|
|---|
| 37 | // De hoek volgt de RANG in tijd, zodat mensen die kort na elkaar schreven
|
|---|
| 38 | // niet op een hoop komen.
|
|---|
| 39 | const rank = new Map(
|
|---|
| 40 | agesMs.map((a, i) => [i, a]).sort((p, q) => p[1] - q[1]).map(([i], r) => [i, r]),
|
|---|
| 41 | );
|
|---|
| 42 | const xs = new Float64Array(n);
|
|---|
| 43 | const ys = new Float64Array(n);
|
|---|
| 44 | for (let i = 0; i < n; i++) {
|
|---|
| 45 | const r = 1.4 + norm[i] * spread;
|
|---|
| 46 | const a = (rank.get(i) ?? i) * GOLDEN;
|
|---|
| 47 | xs[i] = r * Math.cos(a);
|
|---|
| 48 | ys[i] = r * Math.sin(a);
|
|---|
| 49 | }
|
|---|
| 50 | const minD = 1.15;
|
|---|
| 51 | for (let pass = 0; pass < 120; pass++) {
|
|---|
| 52 | let moved = false;
|
|---|
| 53 | for (let i = 0; i < n; i++) {
|
|---|
| 54 | for (let j = i + 1; j < n; j++) {
|
|---|
| 55 | const dx = xs[j] - xs[i];
|
|---|
| 56 | const dy = ys[j] - ys[i];
|
|---|
| 57 | const d = Math.sqrt(dx * dx + dy * dy);
|
|---|
| 58 | if (d < minD) {
|
|---|
| 59 | moved = true;
|
|---|
| 60 | const push = (minD - d) / 2 + 1e-3;
|
|---|
| 61 | // Precies samenvallende plekken splitsen langs hun eigen indexhoek.
|
|---|
| 62 | const ux = d > 1e-6 ? dx / d : Math.cos(i * GOLDEN);
|
|---|
| 63 | const uy = d > 1e-6 ? dy / d : Math.sin(i * GOLDEN);
|
|---|
| 64 | xs[i] -= ux * push; ys[i] -= uy * push;
|
|---|
| 65 | xs[j] += ux * push; ys[j] += uy * push;
|
|---|
| 66 | }
|
|---|
| 67 | }
|
|---|
| 68 | }
|
|---|
| 69 | if (!moved) break;
|
|---|
| 70 | }
|
|---|
| 71 | return Array.from({ length: n }, (_, i) => ({ x: xs[i], y: ys[i] }));
|
|---|
| 72 | }
|
|---|
| 73 |
|
|---|
| 74 | /**
|
|---|
| 75 | * Het gezicht dat het dichtst bij een punt ligt, of null als je ernaast mikte.
|
|---|
| 76 | *
|
|---|
| 77 | * Dezelfde rekensom als de plaatsing, ZONDER de ademhaling: die beweegt hooguit
|
|---|
| 78 | * een paar pixels en zou de treffer alleen maar grillig maken.
|
|---|
| 79 | */
|
|---|
| 80 | export function nearestFace(px, py, spots, { cx, cy, cell, scale, panX, panY }) {
|
|---|
| 81 | const reach = (cell / 2) * Math.min(Math.max(scale, 0.5), 1.6);
|
|---|
| 82 | let best = -1;
|
|---|
| 83 | let bestD = Infinity;
|
|---|
| 84 | for (let i = 0; i < spots.length; i++) {
|
|---|
| 85 | const x = cx + panX + spots[i].x * cell * scale;
|
|---|
| 86 | const y = cy + panY + spots[i].y * cell * scale;
|
|---|
| 87 | const d = Math.hypot(px - x, py - y);
|
|---|
| 88 | if (d <= reach && d < bestD) { best = i; bestD = d; }
|
|---|
| 89 | }
|
|---|
| 90 | return best < 0 ? null : best;
|
|---|
| 91 | }
|
|---|
| 92 |
|
|---|
| 93 | /* ── De tekenlaag ─────────────────────────────────────────────────────────
|
|---|
| 94 | Alles hieronder raakt de DOM en is dus vervangbaar; alles hierboven niet. */
|
|---|
| 95 |
|
|---|
| 96 | const CELL = 92; // één avatarcel, zelfde maat als in de apps
|
|---|
| 97 | const FACE = 44; // de tekening zelf
|
|---|
| 98 | const ATLAS_PAD = 4;
|
|---|
| 99 |
|
|---|
| 100 | /** Eén canvas met alle avatars naast elkaar, zodat tekenen één drawImage is. */
|
|---|
| 101 | async function buildAtlas(faces) {
|
|---|
| 102 | const size = FACE * 2; // retina-scherp
|
|---|
| 103 | const cols = Math.max(1, Math.ceil(Math.sqrt(faces.length || 1)));
|
|---|
| 104 | const rows = Math.max(1, Math.ceil((faces.length || 1) / cols));
|
|---|
| 105 | const atlas = document.createElement('canvas');
|
|---|
| 106 | atlas.width = cols * (size + ATLAS_PAD);
|
|---|
| 107 | atlas.height = rows * (size + ATLAS_PAD);
|
|---|
| 108 | const g = atlas.getContext('2d');
|
|---|
| 109 |
|
|---|
| 110 | await Promise.all(faces.map((f, i) => new Promise((done) => {
|
|---|
| 111 | const col = i % cols;
|
|---|
| 112 | const row = Math.floor(i / cols);
|
|---|
| 113 | const x = col * (size + ATLAS_PAD);
|
|---|
| 114 | const y = row * (size + ATLAS_PAD);
|
|---|
| 115 | f.atlas = { x, y, size };
|
|---|
| 116 |
|
|---|
| 117 | const drawInitial = () => {
|
|---|
| 118 | // Geen plaatje? Dan de eerste letter op een gekleurde schijf, zoals de
|
|---|
| 119 | // apps ook doen: een gezicht zonder foto is nog steeds een persoon.
|
|---|
| 120 | g.save();
|
|---|
| 121 | g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
|
|---|
| 122 | g.fillStyle = '#5a32e6'; g.fillRect(x, y, size, size);
|
|---|
| 123 | g.fillStyle = '#fff';
|
|---|
| 124 | g.font = `600 ${size * 0.45}px system-ui, sans-serif`;
|
|---|
| 125 | g.textAlign = 'center'; g.textBaseline = 'middle';
|
|---|
| 126 | g.fillText((f.name || '?').trim().charAt(0).toUpperCase(), x + size / 2, y + size / 2);
|
|---|
| 127 | g.restore();
|
|---|
| 128 | done();
|
|---|
| 129 | };
|
|---|
| 130 |
|
|---|
| 131 | if (!f.icon) return drawInitial();
|
|---|
| 132 | const img = new Image();
|
|---|
| 133 | img.crossOrigin = 'anonymous';
|
|---|
| 134 | img.onload = () => {
|
|---|
| 135 | g.save();
|
|---|
| 136 | g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
|
|---|
| 137 | g.drawImage(img, x, y, size, size);
|
|---|
| 138 | g.restore();
|
|---|
| 139 | done();
|
|---|
| 140 | };
|
|---|
| 141 | // Een avatar van een andere server kan weigeren; dan de letter, niet niets.
|
|---|
| 142 | img.onerror = drawInitial;
|
|---|
| 143 | img.src = f.icon;
|
|---|
| 144 | })));
|
|---|
| 145 |
|
|---|
| 146 | return atlas;
|
|---|
| 147 | }
|
|---|
| 148 |
|
|---|
| 149 | export class SkyRenderer {
|
|---|
| 150 | /**
|
|---|
| 151 | * @param {HTMLCanvasElement} canvas
|
|---|
| 152 | * @param {(index:number)=>void} onPick een gezicht aangetikt
|
|---|
| 153 | */
|
|---|
| 154 | constructor(canvas, onPick) {
|
|---|
| 155 | this.canvas = canvas;
|
|---|
| 156 | this.g = canvas.getContext('2d');
|
|---|
| 157 | this.onPick = onPick;
|
|---|
| 158 | this.faces = [];
|
|---|
| 159 | this.spots = [];
|
|---|
| 160 | this.scale = 1;
|
|---|
| 161 | this.panX = 0;
|
|---|
| 162 | this.panY = 0;
|
|---|
| 163 | this.running = false;
|
|---|
| 164 | this.interactive = false;
|
|---|
| 165 | this._wire();
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | /** @param {{name:string, icon:string|null, ageMs:number}[]} faces */
|
|---|
| 169 | async setFaces(faces) {
|
|---|
| 170 | this.faces = faces;
|
|---|
| 171 | this.spots = orbitPlacement(faces.map((f) => f.ageMs));
|
|---|
| 172 | this.atlas = await buildAtlas(faces);
|
|---|
| 173 | this._resize();
|
|---|
| 174 | }
|
|---|
| 175 |
|
|---|
| 176 | start() { if (!this.running) { this.running = true; this._loop(); } }
|
|---|
| 177 | stop() { this.running = false; }
|
|---|
| 178 |
|
|---|
| 179 | _resize() {
|
|---|
| 180 | const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|---|
| 181 | const r = this.canvas.getBoundingClientRect();
|
|---|
| 182 | this.canvas.width = Math.max(1, r.width * dpr);
|
|---|
| 183 | this.canvas.height = Math.max(1, r.height * dpr);
|
|---|
| 184 | this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|---|
| 185 | this.w = r.width; this.h = r.height;
|
|---|
| 186 | }
|
|---|
| 187 |
|
|---|
| 188 | _loop() {
|
|---|
| 189 | if (!this.running) return;
|
|---|
| 190 | this._draw(performance.now());
|
|---|
| 191 | requestAnimationFrame(() => this._loop());
|
|---|
| 192 | }
|
|---|
| 193 |
|
|---|
| 194 | _draw(t) {
|
|---|
| 195 | const { g } = this;
|
|---|
| 196 | g.clearRect(0, 0, this.w, this.h);
|
|---|
| 197 | if (!this.atlas) return;
|
|---|
| 198 | const cx = this.w / 2;
|
|---|
| 199 | const cy = this.h / 2;
|
|---|
| 200 | // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
|
|---|
| 201 | // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
|
|---|
| 202 | const phase = ((t / 9000) % 1) * Math.PI * 2;
|
|---|
| 203 | const s = Math.min(Math.max(this.scale, 0.5), 1.6);
|
|---|
| 204 | const draw = FACE * s;
|
|---|
| 205 |
|
|---|
| 206 | for (let i = 0; i < this.spots.length; i++) {
|
|---|
| 207 | const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
|
|---|
| 208 | const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
|
|---|
| 209 | const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
|
|---|
| 210 | // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
|
|---|
| 211 | // enige die bij duizenden gezichten echt telt.
|
|---|
| 212 | if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
|
|---|
| 213 | const a = this.faces[i].atlas;
|
|---|
| 214 | g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
|
|---|
| 215 | }
|
|---|
| 216 | }
|
|---|
| 217 |
|
|---|
| 218 | _wire() {
|
|---|
| 219 | const c = this.canvas;
|
|---|
| 220 | let dragging = false, lastX = 0, lastY = 0, moved = 0;
|
|---|
| 221 | let pinch = null;
|
|---|
| 222 |
|
|---|
| 223 | c.addEventListener('pointerdown', (e) => {
|
|---|
| 224 | if (!this.interactive) return;
|
|---|
| 225 | dragging = true; moved = 0;
|
|---|
| 226 | lastX = e.clientX; lastY = e.clientY;
|
|---|
| 227 | c.setPointerCapture(e.pointerId);
|
|---|
| 228 | });
|
|---|
| 229 | c.addEventListener('pointermove', (e) => {
|
|---|
| 230 | if (!this.interactive || !dragging) return;
|
|---|
| 231 | const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
|---|
| 232 | moved += Math.abs(dx) + Math.abs(dy);
|
|---|
| 233 | this.panX += dx; this.panY += dy;
|
|---|
| 234 | lastX = e.clientX; lastY = e.clientY;
|
|---|
| 235 | });
|
|---|
| 236 | const end = (e) => {
|
|---|
| 237 | if (!this.interactive || !dragging) return;
|
|---|
| 238 | dragging = false;
|
|---|
| 239 | // Nauwelijks bewogen? Dan was het een tik en geen sleep.
|
|---|
| 240 | if (moved < 6) {
|
|---|
| 241 | const r = c.getBoundingClientRect();
|
|---|
| 242 | const hit = nearestFace(e.clientX - r.left, e.clientY - r.top, this.spots, {
|
|---|
| 243 | cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
|
|---|
| 244 | panX: this.panX, panY: this.panY,
|
|---|
| 245 | });
|
|---|
| 246 | if (hit != null) this.onPick?.(hit);
|
|---|
| 247 | }
|
|---|
| 248 | };
|
|---|
| 249 | c.addEventListener('pointerup', end);
|
|---|
| 250 | c.addEventListener('pointercancel', () => { dragging = false; });
|
|---|
| 251 |
|
|---|
| 252 | // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
|
|---|
| 253 | // midden, zodat de hemel zich als een kaart gedraagt.
|
|---|
| 254 | c.addEventListener('wheel', (e) => {
|
|---|
| 255 | if (!this.interactive) return;
|
|---|
| 256 | e.preventDefault();
|
|---|
| 257 | this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
|
|---|
| 258 | }, { passive: false });
|
|---|
| 259 |
|
|---|
| 260 | window.addEventListener('resize', () => this._resize());
|
|---|
| 261 | }
|
|---|
| 262 | }
|
|---|