Index: src/assets/js/ward-sky.js
===================================================================
--- src/assets/js/ward-sky.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/js/ward-sky.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
@@ -0,0 +1,262 @@
+/**
+ * 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) {
+    this.canvas = canvas;
+    this.g = canvas.getContext('2d');
+    this.onPick = onPick;
+    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();
+  }
+
+  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;
+
+    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 hit = nearestFace(e.clientX - r.left, e.clientY - r.top, 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);
+      }
+    };
+    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());
+  }
+}
