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

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

Ward-PWA: de mini-speler, de themakleur en een breder kaartraster

Drie dingen in een ronde.

DE THEMAKLEUR (SkyPalette). De acht presets en de luminantie-regel komen
LETTERLIJK uit Shaer: dezelfde kleuren, en dezelfde beslissing welke
tekst erop leesbaar is (donker op zon, wit op nacht). Een handgehouden
lijstje zou uit de pas lopen; luminantie beslist. De keuze kleurt het
accent (knoppen, bubbels, de (+)), tint de achtergrond zacht, en laat de
hemel zijn placeholder-schijfjes bijkleuren. Opgeslagen per apparaat.

DE MINI-SPELER. Een feed-kaart rendert een gewone audio, maar de feed
wordt bij elke verversing opnieuw getekend en dan sterft dat element:
de muziek stopt midden in een nummer. Daarom is er EEN blijvende speler
buiten de feed; een audiotegel geeft alleen zijn bron door (gevangen in
de capture-fase) en pauzeert zichzelf. Zo overleeft het geluid een
herteken en een wissel naar de hemel, precies als in de app.

HET KAARTRASTER (Robins wens, 12-8). Op desktop net als op mobiel en
tablet MEER kolommen: auto-fill met een minimum van 280px vult zelf op,
een op een smalle telefoon, twee op een tablet, drie of meer breed.

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

  • presets, onSky (luminantie), load/save/apply

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

  • wireTheme (swatches), wireMiniPlayer (gedeelde audio)
  • de hemel skint bij op een themawissel

src/assets/js/ward-sky.js

  • buildAtlas neemt het accent; reskin() bij een kleurwissel

src/views/pages/ward.ejs

  • themaknop, themadialoog, mini-speler-balk

src/assets/css/ward.css

  • accent/achtergrond als variabelen, mini-speler, swatches, responsief raster (auto-fill minmax 280px)

src/services/i18n.js

  • kleur + play/pause in nl, en, de

test/ward-theme.test.js

  • acht presets, en de tekstkleur volgt luminantie

remarks: 905 tests groen. Slepen en het echt doorspelen van audio zijn
in code getest, niet met oor en vinger; dat is aan Robin op dev.

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

  • Property mode set to 100644
File size: 11.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. */
[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
256 _wire() {
257 const c = this.canvas;
258 let dragging = false, lastX = 0, lastY = 0, moved = 0;
259 let pinch = null;
260
261 c.addEventListener('pointerdown', (e) => {
262 if (!this.interactive) return;
263 dragging = true; moved = 0;
264 lastX = e.clientX; lastY = e.clientY;
265 c.setPointerCapture(e.pointerId);
266 });
267 c.addEventListener('pointermove', (e) => {
268 if (!this.interactive || !dragging) return;
269 const dx = e.clientX - lastX, dy = e.clientY - lastY;
270 moved += Math.abs(dx) + Math.abs(dy);
271 this.panX += dx; this.panY += dy;
272 lastX = e.clientX; lastY = e.clientY;
273 });
274 const end = (e) => {
275 if (!this.interactive || !dragging) return;
276 dragging = false;
277 // Nauwelijks bewogen? Dan was het een tik en geen sleep.
278 if (moved < 6) {
279 const r = c.getBoundingClientRect();
[5412cbb]280 const x = e.clientX - r.left;
281 const y = e.clientY - r.top;
282 const hit = nearestFace(x, y, this.spots, {
[084b046]283 cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
284 panX: this.panX, panY: this.panY,
285 });
[5412cbb]286 if (hit != null) { this.onPick?.(hit); return; }
287 // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
288 // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
289 const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
290 const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
291 if (d <= 26 * s2) this.onNew?.();
[084b046]292 }
293 };
294 c.addEventListener('pointerup', end);
295 c.addEventListener('pointercancel', () => { dragging = false; });
296
297 // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
298 // midden, zodat de hemel zich als een kaart gedraagt.
299 c.addEventListener('wheel', (e) => {
300 if (!this.interactive) return;
301 e.preventDefault();
302 this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
303 }, { passive: false });
304
305 window.addEventListener('resize', () => this._resize());
306 }
307}
Note: See TracBrowser for help on using the repository browser.