/** * De avatarhemel: het model en de tekenlaag, met een naad ertussen. * * DE NAAD IS HET PUNT. `orbitPlacement` en `nearestFace` zijn pure wiskunde en * bestaan al in Swift en Kotlin; dit is de derde schrijving en dus de derde * kans op drift. Daarom staan ze hier apart, met dezelfde tests als daar, en * raakt de renderer ze niet aan. Blijkt Canvas 2D ooit te krap bij duizenden * gezichten, dan vervangt een WebGL-variant alleen `SkyRenderer` en merkt de * rest er niets van. * * Waarom Canvas en (nog) geen engine: de rem is niet het aantal sprites maar * het aantal texturen. Alle avatars gaan één keer in een atlas (één offscreen * canvas), en daarna is elk gezicht een `drawImage` uit datzelfde plaatje. */ 'use strict'; const GOLDEN = Math.PI * (3 - Math.sqrt(5)); /** * Waar elk gezicht hangt, in eenheden van één avatarcel, met de oorsprong in * het midden. Hoe verder van het midden, hoe langer geleden je iets van * diegene hoorde: log-geschaald, want het verschil tussen een uur en een dag * telt zwaarder dan tussen een jaar en een jaar-en-een-dag. * * Letterlijke port van de Kotlin- en Swift-versie, inclusief de push-apart. */ export function orbitPlacement(agesMs) { const n = agesMs.length; if (!n) return []; const logs = agesMs.map((a) => Math.log(1 + Math.max(0, a) / 3600000)); const lo = Math.min(...logs); const hi = Math.max(...logs); const norm = logs.map((l) => (hi > lo ? (l - lo) / (hi - lo) : 0.5)); // De ruimte groeit met de drukte, zodat de push-apart convergeert in plaats // van tegen zichzelf te duwen. const spread = Math.max(3, Math.sqrt(n) * 1.1); // De hoek volgt de RANG in tijd, zodat mensen die kort na elkaar schreven // niet op een hoop komen. const rank = new Map( agesMs.map((a, i) => [i, a]).sort((p, q) => p[1] - q[1]).map(([i], r) => [i, r]), ); const xs = new Float64Array(n); const ys = new Float64Array(n); for (let i = 0; i < n; i++) { const r = 1.4 + norm[i] * spread; const a = (rank.get(i) ?? i) * GOLDEN; xs[i] = r * Math.cos(a); ys[i] = r * Math.sin(a); } const minD = 1.15; for (let pass = 0; pass < 120; pass++) { let moved = false; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const dx = xs[j] - xs[i]; const dy = ys[j] - ys[i]; const d = Math.sqrt(dx * dx + dy * dy); if (d < minD) { moved = true; const push = (minD - d) / 2 + 1e-3; // Precies samenvallende plekken splitsen langs hun eigen indexhoek. const ux = d > 1e-6 ? dx / d : Math.cos(i * GOLDEN); const uy = d > 1e-6 ? dy / d : Math.sin(i * GOLDEN); xs[i] -= ux * push; ys[i] -= uy * push; xs[j] += ux * push; ys[j] += uy * push; } } } if (!moved) break; } return Array.from({ length: n }, (_, i) => ({ x: xs[i], y: ys[i] })); } /** * Het gezicht dat het dichtst bij een punt ligt, of null als je ernaast mikte. * * Dezelfde rekensom als de plaatsing, ZONDER de ademhaling: die beweegt hooguit * een paar pixels en zou de treffer alleen maar grillig maken. */ export function nearestFace(px, py, spots, { cx, cy, cell, scale, panX, panY }) { const reach = (cell / 2) * Math.min(Math.max(scale, 0.5), 1.6); let best = -1; let bestD = Infinity; for (let i = 0; i < spots.length; i++) { const x = cx + panX + spots[i].x * cell * scale; const y = cy + panY + spots[i].y * cell * scale; const d = Math.hypot(px - x, py - y); if (d <= reach && d < bestD) { best = i; bestD = d; } } return best < 0 ? null : best; } /* ── De tekenlaag ───────────────────────────────────────────────────────── Alles hieronder raakt de DOM en is dus vervangbaar; alles hierboven niet. */ const CELL = 92; // één avatarcel, zelfde maat als in de apps const FACE = 44; // de tekening zelf const ATLAS_PAD = 4; /** Eén canvas met alle avatars naast elkaar, zodat tekenen één drawImage is. */ async function buildAtlas(faces) { const size = FACE * 2; // retina-scherp const cols = Math.max(1, Math.ceil(Math.sqrt(faces.length || 1))); const rows = Math.max(1, Math.ceil((faces.length || 1) / cols)); const atlas = document.createElement('canvas'); atlas.width = cols * (size + ATLAS_PAD); atlas.height = rows * (size + ATLAS_PAD); const g = atlas.getContext('2d'); await Promise.all(faces.map((f, i) => new Promise((done) => { const col = i % cols; const row = Math.floor(i / cols); const x = col * (size + ATLAS_PAD); const y = row * (size + ATLAS_PAD); f.atlas = { x, y, size }; const drawInitial = () => { // Geen plaatje? Dan de eerste letter op een gekleurde schijf, zoals de // apps ook doen: een gezicht zonder foto is nog steeds een persoon. g.save(); g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip(); g.fillStyle = '#5a32e6'; g.fillRect(x, y, size, size); g.fillStyle = '#fff'; g.font = `600 ${size * 0.45}px system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText((f.name || '?').trim().charAt(0).toUpperCase(), x + size / 2, y + size / 2); g.restore(); done(); }; if (!f.icon) return drawInitial(); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { g.save(); g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip(); g.drawImage(img, x, y, size, size); g.restore(); done(); }; // Een avatar van een andere server kan weigeren; dan de letter, niet niets. img.onerror = drawInitial; img.src = f.icon; }))); return atlas; } export class SkyRenderer { /** * @param {HTMLCanvasElement} canvas * @param {(index:number)=>void} onPick een gezicht aangetikt */ constructor(canvas, onPick, onNew) { this.canvas = canvas; this.g = canvas.getContext('2d'); this.onPick = onPick; // De (+) in het midden: de deur naar iemand NIEUW. Hij zit op de // OORSPRONG van het veld en pant en zoomt met de gezichten mee, zodat hij // altijd het midden van de vliegende avatars is, waar de hemel ook heen // drijft. De plaatsing houdt daar bewust ruimte vrij (elk gezicht hangt // minstens 1.4 cel naar buiten). this.onNew = onNew; this.faces = []; this.spots = []; this.scale = 1; this.panX = 0; this.panY = 0; this.running = false; this.interactive = false; this._wire(); } /** @param {{name:string, icon:string|null, ageMs:number}[]} faces */ async setFaces(faces) { this.faces = faces; this.spots = orbitPlacement(faces.map((f) => f.ageMs)); this.atlas = await buildAtlas(faces); this._resize(); } /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */ pick(px, py) { return nearestFace(px, py, this.spots, { cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale, panX: this.panX, panY: this.panY, }); } start() { if (!this.running) { this.running = true; this._loop(); } } stop() { this.running = false; } _resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const r = this.canvas.getBoundingClientRect(); this.canvas.width = Math.max(1, r.width * dpr); this.canvas.height = Math.max(1, r.height * dpr); this.g.setTransform(dpr, 0, 0, dpr, 0, 0); this.w = r.width; this.h = r.height; } _loop() { if (!this.running) return; this._draw(performance.now()); requestAnimationFrame(() => this._loop()); } _draw(t) { const { g } = this; g.clearRect(0, 0, this.w, this.h); if (!this.atlas) return; const cx = this.w / 2; const cy = this.h / 2; // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec). const phase = ((t / 9000) % 1) * Math.PI * 2; const s = Math.min(Math.max(this.scale, 0.5), 1.6); const draw = FACE * s; // De (+) eerst, dan de gezichten eroverheen als het krap wordt. if (this.interactive && this.onNew) { const px = cx + this.panX; const py = cy + this.panY; const pr = 22 * s; g.beginPath(); g.arc(px, py, pr, 0, Math.PI * 2); g.fillStyle = 'rgba(255,255,255,0.92)'; g.fill(); g.strokeStyle = '#11131a'; g.lineWidth = Math.max(2, 3 * s); g.lineCap = 'round'; g.beginPath(); g.moveTo(px - pr * 0.45, py); g.lineTo(px + pr * 0.45, py); g.moveTo(px, py - pr * 0.45); g.lineTo(px, py + pr * 0.45); g.stroke(); } for (let i = 0; i < this.spots.length; i++) { const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 }; const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale; const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale; // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de // enige die bij duizenden gezichten echt telt. if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue; const a = this.faces[i].atlas; g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw); } } _wire() { const c = this.canvas; let dragging = false, lastX = 0, lastY = 0, moved = 0; let pinch = null; c.addEventListener('pointerdown', (e) => { if (!this.interactive) return; dragging = true; moved = 0; lastX = e.clientX; lastY = e.clientY; c.setPointerCapture(e.pointerId); }); c.addEventListener('pointermove', (e) => { if (!this.interactive || !dragging) return; const dx = e.clientX - lastX, dy = e.clientY - lastY; moved += Math.abs(dx) + Math.abs(dy); this.panX += dx; this.panY += dy; lastX = e.clientX; lastY = e.clientY; }); const end = (e) => { if (!this.interactive || !dragging) return; dragging = false; // Nauwelijks bewogen? Dan was het een tik en geen sleep. if (moved < 6) { const r = c.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; const hit = nearestFace(x, y, this.spots, { cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale, panX: this.panX, panY: this.panY, }); if (hit != null) { this.onPick?.(hit); return; } // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend, // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt. const s2 = Math.min(Math.max(this.scale, 0.5), 1.6); const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY)); if (d <= 26 * s2) this.onNew?.(); } }; c.addEventListener('pointerup', end); c.addEventListener('pointercancel', () => { dragging = false; }); // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het // midden, zodat de hemel zich als een kaart gedraagt. c.addEventListener('wheel', (e) => { if (!this.interactive) return; e.preventDefault(); this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5); }, { passive: false }); window.addEventListener('resize', () => this._resize()); } }