Wijzigen voor document Tekenveld

Laatst gewijzigd door Jeeves de Butler op 23-10-2026 02:42

Van versie 2.1
gewijzigd door Jeeves de Butler
op 16-10-2026 12:29
Opmerking bij wijziging: Er is geen opmerking bij deze versie
Naar versie 1.1
gewijzigd door Jeeves de Butler
op 16-10-2026 12:29
Opmerking bij wijziging: Er is geen opmerking bij deze versie

Samenvatting

Details

XWiki.JavaScriptExtension[0]
cache
... ... @@ -1,1 +1,0 @@
1 -long
code
... ... @@ -1,1335 +1,0 @@
1 -/* Tekenveld voor de Kennisbank. Gemaakt met maak-tekenveld.js uit de skill xwiki-wardenburg-admin.
2 - Engine: Ember src/lib/glyphField.js, commit 2af047d. Niet hier wijzigen: pas de bron aan en maak opnieuw. */
3 -(function () {
4 -"use strict";
5 -// /src/lib/glyphField.js
6 -
7 -/* Het tekenveld (Home, het Dashboard, de lijstpagina's, inloggen en de foutpagina's; welke pagina's
8 - het krijgen staat bij de route, zie src/components/RouteBackground.jsx): een raster van willekeurige letters en cijfers in een monospace
9 - letter, bijna onzichtbaar, met langzaam drijvende dichtere wolken. Waar de muis langs
10 - gaat, worden de tekens donkerder, kleuren ze naar het merkrood en wisselen ze snel; dat
11 - spoor dooft in een paar seconden weer uit. Daaronder klopt een rustige hartslag: twee
12 - hartvormige ringen die vanuit de muis uitdijen en uitdoven. Af en toe schiet er een rode
13 - hartmonitorlijn over het scherm, en een klik op de achtergrond laat een smiley opspringen.
14 -
15 - Bewust zonder React: de engine tekent op een canvas en leest het thema uit de CSS-tokens
16 - (--text en --brand-red), zodat hij licht en donker vanzelf volgt.
17 -
18 - Zuinig met CPU en GPU, want het veld staat open zolang de pagina openstaat:
19 - - de tekens staan eenmalig in een atlas en worden per cel gekopieerd (drawImage) in
20 - plaats van steeds opnieuw gezet (fillText);
21 - - het rustende veld staat op een eigen laag die maar vier keer per seconde opnieuw wordt
22 - getekend; per frame gaat die laag in één keer over het canvas;
23 - - per frame worden alleen de cellen onder het spoor en de hartslag bijgewerkt, binnen de
24 - kleinste rechthoek waarin nog iets ligt;
25 - - op een verborgen tabblad tekent het veld niet, en bij prefers-reduced-motion staat er
26 - één stilstaand beeld zonder spoor. */
27 -
28 -const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
29 -
30 -/* Het thema bepaalt welke tekens het veld gebruikt. Nu alleen de standaardset; een seizoensset
31 - (sneeuwvlokken rond kerst, paaseitjes rond Pasen) komt hier later bij als eigen thema, zonder dat
32 - een pagina iets hoeft te weten. Elk teken krijgt één cel; hooguit 256 tekens (Uint8Array). */
33 -const GLYPH_THEMES = Object.freeze({
34 - standaard: Object.freeze({ label: "Standaard", glyphs: Object.freeze(Array.from(GLYPHS)) }),
35 -});
36 -
37 -const DEFAULT_GLYPH_THEME = "standaard";
38 -
39 -/* Intensiteit per pagina. "vol" is het veld van Home zoals het is; "lijst" heeft in rust minder
40 - tekens (de wolken liggen hoger, zodat er minder cellen boven de drempel komen), maar elk teken is
41 - even duidelijk en het muisspoor, de hartslag, de hartslaglijn, de smileys en de explosie blijven
42 - precies gelijk. Gemeten: in rust staan er op "lijst" ongeveer 70% van de tekens van "vol". */
43 -const GLYPH_INTENSITIES = Object.freeze({
44 - vol: Object.freeze({}),
45 - lijst: Object.freeze({ ambientLift: -0.05 }),
46 -});
47 -
48 -const DEFAULT_GLYPH_INTENSITY = "vol";
49 -
50 -/* De opties voor createGlyphField bij een intensiteit en een thema. Een onbekende naam is een
51 - programmeerfout; die wordt gemeld en valt terug op de standaard, zodat de pagina niet stukgaat. */
52 -function glyphFieldSettings({ intensity = DEFAULT_GLYPH_INTENSITY, theme = DEFAULT_GLYPH_THEME } = {}) {
53 - let intensityOptions = GLYPH_INTENSITIES[intensity];
54 - if (!intensityOptions) {
55 - console.error(`tekenveld: onbekende intensiteit "${intensity}"; ${DEFAULT_GLYPH_INTENSITY} gebruikt`);
56 - intensityOptions = GLYPH_INTENSITIES[DEFAULT_GLYPH_INTENSITY];
57 - }
58 - let themeOptions = GLYPH_THEMES[theme];
59 - if (!themeOptions) {
60 - console.error(`tekenveld: onbekend thema "${theme}"; ${DEFAULT_GLYPH_THEME} gebruikt`);
61 - themeOptions = GLYPH_THEMES[DEFAULT_GLYPH_THEME];
62 - }
63 - return { ...intensityOptions, glyphs: themeOptions.glyphs };
64 -}
65 -
66 -const DEFAULTS = {
67 - // De tekens van het veld; zie GLYPH_THEMES.
68 - glyphs: GLYPH_THEMES.standaard.glyphs,
69 - fontSize: 12,
70 - lineHeight: 15,
71 - fontFamily: 'ui-monospace, "Cascadia Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
72 - // Zichtbaarheid van de wolken in het rustende veld; donker oogt sneller sterker.
73 - ambientMax: { light: 0.1, dark: 0.075 },
74 - // Hoe hoog de wolken liggen: lager betekent minder tekens in rust (zie GLYPH_INTENSITIES).
75 - ambientLift: 0.05,
76 - // Zichtbaarheid onder het spoor van de muis.
77 - trailNeutral: { light: 0.38, dark: 0.3 },
78 - trailAccent: { light: 0.4, dark: 0.45 },
79 - brushRadius: 70,
80 - trailSeconds: 2.2,
81 - ambientIntervalMs: 250,
82 - maxFps: 30,
83 - // Bepaalt of een klik op de achtergrond viel (en dus een smiley geeft); zonder deze
84 - // functie telt elke klik.
85 - isBackground: null,
86 - // Geeft de rechthoeken (getBoundingClientRect) van de inhoud; de hartslaglijn kiest bij
87 - // voorkeur een strook waar die niet staan.
88 - obstacles: null,
89 - // Hartslag: elke slag twee hartvormige ringen vlak na elkaar (lub-dub) die vanaf de muis
90 - // uitdijen en onderweg uitdoven. In rust een slag per heartPeriod; tijdens bewegen een
91 - // slag per heartSpacing pixels, maar niet vaker dan per heartMinGap seconden.
92 - heartPeriod: 4,
93 - heartDubDelay: 0.2,
94 - // De tweede slag (dub) is een zachte naklank van de eerste, geen tweede volle ring.
95 - heartDubWeight: 0.3,
96 - heartSpacing: 420,
97 - heartMinGap: 1.4,
98 - heartMaxRings: 12,
99 - // Hartslaglijn: af en toe trekt er op een willekeurige hoogte een rode lijn in de vorm van
100 - // een hartmonitor van links naar rechts over het scherm; het spoor dooft vanzelf uit.
101 - ecgEverySeconds: [10, 24],
102 - ecgFirstSeconds: [3, 7],
103 - ecgCrossSeconds: 0.7,
104 - ecgBeatWidth: 165,
105 - ecgAmplitude: 105,
106 - ecgRadius: 10,
107 - // De hartslaglijn en de smileys hebben een eigen, kort spoor; ze moeten flitsen, niet
108 - // blijven hangen zoals het spoor van de muis.
109 - streakSeconds: 0.3,
110 - streakNeutral: { light: 0.1, dark: 0.08 },
111 - streakAccent: { light: 1, dark: 1 },
112 - // Smileys: een klik op de achtergrond laat er een opspringen; spammen mag.
113 - smileyRadius: 145,
114 - smileySeconds: 1.5,
115 - smileyMax: 40,
116 - // Variatie in de hartslaglijn; een op de zes lijnen is wild.
117 - ecgWildChance: 0.17,
118 - // Vijf snelle klikken: een atoomexplosie. De letters vallen van het scherm; na 15 s mogen
119 - // de smileys weer, na 30 s komt de hartslaglijn terug, na 45 s de hartslag en het
120 - // muisspoor, na 60 s het hele veld.
121 - nukeClicks: 5,
122 - nukeClickWindow: 1.6,
123 - nukeFallAfter: 2.4,
124 - nukeSmileyAfter: 15,
125 - nukeEcgAfter: 30,
126 - nukeHeartAfter: 45,
127 - nukeNormalAfter: 60,
128 - ecgStrength: 1,
129 - heartDuration: 1.9,
130 - heartReach: 0.42,
131 - heartThickness: 40,
132 - heartStrength: 0.6,
133 - heartSeconds: 0.15,
134 - // Hoe rood een verse ring is ten opzichte van het muisspoor.
135 - heartRed: 1.3,
136 -};
137 -
138 -const MIN_ALPHA = 0.01;
139 -
140 -/* Straal van een hart per hoek, vanuit het midden van het hart en met de punt naar
141 - beneden; de grootste straal is 1. Gemaakt uit de bekende parametrische hartkromme. */
142 -const HEART_BINS = 360;
143 -const HEART_RADIUS = (() => {
144 - const points = [];
145 - for (let i = 0; i < 2000; i += 1) {
146 - const t = (i / 2000) * Math.PI * 2;
147 - const x = 16 * Math.sin(t) ** 3;
148 - const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
149 - points.push([x, y]);
150 - }
151 - const ys = points.map((p) => p[1]);
152 - const midY = (Math.min(...ys) + Math.max(...ys)) / 2;
153 - const table = new Float32Array(HEART_BINS);
154 - for (const [x, y] of points) {
155 - const angle = Math.atan2(y - midY, x);
156 - const bin = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS;
157 - table[bin] = Math.max(table[bin], Math.hypot(x, y - midY));
158 - }
159 - for (let i = 0; i < HEART_BINS; i += 1) {
160 - if (!table[i]) table[i] = table[(i + HEART_BINS - 1) % HEART_BINS];
161 - }
162 - const max = Math.max(...table);
163 - for (let i = 0; i < HEART_BINS; i += 1) table[i] /= max;
164 - return table;
165 -})();
166 -const HEART_MIN = Math.min(...HEART_RADIUS);
167 -
168 -function randomBetween([min, max]) {
169 - return min + Math.random() * (max - min);
170 -}
171 -
172 -function bump(u, center, width, height) {
173 - const d = (u - center) / width;
174 - return height * Math.exp(-d * d);
175 -}
176 -
177 -/* Een slag van een hartmonitor over een fase u van 0 tot 1: ronde P-golf, de scherpe QRS-piek
178 - met een diepe S, een licht oplopend ST-stuk, een brede T-golf en een kleine U-golf. Omhoog
179 - is positief. */
180 -function ecgShape(u) {
181 - return (
182 - bump(u, 0.18, 0.05, 0.16) +
183 - bump(u, 0.36, 0.012, -0.14) +
184 - bump(u, 0.4, 0.013, 1) +
185 - bump(u, 0.44, 0.014, -0.34) +
186 - bump(u, 0.5, 0.05, 0.04) +
187 - bump(u, 0.66, 0.075, 0.32) +
188 - bump(u, 0.82, 0.035, 0.05)
189 - );
190 -}
191 -
192 -/* Smileys in genormaliseerde coördinaten: het midden is (0, 0), de rand van het gezicht
193 - ligt op straal 1 en y loopt naar beneden. Elke vorm geeft per punt 1 (teken) of 0. */
194 -function inDot(x, y, cx, cy, radius) {
195 - return Math.hypot(x - cx, y - cy) < radius;
196 -}
197 -
198 -function inSmile(x, y) {
199 - return y > 0.12 && Math.abs(Math.hypot(x, y + 0.05) - 0.56) < 0.09;
200 -}
201 -
202 -function inHeartEye(x, y, cx, cy) {
203 - const dx = (x - cx) / 0.2;
204 - const dy = (y - cy) / 0.2;
205 - return Math.hypot(dx, dy) < heartRadius(dx, dy);
206 -}
207 -
208 -const SMILEYS = [
209 - // blij
210 - (x, y) => inDot(x, y, -0.33, -0.28, 0.13) || inDot(x, y, 0.33, -0.28, 0.13) || inSmile(x, y),
211 - // grote lach
212 - (x, y) =>
213 - inDot(x, y, -0.33, -0.3, 0.13) ||
214 - inDot(x, y, 0.33, -0.3, 0.13) ||
215 - // open mond: de rand van een D, met een gat erin
216 - (y > 0.06 && Math.hypot(x, y - 0.05) < 0.62 && !(y > 0.2 && Math.hypot(x, y - 0.05) < 0.44)),
217 - // knipoog
218 - (x, y) =>
219 - (Math.abs(y + 0.28) < 0.09 && Math.abs(x + 0.33) < 0.17) || inDot(x, y, 0.33, -0.28, 0.13) || inSmile(x, y),
220 - // tong uit
221 - (x, y) =>
222 - inDot(x, y, -0.33, -0.28, 0.13) ||
223 - inDot(x, y, 0.33, -0.28, 0.13) ||
224 - (Math.abs(y - 0.28) < 0.08 && Math.abs(x) < 0.42) ||
225 - (y > 0.28 && inDot(x, y, 0.14, 0.46, 0.21)),
226 - // verliefd
227 - (x, y) => inHeartEye(x, y, -0.33, -0.26) || inHeartEye(x, y, 0.33, -0.26) || inSmile(x, y),
228 - // verbaasd
229 - (x, y) =>
230 - inDot(x, y, -0.33, -0.3, 0.13) ||
231 - inDot(x, y, 0.33, -0.3, 0.13) ||
232 - Math.abs(Math.hypot(x, y - 0.4) - 0.19) < 0.08,
233 -];
234 -
235 -function smileyAt(kind, x, y) {
236 - const r = Math.hypot(x, y);
237 - // De rand is minstens één rasterregel dik, anders valt hij onderaan tussen de regels door.
238 - if (Math.abs(r - 0.9) < 0.12) return true;
239 - if (r > 0.78) return false;
240 - return SMILEYS[kind](x, y);
241 -}
242 -function hash(value) {
243 - const x = Math.sin(value * 12.9898) * 43758.5453;
244 - return x - Math.floor(x);
245 -}
246 -
247 -function heartRadius(dx, dy) {
248 - const bin = Math.floor(((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS;
249 - return HEART_RADIUS[bin];
250 -}
251 -
252 -function clamp01(value) {
253 - return value < 0 ? 0 : value > 1 ? 1 : value;
254 -}
255 -
256 -function readTheme() {
257 - const root = document.documentElement;
258 - const styles = getComputedStyle(root);
259 - const text = styles.getPropertyValue("--text").trim() || "#111827";
260 - const accent = styles.getPropertyValue("--brand-red").trim() || "#e62b27";
261 - // :root zonder attribuut is in Ember donker; zie layout.css.
262 - const mode = root.getAttribute("data-appearance") === "light" ? "light" : "dark";
263 - return { text, accent, mode };
264 -}
265 -
266 -/* De tekens van een thema: een niet-lege lijst van hooguit 256 tekens (de index staat in een Uint8Array). */
267 -function checkGlyphs(glyphs) {
268 - const list = Array.from(glyphs || []);
269 - if (!list.length || list.length > 256) throw new Error("tekenveld: een thema heeft 1 tot 256 tekens");
270 - return list;
271 -}
272 -
273 -/* Wat geen achtergrond is, staat met data-glyph-block op het element: een blok, een tegel, een kop.
274 - Een klik daarop geeft geen smiley, en de hartslaglijn loopt bij voorkeur waar die niet staan. De
275 - pagina markeert zelf; het veld kent geen vaste selectors van een pagina. */
276 -const GLYPH_BLOCK_SELECTOR = "[data-glyph-block]";
277 -
278 -/* De rechthoeken van alle gemarkeerde blokken die zichtbaar zijn; voor de optie obstacles. */
279 -function glyphBlockRects(root = document) {
280 - return Array.from(root.querySelectorAll(GLYPH_BLOCK_SELECTOR), (element) => element.getBoundingClientRect())
281 - .filter((rect) => rect.width > 0 && rect.height > 0);
282 -}
283 -
284 -/* Start het veld op een canvas. Geeft { destroy, configure } terug: configure past de intensiteit of
285 - het thema aan terwijl het veld doorloopt (een andere pagina met hetzelfde veld), zonder opnieuw te
286 - beginnen. */
287 -function createGlyphField(canvas, overrides = {}) {
288 - const options = { ...DEFAULTS, ...overrides };
289 - let glyphSet = checkGlyphs(options.glyphs);
290 - const randomGlyph = () => (Math.random() * glyphSet.length) | 0;
291 - const ctx = canvas.getContext("2d");
292 - const layer = document.createElement("canvas");
293 - const layerCtx = layer.getContext("2d");
294 - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
295 -
296 - let dpr = 1;
297 - let cellW = 7;
298 - let cellH = options.lineHeight;
299 - let tileW = 7;
300 - let tileH = 15;
301 - let cols = 0;
302 - let rows = 0;
303 - let glyphs = new Uint8Array(0);
304 - let ambient = new Float32Array(0);
305 - let trail = new Float32Array(0);
306 - let pulse = new Float32Array(0);
307 - let streak = new Float32Array(0);
308 - let quiet = new Float32Array(0);
309 - let neutralAtlas = null;
310 - let accentAtlas = null;
311 - let yellowAtlas = null;
312 - let orangeAtlas = null;
313 - let smokeAtlas = null;
314 - let theme = readTheme();
315 -
316 - // Rechthoek (in cellen) waarbinnen nog spoor ligt; leeg als minC > maxC.
317 - let minC = 1;
318 - let maxC = 0;
319 - let minR = 1;
320 - let maxR = 0;
321 -
322 - let pointer = null;
323 - let lastPointer = null;
324 - let heartHome = null;
325 - let rings = [];
326 - let lastBeatAt = -Infinity;
327 - let lastBeatSpot = null;
328 - let ecg = null;
329 - let smileys = [];
330 - let calm = 1;
331 - let nuke = null;
332 - let clickTimes = [];
333 - let fallers = null;
334 - let nextEcgAt = performance.now() + randomBetween(options.ecgFirstSeconds) * 1000;
335 - let frameId = 0;
336 - let lastFrame = 0;
337 - let lastAmbient = -Infinity;
338 - let running = false;
339 - let destroyed = false;
340 -
341 - function buildAtlas(color) {
342 - const atlas = document.createElement("canvas");
343 - atlas.width = tileW * glyphSet.length;
344 - atlas.height = tileH;
345 - const a = atlas.getContext("2d");
346 - a.font = `${options.fontSize * dpr}px ${options.fontFamily}`;
347 - a.textBaseline = "middle";
348 - a.textAlign = "center";
349 - a.fillStyle = color;
350 - for (let i = 0; i < glyphSet.length; i += 1) {
351 - a.fillText(glyphSet[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5);
352 - }
353 - return atlas;
354 - }
355 -
356 - function buildAtlases() {
357 - neutralAtlas = buildAtlas(theme.text);
358 - accentAtlas = buildAtlas(theme.accent);
359 - // Vuurkleuren voor de explosie.
360 - yellowAtlas = buildAtlas(theme.mode === "light" ? "#eab308" : "#facc15");
361 - orangeAtlas = buildAtlas("#f97316");
362 - // Rook: zwart op licht; op donker donkergrijs, anders zie je hem niet.
363 - smokeAtlas = buildAtlas(theme.mode === "light" ? "#0b0b0f" : "#52525b");
364 - }
365 -
366 - function resize() {
367 - dpr = Math.min(window.devicePixelRatio || 1, 2);
368 - const width = window.innerWidth;
369 - const height = window.innerHeight;
370 -
371 - canvas.width = layer.width = Math.round(width * dpr);
372 - canvas.height = layer.height = Math.round(height * dpr);
373 - canvas.style.width = `${width}px`;
374 - canvas.style.height = `${height}px`;
375 -
376 - ctx.font = `${options.fontSize}px ${options.fontFamily}`;
377 - cellW = Math.max(6, Math.round(ctx.measureText("M").width * 10) / 10);
378 - cellH = options.lineHeight;
379 - tileW = Math.ceil(cellW * dpr);
380 - tileH = Math.ceil(cellH * dpr);
381 -
382 - cols = Math.ceil(width / cellW) + 1;
383 - rows = Math.ceil(height / cellH) + 1;
384 - glyphs = new Uint8Array(cols * rows);
385 - for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
386 - ambient = new Float32Array(cols * rows);
387 - trail = new Float32Array(cols * rows);
388 - pulse = new Float32Array(cols * rows);
389 - streak = new Float32Array(cols * rows);
390 - quiet = new Float32Array(cols * rows);
391 - minC = minR = 1;
392 - maxC = maxR = 0;
393 -
394 - buildAtlases();
395 - lastAmbient = -Infinity;
396 - render(performance.now(), 0);
397 - }
398 -
399 - function applyTheme() {
400 - const next = readTheme();
401 - if (next.text === theme.text && next.accent === theme.accent && next.mode === theme.mode) return;
402 - theme = next;
403 - buildAtlases();
404 - lastAmbient = -Infinity;
405 - render(performance.now(), 0);
406 - }
407 -
408 - function cellX(c) {
409 - return Math.round(c * cellW * dpr);
410 - }
411 -
412 - function cellY(r) {
413 - return Math.round(r * cellH * dpr);
414 - }
415 -
416 - // Het rustende veld: twee trage golven per as en een diagonale band. Alleen de toppen
417 - // worden zichtbaar, zodat er losse wolken ontstaan en geen egaal behang.
418 - function drawAmbient(time, elapsed) {
419 - const t = time / 1000;
420 - const max = options.ambientMax[theme.mode];
421 - const colWave = new Float32Array(cols);
422 - const rowWave = new Float32Array(rows);
423 - for (let c = 0; c < cols; c += 1) {
424 - colWave[c] = Math.sin(c * 0.045 + t * 0.21) + 0.6 * Math.sin(c * 0.013 - t * 0.09);
425 - }
426 - for (let r = 0; r < rows; r += 1) {
427 - rowWave[r] = Math.sin(r * 0.11 - t * 0.17) + 0.6 * Math.sin(r * 0.031 + t * 0.07);
428 - }
429 -
430 - // Tekens wisselen ook in rust, heel af en toe.
431 - const rerollChance = elapsed > 0 ? Math.min(0.05 * elapsed, 0.2) : 0;
432 -
433 - layerCtx.setTransform(1, 0, 0, 1, 0, 0);
434 - layerCtx.clearRect(0, 0, layer.width, layer.height);
435 - let currentAlpha = -1;
436 -
437 - for (let r = 0; r < rows; r += 1) {
438 - const y = cellY(r);
439 - const rw = rowWave[r];
440 - for (let c = 0; c < cols; c += 1) {
441 - const index = r * cols + c;
442 - const wave = (colWave[c] * rw + Math.sin((c + r * 2.2) * 0.035 + t * 0.12)) / 3.2;
443 - const cloud = clamp01((wave + options.ambientLift) * 2.2);
444 - const alpha = Math.round(max * cloud * cloud * 128) / 128;
445 - ambient[index] = alpha;
446 - if (rerollChance && Math.random() < rerollChance) glyphs[index] = randomGlyph();
447 - if (alpha < MIN_ALPHA) continue;
448 - if (alpha !== currentAlpha) {
449 - layerCtx.globalAlpha = alpha;
450 - currentAlpha = alpha;
451 - }
452 - layerCtx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
453 - }
454 - }
455 - layerCtx.globalAlpha = 1;
456 - }
457 -
458 - // Een zachte kwast op de plek van de muis; het spoor neemt het hoogste van oud en nieuw.
459 - // flat: een harde kern zonder zachte rand, voor een scherpe lijn in plaats van een vlek.
460 - function stamp(px, py, radius = options.brushRadius, strength = 1, flat = false, target = trail) {
461 - const c0 = Math.max(0, Math.floor((px - radius) / cellW));
462 - const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
463 - const r0 = Math.max(0, Math.floor((py - radius) / cellH));
464 - const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
465 - if (c0 > c1 || r0 > r1) return;
466 - const inv = 1 / (radius * radius);
467 -
468 - for (let r = r0; r <= r1; r += 1) {
469 - const dy = r * cellH + cellH / 2 - py;
470 - for (let c = c0; c <= c1; c += 1) {
471 - const dx = c * cellW + cellW / 2 - px;
472 - const d = (dx * dx + dy * dy) * inv;
473 - if (d >= 1) continue;
474 - // Rafelige rand: niet elke cel aan de buitenkant doet even hard mee.
475 - const edge = flat ? (d < 0.6 ? 1 : (1 - d) / 0.4) : (1 - d) * (1 - d) * (0.75 + Math.random() * 0.25);
476 - const value = edge * strength;
477 - const index = r * cols + c;
478 - if (value > target[index]) target[index] = value;
479 - }
480 - }
481 -
482 - extendBox(c0, c1, r0, r1);
483 - }
484 -
485 - function extendBox(c0, c1, r0, r1) {
486 - if (minC > maxC) {
487 - minC = c0;
488 - maxC = c1;
489 - minR = r0;
490 - maxR = r1;
491 - } else {
492 - minC = Math.min(minC, c0);
493 - maxC = Math.max(maxC, c1);
494 - minR = Math.min(minR, r0);
495 - maxR = Math.max(maxR, r1);
496 - }
497 - }
498 -
499 - // Een hartvormige ring met de grootste straal `size` rond (cx, cy). Per rij worden alleen
500 - // de kolommen bekeken die tussen de kleinste en grootste straal van de ring vallen.
501 - function stampRing(cx, cy, size, strength) {
502 - const half = options.heartThickness / 2;
503 - const outer = size + half;
504 - const inner = Math.max(0, size * HEART_MIN - half);
505 - const r0 = Math.max(0, Math.floor((cy - outer) / cellH));
506 - const r1 = Math.min(rows - 1, Math.ceil((cy + outer) / cellH));
507 - let touched = false;
508 -
509 - for (let r = r0; r <= r1; r += 1) {
510 - const dy = r * cellH + cellH / 2 - cy;
511 - const outerSpan = outer * outer - dy * dy;
512 - if (outerSpan <= 0) continue;
513 - const reach = Math.sqrt(outerSpan);
514 - const innerSpan = inner * inner - dy * dy;
515 - const gap = innerSpan > 0 ? Math.sqrt(innerSpan) : 0;
516 - const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
517 - const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
518 -
519 - for (let c = c0; c <= c1; c += 1) {
520 - const dx = c * cellW + cellW / 2 - cx;
521 - if (dx > -gap && dx < gap) {
522 - c = Math.max(c, Math.floor((cx + gap) / cellW) - 1);
523 - continue;
524 - }
525 - const distance = Math.sqrt(dx * dx + dy * dy);
526 - const offset = Math.abs(distance - size * heartRadius(dx, dy)) / half;
527 - if (offset >= 1) continue;
528 - const value = (1 - offset * offset) * strength;
529 - const index = r * cols + c;
530 - if (value > pulse[index]) pulse[index] = value;
531 - touched = true;
532 - }
533 - }
534 -
535 - if (touched) {
536 - extendBox(
537 - Math.max(0, Math.floor((cx - outer) / cellW)),
538 - Math.min(cols - 1, Math.ceil((cx + outer) / cellW)),
539 - r0,
540 - r1
541 - );
542 - }
543 - }
544 -
545 - // Een slag: twee ringen vanaf dezelfde plek, de tweede iets later en veel zwakker (lub-dub).
546 - function spawnBeat(now, x, y) {
547 - rings.push({ x, y, start: now, weight: 1 });
548 - rings.push({ x, y, start: now + options.heartDubDelay * 1000, weight: options.heartDubWeight });
549 - if (rings.length > options.heartMaxRings) rings.splice(0, rings.length - options.heartMaxRings);
550 - lastBeatAt = now;
551 - lastBeatSpot = { x, y };
552 - }
553 -
554 - /* De hartslag komt uit de muis. Staat de muis stil, dan klopt hij in een rustig ritme op
555 - die plek; beweegt hij, dan komt er een slag bij zodra hij een stuk verder is, zodat er
556 - hartjes langs het pad liggen. Buiten het venster klopt er niets. */
557 - /* De hartslaglijn loopt los van de muis. De baan wordt per 2 px gevolgd, anders valt de
558 - smalle piek tussen twee frames door; een stempel volgt zodra de lijn een stukje verder
559 - is. */
560 - /* Hoogte voor de hartslaglijn. Een lichte voorkeur voor stroken waar weinig inhoud staat:
561 - uit vijf willekeurige hoogtes wint meestal de vrijste, maar een op de vier keer gewoon
562 - een willekeurige, zodat de lijn overal kan verschijnen. */
563 - function pickLineY(height) {
564 - const candidates = [];
565 - for (let i = 0; i < 5; i += 1) candidates.push(height * (0.15 + Math.random() * 0.75));
566 - const rects = options.obstacles ? options.obstacles() : [];
567 - if (!rects.length || Math.random() < 0.25) return candidates[0];
568 -
569 - const width = window.innerWidth;
570 - const top = options.ecgAmplitude;
571 - const bottom = options.ecgAmplitude * 0.4;
572 - let best = candidates[0];
573 - let bestCover = Infinity;
574 - for (const y of candidates) {
575 - let cover = 0;
576 - for (const rect of rects) {
577 - if (rect.bottom < y - top || rect.top > y + bottom) continue;
578 - cover += Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
579 - }
580 - if (cover < bestCover) {
581 - bestCover = cover;
582 - best = y;
583 - }
584 - }
585 - return best;
586 - }
587 -
588 - // Elke slag een eigen grootte, vast per slag zodat hij niet trilt tussen frames.
589 - function beatScale(line, beat) {
590 - const roll = hash(line.seed + beat * 7.31);
591 - let scale = 0.82 + roll * 0.36;
592 - if (line.wild && hash(line.seed - beat * 3.17) > 0.62) scale *= 1.6 + roll * 0.5;
593 - return scale;
594 - }
595 -
596 - function heartLine(now) {
597 - if (nuke && now - nuke.start < options.nukeEcgAfter * 1000) return;
598 - if (!ecg) {
599 - if (now < nextEcgAt) return;
600 - const height = window.innerHeight;
601 - ecg = {
602 - start: now,
603 - baseY: pickLineY(height),
604 - lastX: -options.ecgRadius,
605 - stampX: -Infinity,
606 - stampY: 0,
607 - offset: Math.random(),
608 - seed: Math.random() * 1000,
609 - // Elke lijn een eigen hoogte en tempo; zo nu en dan een wilde met uitschieters.
610 - amplitude: options.ecgAmplitude * (0.8 + Math.random() * 0.4),
611 - beatWidth: options.ecgBeatWidth * (0.8 + Math.random() * 0.45),
612 - wild: Math.random() < options.ecgWildChance,
613 - };
614 - }
615 -
616 - const width = window.innerWidth;
617 - const travel = width + options.ecgRadius * 2;
618 - const progress = (now - ecg.start) / (options.ecgCrossSeconds * 1000);
619 - const targetX = -options.ecgRadius + travel * Math.min(progress, 1);
620 -
621 - for (let x = ecg.lastX; x <= targetX; x += 2) {
622 - // Een echte lijn ligt nooit stil: de basislijn deint zacht mee (ademhaling).
623 - const sway = ecg.wild ? 2 : 1;
624 - const wander = (Math.sin(x * 0.006 + ecg.offset * 6) * 0.1 + Math.sin(x * 0.017 + ecg.offset * 3) * 0.04) * sway;
625 - const phase = x / ecg.beatWidth + ecg.offset;
626 - const y = ecg.baseY - (ecgShape(phase % 1) * beatScale(ecg, Math.floor(phase)) + wander) * ecg.amplitude;
627 - if (Math.hypot(x - ecg.stampX, y - ecg.stampY) >= options.ecgRadius * 0.3) {
628 - stamp(x, y, options.ecgRadius, options.ecgStrength, true, streak);
629 - ecg.stampX = x;
630 - ecg.stampY = y;
631 - }
632 - ecg.lastX = x + 2;
633 - }
634 -
635 - if (progress >= 1) {
636 - ecg = null;
637 - nextEcgAt = now + randomBetween(options.ecgEverySeconds) * 1000;
638 - }
639 - }
640 -
641 - function spawnSmiley(x, y) {
642 - let kind = (Math.random() * SMILEYS.length) | 0;
643 - // Niet twee keer achter elkaar dezelfde.
644 - if (smileys.length && smileys[smileys.length - 1].kind === kind) kind = (kind + 1) % SMILEYS.length;
645 - smileys.push({ x, y, kind, start: performance.now(), tilt: (Math.random() - 0.5) * 0.5 });
646 - if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax);
647 - }
648 -
649 -
650 - /* Een smiley springt op (even groter dan zijn eindmaat), zweeft een stukje omhoog en
651 - dooft uit. Hij wordt elk frame opnieuw in het korte spoor gezet; als hij weg is, flitst
652 - hij in een paar tienden van een seconde uit. */
653 - function drawSmileys(now) {
654 - let keep = 0;
655 - for (const smiley of smileys) {
656 - const age = (now - smiley.start) / 1000;
657 - if (age >= options.smileySeconds) continue;
658 - smileys[keep] = smiley;
659 - keep += 1;
660 -
661 - const pop = Math.min(age / 0.22, 1);
662 - const scale = pop < 1 ? 0.3 + 0.9 * Math.sin((pop * Math.PI) / 2) + 0.1 * Math.sin(pop * Math.PI) : 1;
663 - const fade = Math.min(1, (options.smileySeconds - age) / 0.45);
664 - const radius = options.smileyRadius * scale;
665 - const cx = smiley.x;
666 - const cy = smiley.y - age * 40;
667 - const cos = Math.cos(smiley.tilt);
668 - const sin = Math.sin(smiley.tilt);
669 - // Stille cirkel: binnen 1,1 keer de straal helemaal stil, tot 1,5 keer zacht aflopend.
670 - const reach = radius * 1.5;
671 -
672 - const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
673 - const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
674 - const r0 = Math.max(0, Math.floor((cy - reach) / cellH));
675 - const r1 = Math.min(rows - 1, Math.ceil((cy + reach) / cellH));
676 - if (c0 > c1 || r0 > r1) continue;
677 -
678 - for (let r = r0; r <= r1; r += 1) {
679 - const py = (r * cellH + cellH / 2 - cy) / radius;
680 - for (let c = c0; c <= c1; c += 1) {
681 - const px = (c * cellW + cellW / 2 - cx) / radius;
682 - const x = px * cos + py * sin;
683 - const y = -px * sin + py * cos;
684 - const index = r * cols + c;
685 - const d2 = x * x + y * y;
686 - if (d2 < 2.25) {
687 - const still = fade * (d2 < 1.21 ? 1 : (2.25 - d2) / 1.04);
688 - if (still > quiet[index]) quiet[index] = still;
689 - }
690 - if (!smileyAt(smiley.kind, x, y)) continue;
691 - if (fade > streak[index]) streak[index] = fade;
692 - }
693 - }
694 - extendBox(c0, c1, r0, r1);
695 - }
696 - smileys.length = keep;
697 - }
698 -
699 - function onPointerDown(event) {
700 - if (reducedMotion.matches || event.button !== 0) return;
701 - if (options.isBackground && !options.isBackground(event)) return;
702 - if (nuke) {
703 - // In de nasleep geen nieuwe explosie; na nukeSmileyAfter mogen de smileys wel weer.
704 - if (nukeAge(performance.now()) >= options.nukeSmileyAfter) spawnSmiley(event.clientX, event.clientY);
705 - return;
706 - }
707 -
708 - const now = performance.now();
709 - clickTimes = clickTimes.filter((time) => now - time < options.nukeClickWindow * 1000);
710 - clickTimes.push(now);
711 - if (clickTimes.length >= options.nukeClicks) {
712 - clickTimes = [];
713 - startNuke(now, event.clientX, event.clientY);
714 - return;
715 - }
716 - spawnSmiley(event.clientX, event.clientY);
717 - }
718 -
719 - function startNuke(now, x, y) {
720 - nuke = { start: now, x, y, puffs: [], puffClock: 0 };
721 - smileys = [];
722 - rings = [];
723 - ecg = null;
724 - pointer = null;
725 - lastPointer = null;
726 - trail.fill(0);
727 - pulse.fill(0);
728 - nextEcgAt = now + options.nukeEcgAfter * 1000;
729 - }
730 -
731 - // Hoe ver de nasleep is, in seconden; Infinity als er geen explosie is geweest.
732 - function nukeAge(now) {
733 - return nuke ? (now - nuke.start) / 1000 : Infinity;
734 - }
735 -
736 - function heartbeat(now) {
737 - if (nukeAge(now) < options.nukeHeartAfter) return;
738 - if (heartHome) {
739 - const sinceBeat = now - lastBeatAt;
740 - const moved = lastBeatSpot
741 - ? Math.hypot(heartHome.x - lastBeatSpot.x, heartHome.y - lastBeatSpot.y)
742 - : Infinity;
743 - if (
744 - sinceBeat >= options.heartPeriod * 1000 ||
745 - (moved >= options.heartSpacing && sinceBeat >= options.heartMinGap * 1000)
746 - ) {
747 - spawnBeat(now, heartHome.x, heartHome.y);
748 - }
749 - }
750 -
751 - const reach = Math.max(window.innerWidth, window.innerHeight) * options.heartReach;
752 - let keep = 0;
753 - for (const ring of rings) {
754 - const age = (now - ring.start) / 1000;
755 - if (age >= options.heartDuration) continue;
756 - rings[keep] = ring;
757 - keep += 1;
758 - if (age < 0) continue;
759 - const progress = age / options.heartDuration;
760 - const size = reach * (1 - (1 - progress) * (1 - progress));
761 - const strength = options.heartStrength * ring.weight * (1 - progress) ** 1.3;
762 - // Een bijna uitgedoofde ring is groot en kost veel cellen voor weinig beeld.
763 - if (size > 4 && strength > 0.06) stampRing(ring.x, ring.y, size, strength);
764 - }
765 - rings.length = keep;
766 - }
767 -
768 - function paintPointer() {
769 - const from = lastPointer || pointer;
770 - const dx = pointer.x - from.x;
771 - const dy = pointer.y - from.y;
772 - const step = options.brushRadius * 0.35;
773 - const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
774 - for (let s = 1; s <= steps; s += 1) {
775 - stamp(from.x + (dx * s) / steps, from.y + (dy * s) / steps);
776 - }
777 - lastPointer = pointer;
778 - pointer = null;
779 - }
780 -
781 - // Het spoor: alleen de cellen binnen de rechthoek met spoor. De cel wordt eerst leeg
782 - // gemaakt, zodat het teken uit de rustlaag er niet dubbel onder staat.
783 - function drawTrail(dt) {
784 - if (minC > maxC) return;
785 - const decay = dt > 0 ? Math.exp(-dt / options.trailSeconds) : 1;
786 - const pulseDecay = dt > 0 ? Math.exp(-dt / options.heartSeconds) : 1;
787 - const streakDecay = dt > 0 ? Math.exp(-dt / options.streakSeconds) : 1;
788 - const neutralMax = options.trailNeutral[theme.mode];
789 - const accentMax = options.trailAccent[theme.mode];
790 - const streakNeutral = options.streakNeutral[theme.mode];
791 - const streakAccent = options.streakAccent[theme.mode];
792 - let nextMinC = cols;
793 - let nextMaxC = -1;
794 - let nextMinR = rows;
795 - let nextMaxR = -1;
796 -
797 - for (let r = minR; r <= maxR; r += 1) {
798 - const y = cellY(r);
799 - for (let c = minC; c <= maxC; c += 1) {
800 - const index = r * cols + c;
801 - let heat = trail[index];
802 - let beat = pulse[index];
803 - let flash = streak[index];
804 - let hush = quiet[index];
805 - if (heat <= 0 && beat <= 0 && flash <= 0 && hush <= 0) continue;
806 -
807 - if (heat > 0) {
808 - heat *= decay;
809 - if (heat < 0.01) heat = 0;
810 - trail[index] = heat;
811 - }
812 - if (beat > 0) {
813 - beat *= pulseDecay;
814 - if (beat < 0.04) beat = 0;
815 - pulse[index] = beat;
816 - }
817 - if (flash > 0) {
818 - flash *= streakDecay;
819 - if (flash < 0.03) flash = 0;
820 - streak[index] = flash;
821 - }
822 - if (hush > 0) {
823 - hush *= streakDecay;
824 - if (hush < 0.03) hush = 0;
825 - quiet[index] = hush;
826 - }
827 - if (heat === 0 && beat === 0 && flash === 0 && hush === 0) continue;
828 -
829 - if (c < nextMinC) nextMinC = c;
830 - if (c > nextMaxC) nextMaxC = c;
831 - if (r < nextMinR) nextMinR = r;
832 - if (r > nextMaxR) nextMaxR = r;
833 -
834 - // Onder het spoor en de hartslag wisselen de tekens snel.
835 - if (dt > 0 && Math.random() < (heat * 9 + beat * 5 + flash * 6) * dt) glyphs[index] = randomGlyph();
836 -
837 - // Een verse ring is roder dan het spoor; verder weg is hij al zwakker en daarmee
838 - // vanzelf grijzer.
839 - // In de stille cirkel van een smiley (hush) en tijdens de nasleep van een explosie
840 - // (calm) wijken het spoor en de ringen; de smiley en de hartslaglijn blijven.
841 - heat *= calm * (1 - hush);
842 - beat *= calm * (1 - hush);
843 - const lift = heat > beat ? heat : beat;
844 - const ring = Math.min(1, beat * options.heartRed);
845 - const glow = heat > ring ? heat : ring;
846 - const x = cellX(c);
847 - const sx = glyphs[index] * tileW;
848 - // Achter een smiley wordt het rustende veld stilgezet (hush), zodat het gezicht
849 - // niet in de wolken verdwijnt. De lijn en de smileys (flash) zijn vooral rood.
850 - const neutral = ambient[index] * calm * (1 - lift) * (1 - hush) + neutralMax * lift + streakNeutral * flash;
851 - const red = Math.max(glow * glow * accentMax, flash * streakAccent);
852 - ctx.clearRect(x, y, tileW, tileH);
853 - if (neutral >= MIN_ALPHA) {
854 - ctx.globalAlpha = neutral;
855 - ctx.drawImage(neutralAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
856 - }
857 - if (red > 0.01) {
858 - ctx.globalAlpha = Math.min(1, red);
859 - ctx.drawImage(accentAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
860 - }
861 - }
862 - }
863 -
864 - ctx.globalAlpha = 1;
865 - minC = nextMinC;
866 - maxC = nextMaxC;
867 - minR = nextMinR;
868 - maxR = nextMaxR;
869 - }
870 -
871 - /* Zwarte rook die uit de kop van de paddenstoel spuwt en naar de bovenkant van het scherm
872 - klimt. Losse wolkjes die opstijgen, opzwellen en onderweg dunner worden. */
873 - function drawSmoke(t, dt) {
874 - const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
875 - const capY = nuke.y - 300 * size * easeOut(t / 2.2);
876 - const capRx = (50 + 190 * easeOut(t / 1.4)) * size;
877 -
878 - // Spuwen tussen 0,5 en 3,2 seconden, zo'n vijfentwintig wolkjes per seconde. Alle rook is
879 - // weg rond 5,5 seconden, als de letters gevallen zijn.
880 - if (dt > 0 && t > 0.5 && t < 3.2) {
881 - nuke.puffClock += dt * 25;
882 - while (nuke.puffClock >= 1) {
883 - nuke.puffClock -= 1;
884 - const startY = capY - Math.random() * 40 * size;
885 - nuke.puffs.push({
886 - x: nuke.x + (Math.random() - 0.5) * capRx * 0.9,
887 - y: startY,
888 - // Snel genoeg om in een paar seconden de bovenkant te halen.
889 - vy: -(startY + 120) / (1.1 + Math.random() * 0.8),
890 - vx: (Math.random() - 0.5) * 50 * size,
891 - radius: (14 + Math.random() * 18) * size,
892 - born: t,
893 - seed: Math.random() * 10,
894 - });
895 - }
896 - }
897 -
898 - const life = 2.4;
899 - const allFade = t < 4.2 ? 1 : Math.max(0, 1 - (t - 4.2) / 1.3);
900 - let keep = 0;
901 - for (const puff of nuke.puffs) {
902 - const age = t - puff.born;
903 - if (age >= life) continue;
904 - nuke.puffs[keep] = puff;
905 - keep += 1;
906 -
907 - const px = puff.x + puff.vx * age;
908 - const py = puff.y + puff.vy * age * (1 - age / (life * 2.4));
909 - const radius = puff.radius * (1 + age * 0.85);
910 - const strength = 0.75 * (1 - age / life) * Math.min(1, age / 0.15) * allFade;
911 - if (py + radius < 0 || strength <= 0.01) continue;
912 -
913 - const c0 = Math.max(0, Math.floor((px - radius) / cellW));
914 - const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
915 - const r0 = Math.max(0, Math.floor((py - radius) / cellH));
916 - const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
917 - for (let r = r0; r <= r1; r += 1) {
918 - const dy = r * cellH + cellH / 2 - py;
919 - for (let c = c0; c <= c1; c += 1) {
920 - const dx = c * cellW + cellW / 2 - px;
921 - const angle = Math.atan2(dy, dx);
922 - const edge = 1 + 0.18 * Math.sin(angle * 5 + puff.seed + t * 2);
923 - const d = Math.sqrt(dx * dx + dy * dy) / (radius * edge);
924 - if (d >= 1) continue;
925 - ctx.globalAlpha = strength * (1 - d * d);
926 - ctx.drawImage(smokeAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
927 - }
928 - }
929 - }
930 - nuke.puffs.length = keep;
931 - }
932 -
933 - // 1 als het veld er gewoon staat; 0 tijdens de nasleep van een explosie.
934 - function fieldPresence(now) {
935 - const age = nukeAge(now);
936 - if (age < options.nukeFallAfter) return 1;
937 - if (age < options.nukeNormalAfter) return 0;
938 - return Math.min(1, (age - options.nukeNormalAfter) / 2);
939 - }
940 -
941 - function easeOut(value) {
942 - const v = Math.min(1, Math.max(0, value));
943 - return 1 - (1 - v) ** 3;
944 - }
945 -
946 - /* De explosie, rechtstreeks op het canvas: een rode flits over het hele veld, een
947 - schokgolf die over het scherm rolt, een paddenstoelwolk van kokende letters, het veld
948 - dat schudt, en daarna vallen alle letters van het scherm. */
949 - function drawNuke(now, dt) {
950 - const t = nukeAge(now);
951 - const accentAlpha = theme.mode === "light" ? 0.85 : 0.95;
952 -
953 - // Schudden: alleen het canvas, de inhoud van de pagina blijft staan.
954 - if (t < 1.3) {
955 - const shake = 12 * (1 - t / 1.3) ** 2;
956 - canvas.style.transform = `translate(${(Math.random() - 0.5) * 2 * shake}px, ${(Math.random() - 0.5) * 2 * shake}px)`;
957 - } else if (canvas.style.transform) {
958 - canvas.style.transform = "";
959 - }
960 -
961 - // Flits: het hele veld licht even rood op.
962 - if (t < 0.7) {
963 - const flashAtlas = t < 0.2 ? yellowAtlas : t < 0.42 ? orangeAtlas : accentAtlas;
964 - ctx.globalAlpha = 0.6 * (1 - t / 0.7) ** 2;
965 - for (let r = 0; r < rows; r += 1) {
966 - const y = cellY(r);
967 - for (let c = 0; c < cols; c += 1) {
968 - ctx.drawImage(flashAtlas, glyphs[r * cols + c] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
969 - }
970 - }
971 - }
972 -
973 - // Schokgolf: een ring die in anderhalve seconde voorbij de randen van het scherm rolt.
974 - if (t < 1.8) {
975 - const progress = t / 1.8;
976 - const reach = Math.hypot(window.innerWidth, window.innerHeight);
977 - const radius = reach * easeOut(progress);
978 - const band = 30 + 90 * (1 - progress);
979 - const strength = 0.95 * (1 - progress);
980 - const r0 = Math.max(0, Math.floor((nuke.y - radius - band) / cellH));
981 - const r1 = Math.min(rows - 1, Math.ceil((nuke.y + radius + band) / cellH));
982 - for (let r = r0; r <= r1; r += 1) {
983 - const dy = r * cellH + cellH / 2 - nuke.y;
984 - const outer = (radius + band) ** 2 - dy * dy;
985 - if (outer <= 0) continue;
986 - const reachX = Math.sqrt(outer);
987 - const c0 = Math.max(0, Math.floor((nuke.x - reachX) / cellW));
988 - const c1 = Math.min(cols - 1, Math.ceil((nuke.x + reachX) / cellW));
989 - for (let c = c0; c <= c1; c += 1) {
990 - const dx = c * cellW + cellW / 2 - nuke.x;
991 - const off = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius) / band;
992 - if (off >= 1) continue;
993 - const front = Math.sqrt(dx * dx + dy * dy) - radius;
994 - const waveAtlas = front > band * 0.3 ? yellowAtlas : front > -band * 0.3 ? orangeAtlas : accentAtlas;
995 - ctx.globalAlpha = strength * (1 - off);
996 - ctx.drawImage(waveAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
997 - }
998 - }
999 - }
1000 -
1001 - // Paddenstoelwolk: een kokende kop die opstijgt, een steel en een wolk over de grond.
1002 - if (t < 4.2) {
1003 - const grow = easeOut(t / 1.4);
1004 - const fade = t < 2.8 ? 1 : Math.max(0, 1 - (t - 2.8) / 1.4);
1005 - // Groot op een groot scherm, passend op een klein scherm.
1006 - const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
1007 - const rise = 300 * size * easeOut(t / 2.2);
1008 - const capY = nuke.y - rise;
1009 - const capRx = (50 + 190 * grow) * size;
1010 - const capRy = (35 + 85 * grow) * size;
1011 - const baseRx = (70 + 230 * grow) * size;
1012 - const baseRy = (22 + 34 * grow) * size;
1013 - const spanX = Math.max(capRx * 1.25, baseRx);
1014 - const c0 = Math.max(0, Math.floor((nuke.x - spanX) / cellW));
1015 - const c1 = Math.min(cols - 1, Math.ceil((nuke.x + spanX) / cellW));
1016 - const r0 = Math.max(0, Math.floor((capY - capRy * 1.25) / cellH));
1017 - const r1 = Math.min(rows - 1, Math.ceil((nuke.y + baseRy) / cellH));
1018 -
1019 - for (let r = r0; r <= r1; r += 1) {
1020 - const py = r * cellH + cellH / 2;
1021 - for (let c = c0; c <= c1; c += 1) {
1022 - const dx = c * cellW + cellW / 2 - nuke.x;
1023 - let core = 0;
1024 -
1025 - const capDy = py - capY;
1026 - const angle = Math.atan2(capDy, dx);
1027 - const edge = 1 + 0.14 * Math.sin(angle * 7 + t * 2.5) + 0.08 * Math.sin(angle * 13 - t * 4);
1028 - const capD = Math.sqrt((dx / capRx) ** 2 + (capDy / capRy) ** 2) / edge;
1029 - if (capD < 1) core = Math.max(core, 1 - capD * 0.6);
1030 -
1031 - if (py > capY && py < nuke.y) {
1032 - const along = (py - capY) / Math.max(rise, 1);
1033 - const half = (18 + 14 * grow + 30 * grow * along * along) * size;
1034 - if (Math.abs(dx) < half) core = Math.max(core, 0.75);
1035 - }
1036 -
1037 - const baseD = Math.sqrt((dx / baseRx) ** 2 + ((py - nuke.y) / baseRy) ** 2);
1038 - if (baseD < 1 + 0.12 * Math.sin(dx * 0.05 + t * 3)) core = Math.max(core, 0.85 - baseD * 0.4);
1039 -
1040 - if (core <= 0) continue;
1041 - const x = cellX(c);
1042 - const y = cellY(r);
1043 - const glyph = randomGlyph() * tileW;
1044 - // Heet in de kern; naarmate de wolk afkoelt schuift alles richting rood.
1045 - const heatLevel = core - Math.max(0, t - 1.2) * 0.12 + (Math.random() - 0.5) * 0.12;
1046 - const fireAtlas = heatLevel > 0.8 ? yellowAtlas : heatLevel > 0.58 ? orangeAtlas : accentAtlas;
1047 - ctx.globalAlpha = Math.min(1, accentAlpha * core * fade);
1048 - ctx.drawImage(fireAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
1049 - ctx.globalAlpha = 0.35 * core * core * fade;
1050 - ctx.drawImage(neutralAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
1051 - }
1052 - }
1053 - }
1054 -
1055 - drawSmoke(t, dt);
1056 -
1057 - // De letters vallen: elk teken uit het rustende veld krijgt een eigen moment en valt
1058 - // dan met zwaartekracht van het scherm.
1059 - if (t >= options.nukeFallAfter && !fallers) {
1060 - const list = [];
1061 - for (let i = 0; i < ambient.length; i += 1) if (ambient[i] >= 0.012) list.push(i);
1062 - fallers = {
1063 - index: Int32Array.from(list),
1064 - y: new Float32Array(list.length),
1065 - vy: new Float32Array(list.length),
1066 - delay: Float32Array.from(list, () => Math.random() * 1.8),
1067 - };
1068 - }
1069 - if (fallers && t < options.nukeNormalAfter) {
1070 - const since = t - options.nukeFallAfter;
1071 - const floor = canvas.height;
1072 - for (let i = 0; i < fallers.index.length; i += 1) {
1073 - const index = fallers.index[i];
1074 - if (since > fallers.delay[i] && dt > 0) {
1075 - fallers.vy[i] += 1600 * dpr * dt;
1076 - fallers.y[i] += fallers.vy[i] * dt;
1077 - }
1078 - const r = (index / cols) | 0;
1079 - const y = cellY(r) + fallers.y[i];
1080 - if (y > floor) continue;
1081 - // Tijdens het vallen iets duidelijker, anders zie je ze bijna niet gaan.
1082 - const boost = since > fallers.delay[i] ? 3.5 : 1;
1083 - ctx.globalAlpha = Math.min(0.5, ambient[index] * boost);
1084 - ctx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(index % cols), y, tileW, tileH);
1085 - }
1086 - }
1087 -
1088 - ctx.globalAlpha = 1;
1089 - if (t >= options.nukeNormalAfter + 2) {
1090 - nuke = null;
1091 - fallers = null;
1092 - canvas.style.transform = "";
1093 - }
1094 - }
1095 -
1096 - function render(now, dt) {
1097 - // Een venster zonder afmeting (geminimaliseerd of verborgen bij het laden): niets tekenen.
1098 - // drawImage van een leeg canvas gooit een fout, en die fout brak hier createGlyphField af.
1099 - // Bij de eerste resize gaat het vanzelf verder.
1100 - if (!neutralAtlas || !cols || !canvas.width || !canvas.height) return;
1101 - const hadTrail = minC <= maxC;
1102 - const ambientDue = now - lastAmbient >= options.ambientIntervalMs;
1103 -
1104 - // Los aangeroepen (thema, formaat) terwijl er niets veranderd is: niets tekenen.
1105 - if (!hadTrail && !ambientDue && dt === 0) return;
1106 -
1107 - if (ambientDue) {
1108 - const elapsed = Number.isFinite(lastAmbient) ? (now - lastAmbient) / 1000 : 0;
1109 - drawAmbient(now, elapsed);
1110 - lastAmbient = now;
1111 - }
1112 -
1113 - ctx.setTransform(1, 0, 0, 1, 0, 0);
1114 - ctx.clearRect(0, 0, canvas.width, canvas.height);
1115 - // Na een explosie ligt het veld stil tot nukeNormalAfter en komt het daarna terug. Een
1116 - // smiley maakt alleen een cirkel om zichzelf stil (quiet), niet het hele veld.
1117 - calm = fieldPresence(now);
1118 - if (calm > 0.01) {
1119 - ctx.globalAlpha = calm;
1120 - ctx.drawImage(layer, 0, 0);
1121 - ctx.globalAlpha = 1;
1122 - }
1123 - if (dt > 0) {
1124 - heartbeat(now);
1125 - heartLine(now);
1126 - drawSmileys(now);
1127 - }
1128 - drawTrail(dt);
1129 - if (nuke) drawNuke(now, dt);
1130 - }
1131 -
1132 - function loop(now) {
1133 - if (!running) return;
1134 - frameId = requestAnimationFrame(loop);
1135 - if (pointer && nukeAge(now) >= options.nukeHeartAfter) paintPointer();
1136 - else if (nukeAge(now) < options.nukeHeartAfter) pointer = null;
1137 - // Dertig beelden per seconde is genoeg voor wisselende tekens; dat halveert het werk
1138 - // op een 60 Hz-scherm en meer op een sneller scherm.
1139 - if (lastFrame && now - lastFrame < 1000 / options.maxFps - 2) return;
1140 - const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.25) : 0;
1141 - lastFrame = now;
1142 - render(now, dt);
1143 - }
1144 -
1145 - function start() {
1146 - if (running || destroyed || reducedMotion.matches || document.hidden) return;
1147 - running = true;
1148 - lastFrame = 0;
1149 - frameId = requestAnimationFrame(loop);
1150 - }
1151 -
1152 - function stop() {
1153 - running = false;
1154 - cancelAnimationFrame(frameId);
1155 - }
1156 -
1157 - function onPointerMove(event) {
1158 - if (reducedMotion.matches) return;
1159 - const next = { x: event.clientX, y: event.clientY };
1160 - // Na het verlaten van het venster geen streep trekken vanaf de oude plek.
1161 - if (!lastPointer) lastPointer = next;
1162 - pointer = next;
1163 - heartHome = next;
1164 - }
1165 -
1166 - function onPointerLeave() {
1167 - lastPointer = null;
1168 - pointer = null;
1169 - heartHome = null;
1170 - lastBeatSpot = null;
1171 - }
1172 -
1173 - function onVisibility() {
1174 - if (document.hidden) stop();
1175 - else start();
1176 - }
1177 -
1178 - function onMotionChange() {
1179 - if (reducedMotion.matches) {
1180 - stop();
1181 - trail.fill(0);
1182 - pulse.fill(0);
1183 - rings = [];
1184 - smileys = [];
1185 - nuke = null;
1186 - fallers = null;
1187 - canvas.style.transform = "";
1188 - streak.fill(0);
1189 - quiet.fill(0);
1190 - minC = minR = 1;
1191 - maxC = maxR = 0;
1192 - lastAmbient = -Infinity;
1193 - render(performance.now(), 0);
1194 - } else {
1195 - start();
1196 - }
1197 - }
1198 -
1199 - let resizeTimer = 0;
1200 - function onResize() {
1201 - window.clearTimeout(resizeTimer);
1202 - resizeTimer = window.setTimeout(resize, 120);
1203 - }
1204 -
1205 - // Het thema wisselt via data-appearance op <html>; zie layout.css.
1206 - const themeObserver = new MutationObserver(applyTheme);
1207 - themeObserver.observe(document.documentElement, {
1208 - attributes: true,
1209 - attributeFilter: ["data-appearance", "class", "style"],
1210 - });
1211 -
1212 - window.addEventListener("pointermove", onPointerMove, { passive: true });
1213 - window.addEventListener("pointerdown", onPointerDown, { passive: true });
1214 - document.documentElement.addEventListener("pointerleave", onPointerLeave);
1215 - window.addEventListener("blur", onPointerLeave);
1216 - window.addEventListener("resize", onResize);
1217 - document.addEventListener("visibilitychange", onVisibility);
1218 - reducedMotion.addEventListener("change", onMotionChange);
1219 -
1220 - /* Andere intensiteit of ander thema zonder het veld opnieuw te starten. Een nieuw thema zet nieuwe
1221 - tekens in elke cel; een andere intensiteit tekent alleen de rustlaag opnieuw. */
1222 - function configure(next = {}) {
1223 - if (destroyed) return;
1224 - Object.assign(options, next);
1225 - if (next.glyphs) {
1226 - const nextSet = checkGlyphs(next.glyphs);
1227 - const changed = nextSet.length !== glyphSet.length || nextSet.some((glyph, index) => glyph !== glyphSet[index]);
1228 - if (changed) {
1229 - glyphSet = nextSet;
1230 - for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
1231 - buildAtlases();
1232 - }
1233 - }
1234 - lastAmbient = -Infinity;
1235 - render(performance.now(), 0);
1236 - }
1237 -
1238 - resize();
1239 - start();
1240 -
1241 - return { destroy, configure };
1242 -
1243 - function destroy() {
1244 - destroyed = true;
1245 - stop();
1246 - window.clearTimeout(resizeTimer);
1247 - themeObserver.disconnect();
1248 - window.removeEventListener("pointermove", onPointerMove);
1249 - window.removeEventListener("pointerdown", onPointerDown);
1250 - document.documentElement.removeEventListener("pointerleave", onPointerLeave);
1251 - window.removeEventListener("blur", onPointerLeave);
1252 - window.removeEventListener("resize", onResize);
1253 - document.removeEventListener("visibilitychange", onVisibility);
1254 - reducedMotion.removeEventListener("change", onMotionChange);
1255 - }
1256 -}
1257 -
1258 -// Opstart van het tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld).
1259 -// maak-tekenveld.js zet dit achter de engine van Ember; samen vormen ze de JavaScriptExtension.
1260 -// De engine zelf (createGlyphField, glyphFieldSettings) komt ongewijzigd uit Ember.
1261 -
1262 -// Waar een klik iets doet of waar inhoud staat: geen achtergrond, dus geen smiley.
1263 -const KB_NIET_ACHTERGROND = [
1264 - "a",
1265 - "button",
1266 - "input",
1267 - "select",
1268 - "textarea",
1269 - "label",
1270 - "form",
1271 - "summary",
1272 - "[role='button']",
1273 - "[role='dialog']",
1274 - ".navbar",
1275 - "#leftPanels",
1276 - "#rightPanels",
1277 - ".document-header",
1278 - ".wb-zoek > *",
1279 - ".wb-tegels li",
1280 - ".wb-home-onder",
1281 -].join(",");
1282 -
1283 -// De blokken van de startpagina; de hartslaglijn loopt bij voorkeur waar die niet staan.
1284 -const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar";
1285 -
1286 -// Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en
1287 -// beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer.
1288 -const KB_STILSTAND_MS = 60000;
1289 -
1290 -function kbTekenveld() {
1291 - // Alleen op de startpagina; de extensie wordt ook alleen daar geladen.
1292 - if (!document.querySelector(".wb-home")) return;
1293 -
1294 - // De Kennisbank heeft alleen een lichte stand; de engine leest die van <html>.
1295 - document.documentElement.setAttribute("data-appearance", "light");
1296 -
1297 - const canvas = document.createElement("canvas");
1298 - canvas.className = "kb-tekenveld";
1299 - canvas.setAttribute("aria-hidden", "true");
1300 - document.body.prepend(canvas);
1301 -
1302 - const opties = {
1303 - ...glyphFieldSettings({ intensity: "lijst" }),
1304 - // Geen atoomexplosie op de Kennisbank.
1305 - nukeClicks: Infinity,
1306 - isBackground: (event) => event.target instanceof Element && !event.target.closest(KB_NIET_ACHTERGROND),
1307 - obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()),
1308 - };
1309 -
1310 - let veld = null;
1311 - let rustTimer = 0;
1312 -
1313 - function wakker() {
1314 - if (!veld) veld = createGlyphField(canvas, opties);
1315 - window.clearTimeout(rustTimer);
1316 - rustTimer = window.setTimeout(() => {
1317 - // Het laatste beeld blijft staan; alleen de animatie stopt.
1318 - veld.destroy();
1319 - veld = null;
1320 - }, KB_STILSTAND_MS);
1321 - }
1322 -
1323 - window.addEventListener("pointermove", wakker, { passive: true });
1324 - window.addEventListener("pointerdown", wakker, { passive: true });
1325 - wakker();
1326 -}
1327 -
1328 -if (document.readyState === "loading") {
1329 - document.addEventListener("DOMContentLoaded", kbTekenveld);
1330 -} else {
1331 - kbTekenveld();
1332 -}
1333 -
1334 -})();
1335 -
name
... ... @@ -1,1 +1,0 @@
1 -Wardenburg tekenveld
parse
... ... @@ -1,1 +1,0 @@
1 -Nee
use
... ... @@ -1,1 +1,0 @@
1 -onDemand