source: Klonkt/src/assets/js/ward-sky.js@ 084b046

Last change on this file since 084b046 was 084b046, checked in by Robin <roboburr@…>, 3 months ago

De Ward PWA: de avatarhemel achter de kaarten

Plak 3. Robins correctie: niet de hemel IN PLAATS VAN de kaarten, maar
allebei, met de hemel er altijd achter. Zelfde beweging als in Shaer: de
kaarten vallen weg en wat eronder lag was er al.

MODEL EN RENDERER APART, en die naad is het punt. orbitPlacement en
nearestFace zijn pure wiskunde en bestaan nu drie keer (Swift, Kotlin,
hier). Drie keer kans op drift, en drift zie je niet aan een screenshot:
je ziet het als gezichten ergens anders hangen dan in de app. Daarom in
een eigen module, met dezelfde eigenschappen bewaakt als AvatarSkyTest.kt.

CANVAS, GEEN ENGINE, en dat is een afweging en geen luiheid. De rem is
niet het aantal sprites maar het aantal texturen: alle avatars gaan een
keer in een atlas, daarna is elk gezicht een drawImage uit datzelfde
plaatje. Dat haalt duizenden. Wil het ooit meer, dan vervangt een
WebGL-variant alleen SkyRenderer en merkt de rest er niets van. De echte
schaaloplossing is trouwens niet sneller tekenen maar clusteren; dat
staat als bead voor later.

New file:
src/assets/js/ward-sky.js

  • orbitPlacement + nearestFace, letterlijke port
  • SkyRenderer: atlas, ademhaling, pan, zoom, tik

test/ward-sky.test.js

  • vers dichterbij dan oud, deterministisch, geen overlap, mikken kiest de dichtstbijzijnde en schaalt mee met de zoom

Changed files:
src/routes/ward.js

  • /api/conversations: per tegenpartij het jongste bericht
  • /sky.js als eigen asset

src/views/pages/ward.ejs

  • canvas achter alles, wisselknop, app.js als module

src/assets/js/ward.js

  • de wissel: kaarten of hemel, hemel draait altijd

src/assets/css/ward.css

  • hemel op z-index 0, kaarten erboven

src/services/i18n.js

  • drie sleutels in nl, en, de

remarks: 895 tests groen. Een tik op een gezicht wijst naar /ward/thread,
en die route bestaat nog niet; dat is de volgende plak.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 9.7 KB
RevLine 
[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
17const 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 */
27export 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 */
80export 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
96const CELL = 92; // één avatarcel, zelfde maat als in de apps
97const FACE = 44; // de tekening zelf
98const ATLAS_PAD = 4;
99
100/** Eén canvas met alle avatars naast elkaar, zodat tekenen één drawImage is. */
101async 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
149export 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}
Note: See TracBrowser for help on using the repository browser.