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

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

Ward-PWA: echte pinch-to-zoom op de avatarhemel

De hemel deed zoom met het muiswiel, maar de belofte 'knijpen op een
telefoon' in de comment was nog niet waar: er stond alleen wielcode en
een ongebruikte pinch-variabele. Nu echt twee-vinger pinch.

Pointers in een Map: één vinger pant of tikt, twee vingers knijpen. De
schaal volgt de verhouding van de vingerafstand, en we zoomen ROND het
midden tussen de vingers, niet rond het canvas-midden. Dat is het
verschil tussen een kaart en beeld dat onder je weg schuift: het
wereldpunt onder je vingers blijft staan. Het wiel op desktop gebruikt
nu dezelfde weg, om de muis.

Zorgvuldig met de tik: pas een tik als er NOOIT twee vingers lagen en de
ene vinger nauwelijks bewoog. Anders opent het einde van een knijp per
ongeluk een gesprek. En bij het loslaten van de tweede vinger wordt de
afstand opnieuw geijkt, zodat de overgebleven vinger niet met een sprong
verder pant. touch-action:none stond al aan in berichtenstand, dus de
browser laat de pinch aan ons.

Changed files:
src/assets/js/ward-sky.js

  • _zoomAround(px,py,factor): zoom om een schermpunt
  • _clampScale: de grenzen op één plek
  • _wire herschreven: multi-pointer pan/pinch/tik

test/ward-sky.test.js

  • zoomen houdt het punt onder je vingers op zijn plek; de zoom blijft binnen zijn grenzen

remarks: 908 tests groen. De rekenkant is getest; het gebaar zelf met
twee echte vingers is aan Robin op dev.

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

  • Property mode set to 100644
