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

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

De Ward PWA: het gesprek over de hemel, en de zwaai-hand

Plak 4. Een tik op een gezicht opent nu echt iets.

HET GESPREK is een overlay OVER de levende hemel, geen aparte pagina:
de hemel eronder blijft dezelfde draaiende instantie, en het paneel is
doorschijnend met blur, zoals de glazen bubbels in de app. Dezelfde
bron als de C2S-lezing die Shaer gebruikt (conversationHistory):
inkomend de ap_mentions, uitgaand de eigen directe notes. Openen is
lezen: markRead neemt het maximum, dus een oud toestel kan nooit iets
weer ongelezen maken. Verversen is pollen om de acht seconden zolang
het gesprek open staat; het ritme van een gesprek is geen milliseconde,
en een socket kan later zonder iets te slopen.

DE ZWAAI-HAND hangt in dezelfde hoek als de boei en ze wisselen elkaar
af met de stand: met de kaarten verdwijnt de boei, op de hemel hangt de
hand. Slepen op een gezicht opent het paneel met de drie antwoorden, en
de tekst die je kiest IS het bericht. De treffer is sky.pick(): exact
dezelfde rekensom als een tik, dus mikken gedraagt zich overal gelijk.

Changed files:
src/routes/ward.js

  • /api/thread: beide kanten, cursor, markRead bij openen
  • /api/message: direct bericht of zwaai (shaer:wave reist mee)

src/assets/js/ward-sky.js

  • pick() als publieke mikfunctie

src/assets/js/ward.js

  • thread-overlay met poll, verstuurvak, Escape sluit
  • de hand: slepen, mikken, paneel, versturen

src/views/pages/ward.ejs

  • thread, hand en zwaai-paneel in de schil

src/assets/css/ward.css

  • doorschijnend gesprek, bubbels, hand, paneel

src/services/i18n.js

  • tien sleutels in nl, en, de

test/ward-pwa.test.js

  • het gesprek draagt beide kanten en de zwaai-vlag overleeft; versturen eist een echte ontvanger en echte tekst

remarks: 897 tests groen. Nog niet geport: de (+) voor een nieuw
gesprek, Friends/Orbit-panelen, de composer met media, de mini-speler
en de themakleur; beads volgen. Slepen met een echte vinger is aan
Robin op dev.

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

  • Property mode set to 100644
File size: 9.9 KB
Line 
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 /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */
177 pick(px, py) {
178 return nearestFace(px, py, this.spots, {
179 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
180 panX: this.panX, panY: this.panY,
181 });
182 }
183
184 start() { if (!this.running) { this.running = true; this._loop(); } }
185 stop() { this.running = false; }
186
187 _resize() {
188 const dpr = Math.min(window.devicePixelRatio || 1, 2);
189 const r = this.canvas.getBoundingClientRect();
190 this.canvas.width = Math.max(1, r.width * dpr);
191 this.canvas.height = Math.max(1, r.height * dpr);
192 this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
193 this.w = r.width; this.h = r.height;
194 }
195
196 _loop() {
197 if (!this.running) return;
198 this._draw(performance.now());
199 requestAnimationFrame(() => this._loop());
200 }
201
202 _draw(t) {
203 const { g } = this;
204 g.clearRect(0, 0, this.w, this.h);
205 if (!this.atlas) return;
206 const cx = this.w / 2;
207 const cy = this.h / 2;
208 // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
209 // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
210 const phase = ((t / 9000) % 1) * Math.PI * 2;
211 const s = Math.min(Math.max(this.scale, 0.5), 1.6);
212 const draw = FACE * s;
213
214 for (let i = 0; i < this.spots.length; i++) {
215 const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
216 const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
217 const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
218 // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
219 // enige die bij duizenden gezichten echt telt.
220 if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
221 const a = this.faces[i].atlas;
222 g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
223 }
224 }
225
226 _wire() {
227 const c = this.canvas;
228 let dragging = false, lastX = 0, lastY = 0, moved = 0;
229 let pinch = null;
230
231 c.addEventListener('pointerdown', (e) => {
232 if (!this.interactive) return;
233 dragging = true; moved = 0;
234 lastX = e.clientX; lastY = e.clientY;
235 c.setPointerCapture(e.pointerId);
236 });
237 c.addEventListener('pointermove', (e) => {
238 if (!this.interactive || !dragging) return;
239 const dx = e.clientX - lastX, dy = e.clientY - lastY;
240 moved += Math.abs(dx) + Math.abs(dy);
241 this.panX += dx; this.panY += dy;
242 lastX = e.clientX; lastY = e.clientY;
243 });
244 const end = (e) => {
245 if (!this.interactive || !dragging) return;
246 dragging = false;
247 // Nauwelijks bewogen? Dan was het een tik en geen sleep.
248 if (moved < 6) {
249 const r = c.getBoundingClientRect();
250 const hit = nearestFace(e.clientX - r.left, e.clientY - r.top, this.spots, {
251 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
252 panX: this.panX, panY: this.panY,
253 });
254 if (hit != null) this.onPick?.(hit);
255 }
256 };
257 c.addEventListener('pointerup', end);
258 c.addEventListener('pointercancel', () => { dragging = false; });
259
260 // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
261 // midden, zodat de hemel zich als een kaart gedraagt.
262 c.addEventListener('wheel', (e) => {
263 if (!this.interactive) return;
264 e.preventDefault();
265 this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
266 }, { passive: false });
267
268 window.addEventListener('resize', () => this._resize());
269 }
270}
Note: See TracBrowser for help on using the repository browser.