source: Klonkt/src/assets/js/ward.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: 10.6 KB
RevLine 
[303bfc4]1/**
2 * De Ward PWA (FEP-633c), clientkant. Tegenhanger van guardian.js, maar dan
3 * vanuit het kind: wie waakt over mij, is dat vangnet heel, en wat ligt er bij
4 * hen te wachten.
5 *
6 * Geen framework, net als guardian.js: de toestand komt als JSON mee met de
7 * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*.
8 */
[084b046]9import { SkyRenderer } from '/ward/sky.js';
[303bfc4]10
11const state = JSON.parse(document.getElementById('ward-state').textContent);
12const $ = (id) => document.getElementById(id);
13
14/**
15 * De §4-uitkomst in woorden.
16 *
17 * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring.
18 * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet,
19 * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het
20 * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer.
21 */
22const CHECK_NOTE = {
23 malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' },
24 unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' },
25 sound: null,
26};
27
28/** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */
29const AVAILABILITY = {
30 active: { label: 'Beschikbaar', cls: 'ok' },
31 away: { label: 'Even weg', cls: 'away' },
32 dormant: { label: 'Offline', cls: 'off' },
33};
34
35function el(tag, cls, text) {
36 const n = document.createElement(tag);
37 if (cls) n.className = cls;
38 if (text != null) n.textContent = text;
39 return n;
40}
41
42function renderGuardians(checks) {
43 const list = $('guardian-list');
44 list.replaceChildren();
45 $('guardians-empty').hidden = state.guardians.length > 0;
46
47 state.guardians.forEach((g) => {
48 const li = el('li', 'card');
49 const head = el('div', 'row');
50 head.append(el('span', 'name', g.handle));
51
52 const av = AVAILABILITY[g.availability] || AVAILABILITY.active;
53 head.append(el('span', `chip ${av.cls}`, av.label));
54 li.append(head);
55
56 // De controle staat WAAR de guardian genoemd wordt, niet in een logboek:
57 // daar heeft het gat betekenis.
58 const note = CHECK_NOTE[checks?.[g.uri]];
59 if (note) li.append(el('p', `note ${note.cls}`, note.text));
60 list.append(li);
61 });
62}
63
64function renderOffers() {
65 const sec = $('offers');
66 const list = $('offer-list');
67 list.replaceChildren();
68 sec.hidden = !state.offers.length;
69
70 state.offers.forEach((o) => {
71 const li = el('li', 'card');
72 const who = o['shaer:candidate'] || o.actor || '';
73 li.append(el('p', 'name', `${who} wil jouw guardian worden`));
74 if (o['shaer:acceptedBy']) {
75 li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`));
76 }
77 list.append(li);
78 });
79}
80
81function renderWaiting() {
82 const sec = $('waiting');
83 const list = $('waiting-list');
84 list.replaceChildren();
85 sec.hidden = !state.waitingFollows.length;
86
87 state.waitingFollows.forEach((w) => {
88 const li = el('li', 'card');
89 li.append(el('p', 'name', w.handle));
90 // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is
91 // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer.
92 const denied = w.status === 'denied';
93 li.append(el('p', denied ? 'note alarm' : 'hint',
94 denied ? 'Je guardians hebben hier nee tegen gezegd.'
95 : 'Je guardians moeten hier nog ja tegen zeggen.'));
96 list.append(li);
97 });
98}
99
[a5a6249]100/**
101 * De kaarten. De body komt AF van de server (dezelfde partial als de Krant),
102 * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en
103 * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien.
104 */
105function renderFeed(items) {
106 const box = document.getElementById('card-list');
107 box.replaceChildren();
108 document.getElementById('feed-empty').hidden = items.length > 0;
109
110 items.forEach((it) => {
111 const card = el('article', 'post');
112 card.dataset.note = it.id; // waar de boei op mikt
113 const head = el('div', 'row');
114 if (it.authorIcon) {
115 const img = el('img', 'avatar');
116 img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy';
117 head.append(img);
118 }
119 head.append(el('span', 'name', it.authorName || it.author));
120 head.append(el('span', 'when', it.whenText || ''));
121 card.append(head);
122
123 if (it.cw) card.append(el('p', 'cw', it.cw));
124 const body = el('div', 'body');
125 body.innerHTML = it.bodyHtml || ''; // server-gerenderd en gesaniteerd
126 card.append(body);
127 box.append(card);
128 });
129}
130
131async function loadFeed() {
132 try {
133 const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } });
134 if (!r.ok) return;
135 renderFeed((await r.json()).items || []);
136 } catch { /* de pagina blijft staan met wat er al is */ }
137}
138
139/**
140 * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post.
141 *
142 * Pointer-events in plaats van muis- of touch-events apart, want dat is het
143 * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture`
144 * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt
145 * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met
146 * scrollen en verlies je hem halverwege.
147 */
148function wireBuoy() {
149 const buoy = document.getElementById('buoy');
150 const dlg = document.getElementById('help-dialog');
151 let dragging = false, dx = 0, dy = 0, target = null;
152
153 const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post');
154
155 buoy.addEventListener('pointerdown', (e) => {
156 dragging = true;
157 buoy.setPointerCapture(e.pointerId);
158 const r = buoy.getBoundingClientRect();
159 dx = e.clientX - r.left; dy = e.clientY - r.top;
160 buoy.classList.add('dragging');
161 });
162
163 buoy.addEventListener('pointermove', (e) => {
164 if (!dragging) return;
165 buoy.style.left = `${e.clientX - dx}px`;
166 buoy.style.top = `${e.clientY - dy}px`;
167 buoy.style.right = 'auto'; buoy.style.bottom = 'auto';
168 // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest.
169 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
170 cardUnder(e.clientX, e.clientY)?.classList.add('hover');
171 });
172
173 const drop = (e) => {
174 if (!dragging) return;
175 dragging = false;
176 buoy.classList.remove('dragging');
177 const card = cardUnder(e.clientX, e.clientY);
178 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
179 // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen.
180 buoy.style.left = buoy.style.top = 'auto';
181 buoy.style.right = ''; buoy.style.bottom = '';
182 if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || '');
183 };
184 buoy.addEventListener('pointerup', drop);
185 buoy.addEventListener('pointercancel', drop);
186
187 // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort.
188 buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); });
189
190 function askHelp(noteId, who) {
191 document.getElementById('help-title').textContent = who
192 ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?';
193 dlg.dataset.note = noteId || '';
194 document.getElementById('help-words').value = '';
195 dlg.showModal();
196 }
197
198 dlg.addEventListener('close', async () => {
199 if (dlg.returnValue !== 'send') return;
200 const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value };
201 const hint = document.getElementById('help-hint');
202 try {
203 const r = await fetch('/ward/api/help', {
204 method: 'POST',
205 headers: { 'Content-Type': 'application/json' },
206 body: JSON.stringify(body),
207 });
208 const out = await r.json().catch(() => ({}));
209 hint.textContent = r.ok ? state.strings.helpSent
210 : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.');
211 } catch {
212 hint.textContent = 'Dat lukte niet; probeer het nog eens.';
213 }
214 });
215}
216
[084b046]217/**
218 * De hemel en de kaarten zijn één scherm met twee standen.
219 *
220 * De hemel draait ALTIJD en ligt achter de kaarten; in kaartenstand is hij
221 * decor en vangt hij geen tikken, in berichtenstand vallen de kaarten weg en
222 * wordt hij de interface. Dat is precies de beweging uit Shaer: de kaarten
223 * vallen weg en wat eronder lag was er al.
224 */
225function wireSky() {
226 const canvas = document.getElementById('sky');
227 const btn = document.getElementById('mode');
228 const main = document.getElementById('app');
229 let convs = [];
230
231 const sky = new SkyRenderer(canvas, (i) => {
232 const c = convs[i];
233 if (c) window.location.href = `/ward/thread?with=${encodeURIComponent(c.uri)}`;
234 });
235
236 let messagesMode = false;
237 const setMode = (on) => {
238 messagesMode = on;
239 sky.interactive = on;
240 canvas.setAttribute('aria-hidden', String(!on));
241 // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun
242 // je in kaartenstand niet meer scrollen.
243 canvas.style.touchAction = on ? 'none' : '';
244 canvas.style.pointerEvents = on ? 'auto' : 'none';
245 document.body.classList.toggle('messages-mode', on);
246 btn.textContent = on ? state.strings.toCards : state.strings.toSky;
247 btn.setAttribute('aria-pressed', String(on));
248 main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; });
249 };
250
251 btn.addEventListener('click', () => setMode(!messagesMode));
252 setMode(false);
253
254 (async () => {
255 try {
256 const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } });
257 if (!r.ok) return;
258 convs = (await r.json()).items || [];
259 await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs })));
260 sky.start();
261 } catch { /* zonder hemel blijft het scherm gewoon werken */ }
262 })();
263}
264
265/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. *//** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
[303bfc4]266async function loadChecks() {
267 if (!state.guardians.length) return;
268 try {
269 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
270 if (!r.ok) return;
271 const { checks } = await r.json();
272 renderGuardians(checks);
273 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
274}
275
276function wireHelp() {
277 const btn = $('help-btn');
278 const hint = $('help-hint');
279 if (!state.guardians.length) {
280 btn.disabled = true;
281 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
282 return;
283 }
284 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
[a5a6249]285 btn.addEventListener('click', () => document.getElementById('buoy').click());
[303bfc4]286}
287
288renderGuardians(null);
[a5a6249]289wireBuoy();
[084b046]290wireSky();
[a5a6249]291loadFeed();
[303bfc4]292renderOffers();
293renderWaiting();
294wireHelp();
295loadChecks();
Note: See TracBrowser for help on using the repository browser.