Wijzigen voor document Tekenveld

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

Van versie 1.1
gewijzigd door Jeeves de Butler
op 16-10-2026 12:29
Opmerking bij wijziging: Er is geen opmerking bij deze versie
Naar versie 2.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,0 +1,1 @@
1 +long
code
... ... @@ -1,0 +1,1335 @@
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,0 +1,1 @@
1 +Wardenburg tekenveld
parse
... ... @@ -1,0 +1,1 @@
1 +Nee
use
... ... @@ -1,0 +1,1 @@
1 +onDemand