File size: 13.9 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. */
[39dab6b]101async function buildAtlas(faces, accent) {
[084b046]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();
[39dab6b]122 g.fillStyle = accent || '#5a32e6'; g.fillRect(x, y, size, size);
[084b046]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));
[39dab6b]178 this.atlas = await buildAtlas(faces, this._accent());
[084b046]179 this._resize();
180 }
181
[39dab6b]182 /** De themakleur uit de CSS-variabele, voor de placeholder-schijfjes. */
183 _accent() {
184 return getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#5a32e6';
185 }
186
187 /** Het thema veranderde: de letters-op-schijf opnieuw in het nieuwe accent. */
188 async reskin() {
189 if (this.faces.length) this.atlas = await buildAtlas(this.faces, this._accent());
190 }
191
[5218863]192 /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */
193 pick(px, py) {
194 return nearestFace(px, py, this.spots, {
195 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
196 panX: this.panX, panY: this.panY,
197 });
198 }
199
[084b046]200 start() { if (!this.running) { this.running = true; this._loop(); } }
201 stop() { this.running = false; }
202
203 _resize() {
204 const dpr = Math.min(window.devicePixelRatio || 1, 2);
205 const r = this.canvas.getBoundingClientRect();
206 this.canvas.width = Math.max(1, r.width * dpr);
207 this.canvas.height = Math.max(1, r.height * dpr);
208 this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
209 this.w = r.width; this.h = r.height;
210 }
211
212 _loop() {
213 if (!this.running) return;
214 this._draw(performance.now());
215 requestAnimationFrame(() => this._loop());
216 }
217
218 _draw(t) {
219 const { g } = this;
220 g.clearRect(0, 0, this.w, this.h);
221 if (!this.atlas) return;
222 const cx = this.w / 2;
223 const cy = this.h / 2;
224 // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
225 // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
226 const phase = ((t / 9000) % 1) * Math.PI * 2;
227 const s = Math.min(Math.max(this.scale, 0.5), 1.6);
228 const draw = FACE * s;
229
[5412cbb]230 // De (+) eerst, dan de gezichten eroverheen als het krap wordt.
231 if (this.interactive && this.onNew) {
232 const px = cx + this.panX;
233 const py = cy + this.panY;
234 const pr = 22 * s;
235 g.beginPath(); g.arc(px, py, pr, 0, Math.PI * 2);
236 g.fillStyle = 'rgba(255,255,255,0.92)'; g.fill();
237 g.strokeStyle = '#11131a'; g.lineWidth = Math.max(2, 3 * s); g.lineCap = 'round';
238 g.beginPath();
239 g.moveTo(px - pr * 0.45, py); g.lineTo(px + pr * 0.45, py);
240 g.moveTo(px, py - pr * 0.45); g.lineTo(px, py + pr * 0.45);
241 g.stroke();
242 }
243
[084b046]244 for (let i = 0; i < this.spots.length; i++) {
245 const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
246 const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
247 const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
248 // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
249 // enige die bij duizenden gezichten echt telt.
250 if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
251 const a = this.faces[i].atlas;
252 g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
253 }
254 }
255
[3866994]256 /** De schaal binnen zijn grenzen. Eén plek, zodat wiel en pinch gelijk zijn. */
257 _clampScale(s) { return Math.min(Math.max(s, 0.2), 2.5); }
258
259 /**
260 * Zoomen ROND een schermpunt, niet rond het canvas-midden: de wereld onder
261 * je vingers (of je muis) blijft staan, zodat de hemel zich als een kaart
262 * gedraagt in plaats van onder je weg te schuiven.
263 */
264 _zoomAround(px, py, factor) {
265 const next = this._clampScale(this.scale * factor);
266 const cx = this.w / 2, cy = this.h / 2;
267 // Het wereldpunt onder (px,py) mag na de zoom op dezelfde plek blijven.
268 const worldX = (px - cx - this.panX) / this.scale;
269 const worldY = (py - cy - this.panY) / this.scale;
270 this.panX = px - cx - worldX * next;
271 this.panY = py - cy - worldY * next;
272 this.scale = next;
273 }
274
[084b046]275 _wire() {
276 const c = this.canvas;
[3866994]277 // Alle vingers die nu op de hemel liggen. Eén = pannen of tikken, twee =
278 // knijpen. Een Map want pointers komen en gaan in willekeurige volgorde.
279 const pts = new Map();
280 let moved = 0; // hoeveel de ENE vinger bewoog (tik vs sleep)
281 let pinchDist = 0; // vingerafstand bij het begin van een knijp
282 let becamePinch = false; // ooit twee vingers? dan geen tik aan het eind
283
284 const local = (e) => {
285 const r = c.getBoundingClientRect();
286 return { x: e.clientX - r.left, y: e.clientY - r.top };
287 };
288 const twoPointerState = () => {
289 const [a, b] = [...pts.values()];
290 return {
291 dist: Math.hypot(a.x - b.x, a.y - b.y),
292 mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2,
293 };
294 };
[084b046]295
296 c.addEventListener('pointerdown', (e) => {
297 if (!this.interactive) return;
298 c.setPointerCapture(e.pointerId);
[3866994]299 pts.set(e.pointerId, local(e));
300 moved = 0;
301 if (pts.size === 2) { becamePinch = true; pinchDist = twoPointerState().dist; }
[084b046]302 });
[3866994]303
[084b046]304 c.addEventListener('pointermove', (e) => {
[3866994]305 if (!this.interactive || !pts.has(e.pointerId)) return;
306 const prev = pts.get(e.pointerId);
307 const cur = local(e);
308 pts.set(e.pointerId, cur);
309
310 if (pts.size >= 2) {
311 // Knijpen: schaal volgt de verhouding van de vingerafstand, en we
312 // zoomen om het midden tussen de vingers.
313 const { dist, mx, my } = twoPointerState();
314 if (pinchDist > 0 && dist > 0) {
315 this._zoomAround(mx, my, dist / pinchDist);
316 pinchDist = dist;
317 }
318 } else {
319 // Eén vinger: pannen.
320 const dx = cur.x - prev.x, dy = cur.y - prev.y;
321 moved += Math.abs(dx) + Math.abs(dy);
322 this.panX += dx; this.panY += dy;
323 }
[084b046]324 });
[3866994]325
326 const lift = (e) => {
327 if (!pts.has(e.pointerId)) return;
328 const cur = pts.get(e.pointerId);
329 pts.delete(e.pointerId);
330 // Nog één vinger over na een knijp: zijn afstand als nieuw ijkpunt, en
331 // niet meteen als tik tellen.
332 if (pts.size === 1) { pinchDist = 0; return; }
333 if (pts.size > 1) return;
334
335 // Laatste vinger los. Een tik alleen als er nooit geknepen is en de
336 // vinger nauwelijks bewoog.
337 if (!becamePinch && moved < 6) {
338 const { x, y } = cur;
[5412cbb]339 const hit = nearestFace(x, y, this.spots, {
[084b046]340 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
341 panX: this.panX, panY: this.panY,
342 });
[3866994]343 if (hit != null) { this.onPick?.(hit); }
344 else {
345 // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
346 // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
347 const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
348 const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
349 if (d <= 26 * s2) this.onNew?.();
350 }
[084b046]351 }
[3866994]352 becamePinch = false;
[084b046]353 };
[3866994]354 c.addEventListener('pointerup', lift);
355 c.addEventListener('pointercancel', (e) => { pts.delete(e.pointerId); if (pts.size < 2) pinchDist = 0; });
[084b046]356
[3866994]357 // Het wiel op een desktop: zoomen om de muis, dezelfde weg als de pinch.
[084b046]358 c.addEventListener('wheel', (e) => {
359 if (!this.interactive) return;
360 e.preventDefault();
[3866994]361 const { x, y } = local(e);
362 this._zoomAround(x, y, e.deltaY < 0 ? 1.1 : 0.9);
[084b046]363 }, { passive: false });
364
365 window.addEventListener('resize', () => this._resize());
366 }
367}
Note: See TracBrowser for help on using the repository browser.