source: Klonkt/src/assets/js/ward-sky.js@ 5412cbb

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

Ward-PWA: de (+) voor een nieuw gesprek

De hemel toont alleen met wie je al sprak; de (+) is de deur naar de
rest. 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. Precies waar de plaatsing bewust ruimte
vrijhoudt: elk gezicht hangt minstens 1.4 cel naar buiten.

De tik kiest eerst een gezicht en pas daarna de (+), met een raakzone
die meeschaalt met de zoom. De kiezer erachter toont iedereen met een
volgrelatie, welke kant op dan ook: dezelfde bron als de Connect-pagina
(listConnections), want de vraag is dezelfde. Kies iemand en het lege
gesprek opent gewoon; het eerste bericht maakt het echt.

Changed files:
src/routes/ward.js

  • /api/friends: following + followers samengevoegd, met naam en icoon

src/assets/js/ward-sky.js

  • de (+) getekend op de veldoorsprong; onNew naast onPick

src/assets/js/ward.js

  • de vriendenkiezer, die doorvalt in openThread

src/views/pages/ward.ejs + src/assets/css/ward.css

  • de kiezer als dialoog

src/services/i18n.js

  • twee sleutels in nl, en, de

test/ward-pwa.test.js

  • beide richtingen zijn een deur, en elke deur heeft een naam

remarks: 899 tests groen.

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

  • Property mode set to 100644
File size: 11.3 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 */
[5412cbb]154 constructor(canvas, onPick, onNew) {
[084b046]155 this.canvas = canvas;
156 this.g = canvas.getContext('2d');
157 this.onPick = onPick;
[5412cbb]158 // De (+) in het midden: de deur naar iemand NIEUW. Hij zit op de
159 // OORSPRONG van het veld en pant en zoomt met de gezichten mee, zodat hij
160 // altijd het midden van de vliegende avatars is, waar de hemel ook heen
161 // drijft. De plaatsing houdt daar bewust ruimte vrij (elk gezicht hangt
162 // minstens 1.4 cel naar buiten).
163 this.onNew = onNew;
[084b046]164 this.faces = [];
165 this.spots = [];
166 this.scale = 1;
167 this.panX = 0;
168 this.panY = 0;
169 this.running = false;
170 this.interactive = false;
171 this._wire();
172 }
173
174 /** @param {{name:string, icon:string|null, ageMs:number}[]} faces */
175 async setFaces(faces) {
176 this.faces = faces;
177 this.spots = orbitPlacement(faces.map((f) => f.ageMs));
178 this.atlas = await buildAtlas(faces);
179 this._resize();
180 }
181
[5218863]182 /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */
183 pick(px, py) {
184 return nearestFace(px, py, this.spots, {
185 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
186 panX: this.panX, panY: this.panY,
187 });
188 }
189
[084b046]190 start() { if (!this.running) { this.running = true; this._loop(); } }
191 stop() { this.running = false; }
192
193 _resize() {
194 const dpr = Math.min(window.devicePixelRatio || 1, 2);
195 const r = this.canvas.getBoundingClientRect();
196 this.canvas.width = Math.max(1, r.width * dpr);
197 this.canvas.height = Math.max(1, r.height * dpr);
198 this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
199 this.w = r.width; this.h = r.height;
200 }
201
202 _loop() {
203 if (!this.running) return;
204 this._draw(performance.now());
205 requestAnimationFrame(() => this._loop());
206 }
207
208 _draw(t) {
209 const { g } = this;
210 g.clearRect(0, 0, this.w, this.h);
211 if (!this.atlas) return;
212 const cx = this.w / 2;
213 const cy = this.h / 2;
214 // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
215 // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
216 const phase = ((t / 9000) % 1) * Math.PI * 2;
217 const s = Math.min(Math.max(this.scale, 0.5), 1.6);
218 const draw = FACE * s;
219
[5412cbb]220 // De (+) eerst, dan de gezichten eroverheen als het krap wordt.
221 if (this.interactive && this.onNew) {
222 const px = cx + this.panX;
223 const py = cy + this.panY;
224 const pr = 22 * s;
225 g.beginPath(); g.arc(px, py, pr, 0, Math.PI * 2);
226 g.fillStyle = 'rgba(255,255,255,0.92)'; g.fill();
227 g.strokeStyle = '#11131a'; g.lineWidth = Math.max(2, 3 * s); g.lineCap = 'round';
228 g.beginPath();
229 g.moveTo(px - pr * 0.45, py); g.lineTo(px + pr * 0.45, py);
230 g.moveTo(px, py - pr * 0.45); g.lineTo(px, py + pr * 0.45);
231 g.stroke();
232 }
233
[084b046]234 for (let i = 0; i < this.spots.length; i++) {
235 const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
236 const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
237 const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
238 // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
239 // enige die bij duizenden gezichten echt telt.
240 if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
241 const a = this.faces[i].atlas;
242 g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
243 }
244 }
245
246 _wire() {
247 const c = this.canvas;
248 let dragging = false, lastX = 0, lastY = 0, moved = 0;
249 let pinch = null;
250
251 c.addEventListener('pointerdown', (e) => {
252 if (!this.interactive) return;
253 dragging = true; moved = 0;
254 lastX = e.clientX; lastY = e.clientY;
255 c.setPointerCapture(e.pointerId);
256 });
257 c.addEventListener('pointermove', (e) => {
258 if (!this.interactive || !dragging) return;
259 const dx = e.clientX - lastX, dy = e.clientY - lastY;
260 moved += Math.abs(dx) + Math.abs(dy);
261 this.panX += dx; this.panY += dy;
262 lastX = e.clientX; lastY = e.clientY;
263 });
264 const end = (e) => {
265 if (!this.interactive || !dragging) return;
266 dragging = false;
267 // Nauwelijks bewogen? Dan was het een tik en geen sleep.
268 if (moved < 6) {
269 const r = c.getBoundingClientRect();
[5412cbb]270 const x = e.clientX - r.left;
271 const y = e.clientY - r.top;
272 const hit = nearestFace(x, y, this.spots, {
[084b046]273 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
274 panX: this.panX, panY: this.panY,
275 });
[5412cbb]276 if (hit != null) { this.onPick?.(hit); return; }
277 // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
278 // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
279 const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
280 const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
281 if (d <= 26 * s2) this.onNew?.();
[084b046]282 }
283 };
284 c.addEventListener('pointerup', end);
285 c.addEventListener('pointercancel', () => { dragging = false; });
286
287 // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
288 // midden, zodat de hemel zich als een kaart gedraagt.
289 c.addEventListener('wheel', (e) => {
290 if (!this.interactive) return;
291 e.preventDefault();
292 this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
293 }, { passive: false });
294
295 window.addEventListener('resize', () => this._resize());
296 }
297}
Note: See TracBrowser for help on using the repository browser.