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 7.1
gewijzigd door Jeeves de Butler
op 19-10-2026 01:22
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,1391 @@
1 +/* Tekenveld voor de Kennisbank. Gemaakt met maak-tekenveld.js uit de skill xwiki-wardenburg-admin.
2 + Engine: Ember src/lib/glyphField.js, commit 83ea536. 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 + // Een eigen kleur voor de hartslaglijn (ECG); zonder --glyph-line is dat het accent, zoals in Ember.
262 + const line = styles.getPropertyValue("--glyph-line").trim() || accent;
263 + // :root zonder attribuut is in Ember donker; zie layout.css.
264 + const mode = root.getAttribute("data-appearance") === "light" ? "light" : "dark";
265 + return { text, accent, line, mode };
266 +}
267 +
268 +/* De tekens van een thema: een niet-lege lijst van hooguit 256 tekens (de index staat in een Uint8Array). */
269 +function checkGlyphs(glyphs) {
270 + const list = Array.from(glyphs || []);
271 + if (!list.length || list.length > 256) throw new Error("tekenveld: een thema heeft 1 tot 256 tekens");
272 + return list;
273 +}
274 +
275 +/* Wat geen achtergrond is, staat met data-glyph-block op het element: een blok, een tegel, een kop.
276 + Een klik daarop geeft geen smiley, en de hartslaglijn loopt bij voorkeur waar die niet staan. De
277 + pagina markeert zelf; het veld kent geen vaste selectors van een pagina. */
278 +const GLYPH_BLOCK_SELECTOR = "[data-glyph-block]";
279 +
280 +/* De rechthoeken van alle gemarkeerde blokken die zichtbaar zijn; voor de optie obstacles. */
281 +function glyphBlockRects(root = document) {
282 + return Array.from(root.querySelectorAll(GLYPH_BLOCK_SELECTOR), (element) => element.getBoundingClientRect())
283 + .filter((rect) => rect.width > 0 && rect.height > 0);
284 +}
285 +
286 +/* Start het veld op een canvas. Geeft { destroy, configure } terug: configure past de intensiteit of
287 + het thema aan terwijl het veld doorloopt (een andere pagina met hetzelfde veld), zonder opnieuw te
288 + beginnen. */
289 +function createGlyphField(canvas, overrides = {}) {
290 + const options = { ...DEFAULTS, ...overrides };
291 + let glyphSet = checkGlyphs(options.glyphs);
292 + const randomGlyph = () => (Math.random() * glyphSet.length) | 0;
293 + const ctx = canvas.getContext("2d");
294 + const layer = document.createElement("canvas");
295 + const layerCtx = layer.getContext("2d");
296 + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
297 +
298 + let dpr = 1;
299 + let cellW = 7;
300 + let cellH = options.lineHeight;
301 + let tileW = 7;
302 + let tileH = 15;
303 + let cols = 0;
304 + let rows = 0;
305 + let glyphs = new Uint8Array(0);
306 + let ambient = new Float32Array(0);
307 + let trail = new Float32Array(0);
308 + let pulse = new Float32Array(0);
309 + let streak = new Float32Array(0);
310 + // Het spoor van de hartslaglijn apart van dat van de smileys, zodat de lijn een eigen kleur kan hebben.
311 + let lineTrail = new Float32Array(0);
312 + let quiet = new Float32Array(0);
313 + let neutralAtlas = null;
314 + let accentAtlas = null;
315 + let lineAtlas = null;
316 + let yellowAtlas = null;
317 + let orangeAtlas = null;
318 + let smokeAtlas = null;
319 + let theme = readTheme();
320 +
321 + // Rechthoek (in cellen) waarbinnen nog spoor ligt; leeg als minC > maxC.
322 + let minC = 1;
323 + let maxC = 0;
324 + let minR = 1;
325 + let maxR = 0;
326 +
327 + let pointer = null;
328 + let lastPointer = null;
329 + let heartHome = null;
330 + let rings = [];
331 + let lastBeatAt = -Infinity;
332 + let lastBeatSpot = null;
333 + let ecg = null;
334 + let smileys = [];
335 + let calm = 1;
336 + let nuke = null;
337 + let clickTimes = [];
338 + let fallers = null;
339 + let nextEcgAt = performance.now() + randomBetween(options.ecgFirstSeconds) * 1000;
340 + let frameId = 0;
341 + let lastFrame = 0;
342 + let lastAmbient = -Infinity;
343 + let running = false;
344 + let destroyed = false;
345 +
346 + function buildAtlas(color) {
347 + const atlas = document.createElement("canvas");
348 + atlas.width = tileW * glyphSet.length;
349 + atlas.height = tileH;
350 + const a = atlas.getContext("2d");
351 + a.font = `${options.fontSize * dpr}px ${options.fontFamily}`;
352 + a.textBaseline = "middle";
353 + a.textAlign = "center";
354 + a.fillStyle = color;
355 + for (let i = 0; i < glyphSet.length; i += 1) {
356 + a.fillText(glyphSet[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5);
357 + }
358 + return atlas;
359 + }
360 +
361 + function buildAtlases() {
362 + neutralAtlas = buildAtlas(theme.text);
363 + accentAtlas = buildAtlas(theme.accent);
364 + lineAtlas = theme.line === theme.accent ? accentAtlas : buildAtlas(theme.line);
365 + // Vuurkleuren voor de explosie.
366 + yellowAtlas = buildAtlas(theme.mode === "light" ? "#eab308" : "#facc15");
367 + orangeAtlas = buildAtlas("#f97316");
368 + // Rook: zwart op licht; op donker donkergrijs, anders zie je hem niet.
369 + smokeAtlas = buildAtlas(theme.mode === "light" ? "#0b0b0f" : "#52525b");
370 + }
371 +
372 + function resize() {
373 + dpr = Math.min(window.devicePixelRatio || 1, 2);
374 + const width = window.innerWidth;
375 + const height = window.innerHeight;
376 +
377 + canvas.width = layer.width = Math.round(width * dpr);
378 + canvas.height = layer.height = Math.round(height * dpr);
379 + canvas.style.width = `${width}px`;
380 + canvas.style.height = `${height}px`;
381 +
382 + ctx.font = `${options.fontSize}px ${options.fontFamily}`;
383 + cellW = Math.max(6, Math.round(ctx.measureText("M").width * 10) / 10);
384 + cellH = options.lineHeight;
385 + tileW = Math.ceil(cellW * dpr);
386 + tileH = Math.ceil(cellH * dpr);
387 +
388 + cols = Math.ceil(width / cellW) + 1;
389 + rows = Math.ceil(height / cellH) + 1;
390 + glyphs = new Uint8Array(cols * rows);
391 + for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
392 + ambient = new Float32Array(cols * rows);
393 + trail = new Float32Array(cols * rows);
394 + pulse = new Float32Array(cols * rows);
395 + streak = new Float32Array(cols * rows);
396 + lineTrail = new Float32Array(cols * rows);
397 + quiet = new Float32Array(cols * rows);
398 + minC = minR = 1;
399 + maxC = maxR = 0;
400 +
401 + buildAtlases();
402 + lastAmbient = -Infinity;
403 + render(performance.now(), 0);
404 + }
405 +
406 + function applyTheme() {
407 + const next = readTheme();
408 + if (next.text === theme.text && next.accent === theme.accent && next.line === theme.line && next.mode === theme.mode) return;
409 + theme = next;
410 + buildAtlases();
411 + lastAmbient = -Infinity;
412 + render(performance.now(), 0);
413 + }
414 +
415 + function cellX(c) {
416 + return Math.round(c * cellW * dpr);
417 + }
418 +
419 + function cellY(r) {
420 + return Math.round(r * cellH * dpr);
421 + }
422 +
423 + // Het rustende veld: twee trage golven per as en een diagonale band. Alleen de toppen
424 + // worden zichtbaar, zodat er losse wolken ontstaan en geen egaal behang.
425 + function drawAmbient(time, elapsed) {
426 + const t = time / 1000;
427 + const max = options.ambientMax[theme.mode];
428 + const colWave = new Float32Array(cols);
429 + const rowWave = new Float32Array(rows);
430 + for (let c = 0; c < cols; c += 1) {
431 + colWave[c] = Math.sin(c * 0.045 + t * 0.21) + 0.6 * Math.sin(c * 0.013 - t * 0.09);
432 + }
433 + for (let r = 0; r < rows; r += 1) {
434 + rowWave[r] = Math.sin(r * 0.11 - t * 0.17) + 0.6 * Math.sin(r * 0.031 + t * 0.07);
435 + }
436 +
437 + // Tekens wisselen ook in rust, heel af en toe.
438 + const rerollChance = elapsed > 0 ? Math.min(0.05 * elapsed, 0.2) : 0;
439 +
440 + layerCtx.setTransform(1, 0, 0, 1, 0, 0);
441 + layerCtx.clearRect(0, 0, layer.width, layer.height);
442 + let currentAlpha = -1;
443 +
444 + for (let r = 0; r < rows; r += 1) {
445 + const y = cellY(r);
446 + const rw = rowWave[r];
447 + for (let c = 0; c < cols; c += 1) {
448 + const index = r * cols + c;
449 + const wave = (colWave[c] * rw + Math.sin((c + r * 2.2) * 0.035 + t * 0.12)) / 3.2;
450 + const cloud = clamp01((wave + options.ambientLift) * 2.2);
451 + const alpha = Math.round(max * cloud * cloud * 128) / 128;
452 + ambient[index] = alpha;
453 + if (rerollChance && Math.random() < rerollChance) glyphs[index] = randomGlyph();
454 + if (alpha < MIN_ALPHA) continue;
455 + if (alpha !== currentAlpha) {
456 + layerCtx.globalAlpha = alpha;
457 + currentAlpha = alpha;
458 + }
459 + layerCtx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
460 + }
461 + }
462 + layerCtx.globalAlpha = 1;
463 + }
464 +
465 + // Een zachte kwast op de plek van de muis; het spoor neemt het hoogste van oud en nieuw.
466 + // flat: een harde kern zonder zachte rand, voor een scherpe lijn in plaats van een vlek.
467 + function stamp(px, py, radius = options.brushRadius, strength = 1, flat = false, target = trail) {
468 + const c0 = Math.max(0, Math.floor((px - radius) / cellW));
469 + const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
470 + const r0 = Math.max(0, Math.floor((py - radius) / cellH));
471 + const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
472 + if (c0 > c1 || r0 > r1) return;
473 + const inv = 1 / (radius * radius);
474 +
475 + for (let r = r0; r <= r1; r += 1) {
476 + const dy = r * cellH + cellH / 2 - py;
477 + for (let c = c0; c <= c1; c += 1) {
478 + const dx = c * cellW + cellW / 2 - px;
479 + const d = (dx * dx + dy * dy) * inv;
480 + if (d >= 1) continue;
481 + // Rafelige rand: niet elke cel aan de buitenkant doet even hard mee.
482 + const edge = flat ? (d < 0.6 ? 1 : (1 - d) / 0.4) : (1 - d) * (1 - d) * (0.75 + Math.random() * 0.25);
483 + const value = edge * strength;
484 + const index = r * cols + c;
485 + if (value > target[index]) target[index] = value;
486 + }
487 + }
488 +
489 + extendBox(c0, c1, r0, r1);
490 + }
491 +
492 + function extendBox(c0, c1, r0, r1) {
493 + if (minC > maxC) {
494 + minC = c0;
495 + maxC = c1;
496 + minR = r0;
497 + maxR = r1;
498 + } else {
499 + minC = Math.min(minC, c0);
500 + maxC = Math.max(maxC, c1);
501 + minR = Math.min(minR, r0);
502 + maxR = Math.max(maxR, r1);
503 + }
504 + }
505 +
506 + // Een hartvormige ring met de grootste straal `size` rond (cx, cy). Per rij worden alleen
507 + // de kolommen bekeken die tussen de kleinste en grootste straal van de ring vallen.
508 + function stampRing(cx, cy, size, strength) {
509 + const half = options.heartThickness / 2;
510 + const outer = size + half;
511 + const inner = Math.max(0, size * HEART_MIN - half);
512 + const r0 = Math.max(0, Math.floor((cy - outer) / cellH));
513 + const r1 = Math.min(rows - 1, Math.ceil((cy + outer) / cellH));
514 + let touched = false;
515 +
516 + for (let r = r0; r <= r1; r += 1) {
517 + const dy = r * cellH + cellH / 2 - cy;
518 + const outerSpan = outer * outer - dy * dy;
519 + if (outerSpan <= 0) continue;
520 + const reach = Math.sqrt(outerSpan);
521 + const innerSpan = inner * inner - dy * dy;
522 + const gap = innerSpan > 0 ? Math.sqrt(innerSpan) : 0;
523 + const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
524 + const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
525 +
526 + for (let c = c0; c <= c1; c += 1) {
527 + const dx = c * cellW + cellW / 2 - cx;
528 + if (dx > -gap && dx < gap) {
529 + c = Math.max(c, Math.floor((cx + gap) / cellW) - 1);
530 + continue;
531 + }
532 + const distance = Math.sqrt(dx * dx + dy * dy);
533 + const offset = Math.abs(distance - size * heartRadius(dx, dy)) / half;
534 + if (offset >= 1) continue;
535 + const value = (1 - offset * offset) * strength;
536 + const index = r * cols + c;
537 + if (value > pulse[index]) pulse[index] = value;
538 + touched = true;
539 + }
540 + }
541 +
542 + if (touched) {
543 + extendBox(
544 + Math.max(0, Math.floor((cx - outer) / cellW)),
545 + Math.min(cols - 1, Math.ceil((cx + outer) / cellW)),
546 + r0,
547 + r1
548 + );
549 + }
550 + }
551 +
552 + // Een slag: twee ringen vanaf dezelfde plek, de tweede iets later en veel zwakker (lub-dub).
553 + function spawnBeat(now, x, y) {
554 + rings.push({ x, y, start: now, weight: 1 });
555 + rings.push({ x, y, start: now + options.heartDubDelay * 1000, weight: options.heartDubWeight });
556 + if (rings.length > options.heartMaxRings) rings.splice(0, rings.length - options.heartMaxRings);
557 + lastBeatAt = now;
558 + lastBeatSpot = { x, y };
559 + }
560 +
561 + /* De hartslag komt uit de muis. Staat de muis stil, dan klopt hij in een rustig ritme op
562 + die plek; beweegt hij, dan komt er een slag bij zodra hij een stuk verder is, zodat er
563 + hartjes langs het pad liggen. Buiten het venster klopt er niets. */
564 + /* De hartslaglijn loopt los van de muis. De baan wordt per 2 px gevolgd, anders valt de
565 + smalle piek tussen twee frames door; een stempel volgt zodra de lijn een stukje verder
566 + is. */
567 + /* Hoogte voor de hartslaglijn. Een lichte voorkeur voor stroken waar weinig inhoud staat:
568 + uit vijf willekeurige hoogtes wint meestal de vrijste, maar een op de vier keer gewoon
569 + een willekeurige, zodat de lijn overal kan verschijnen. */
570 + function pickLineY(height) {
571 + const candidates = [];
572 + for (let i = 0; i < 5; i += 1) candidates.push(height * (0.15 + Math.random() * 0.75));
573 + const rects = options.obstacles ? options.obstacles() : [];
574 + if (!rects.length || Math.random() < 0.25) return candidates[0];
575 +
576 + const width = window.innerWidth;
577 + const top = options.ecgAmplitude;
578 + const bottom = options.ecgAmplitude * 0.4;
579 + let best = candidates[0];
580 + let bestCover = Infinity;
581 + for (const y of candidates) {
582 + let cover = 0;
583 + for (const rect of rects) {
584 + if (rect.bottom < y - top || rect.top > y + bottom) continue;
585 + cover += Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
586 + }
587 + if (cover < bestCover) {
588 + bestCover = cover;
589 + best = y;
590 + }
591 + }
592 + return best;
593 + }
594 +
595 + // Elke slag een eigen grootte, vast per slag zodat hij niet trilt tussen frames.
596 + function beatScale(line, beat) {
597 + const roll = hash(line.seed + beat * 7.31);
598 + let scale = 0.82 + roll * 0.36;
599 + if (line.wild && hash(line.seed - beat * 3.17) > 0.62) scale *= 1.6 + roll * 0.5;
600 + return scale;
601 + }
602 +
603 + function heartLine(now) {
604 + if (nuke && now - nuke.start < options.nukeEcgAfter * 1000) return;
605 + if (!ecg) {
606 + if (now < nextEcgAt) return;
607 + const height = window.innerHeight;
608 + ecg = {
609 + start: now,
610 + baseY: pickLineY(height),
611 + lastX: -options.ecgRadius,
612 + stampX: -Infinity,
613 + stampY: 0,
614 + offset: Math.random(),
615 + seed: Math.random() * 1000,
616 + // Elke lijn een eigen hoogte en tempo; zo nu en dan een wilde met uitschieters.
617 + amplitude: options.ecgAmplitude * (0.8 + Math.random() * 0.4),
618 + beatWidth: options.ecgBeatWidth * (0.8 + Math.random() * 0.45),
619 + wild: Math.random() < options.ecgWildChance,
620 + };
621 + }
622 +
623 + const width = window.innerWidth;
624 + const travel = width + options.ecgRadius * 2;
625 + const progress = (now - ecg.start) / (options.ecgCrossSeconds * 1000);
626 + const targetX = -options.ecgRadius + travel * Math.min(progress, 1);
627 +
628 + for (let x = ecg.lastX; x <= targetX; x += 2) {
629 + // Een echte lijn ligt nooit stil: de basislijn deint zacht mee (ademhaling).
630 + const sway = ecg.wild ? 2 : 1;
631 + const wander = (Math.sin(x * 0.006 + ecg.offset * 6) * 0.1 + Math.sin(x * 0.017 + ecg.offset * 3) * 0.04) * sway;
632 + const phase = x / ecg.beatWidth + ecg.offset;
633 + const y = ecg.baseY - (ecgShape(phase % 1) * beatScale(ecg, Math.floor(phase)) + wander) * ecg.amplitude;
634 + if (Math.hypot(x - ecg.stampX, y - ecg.stampY) >= options.ecgRadius * 0.3) {
635 + stamp(x, y, options.ecgRadius, options.ecgStrength, true, lineTrail);
636 + ecg.stampX = x;
637 + ecg.stampY = y;
638 + }
639 + ecg.lastX = x + 2;
640 + }
641 +
642 + if (progress >= 1) {
643 + ecg = null;
644 + nextEcgAt = now + randomBetween(options.ecgEverySeconds) * 1000;
645 + }
646 + }
647 +
648 + function spawnSmiley(x, y) {
649 + let kind = (Math.random() * SMILEYS.length) | 0;
650 + // Niet twee keer achter elkaar dezelfde.
651 + if (smileys.length && smileys[smileys.length - 1].kind === kind) kind = (kind + 1) % SMILEYS.length;
652 + smileys.push({ x, y, kind, start: performance.now(), tilt: (Math.random() - 0.5) * 0.5 });
653 + if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax);
654 + }
655 +
656 +
657 + /* Een smiley springt op (even groter dan zijn eindmaat), zweeft een stukje omhoog en
658 + dooft uit. Hij wordt elk frame opnieuw in het korte spoor gezet; als hij weg is, flitst
659 + hij in een paar tienden van een seconde uit. */
660 + function drawSmileys(now) {
661 + let keep = 0;
662 + for (const smiley of smileys) {
663 + const age = (now - smiley.start) / 1000;
664 + if (age >= options.smileySeconds) continue;
665 + smileys[keep] = smiley;
666 + keep += 1;
667 +
668 + const pop = Math.min(age / 0.22, 1);
669 + const scale = pop < 1 ? 0.3 + 0.9 * Math.sin((pop * Math.PI) / 2) + 0.1 * Math.sin(pop * Math.PI) : 1;
670 + const fade = Math.min(1, (options.smileySeconds - age) / 0.45);
671 + const radius = options.smileyRadius * scale;
672 + const cx = smiley.x;
673 + const cy = smiley.y - age * 40;
674 + const cos = Math.cos(smiley.tilt);
675 + const sin = Math.sin(smiley.tilt);
676 + // Stille cirkel: binnen 1,1 keer de straal helemaal stil, tot 1,5 keer zacht aflopend.
677 + const reach = radius * 1.5;
678 +
679 + const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
680 + const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
681 + const r0 = Math.max(0, Math.floor((cy - reach) / cellH));
682 + const r1 = Math.min(rows - 1, Math.ceil((cy + reach) / cellH));
683 + if (c0 > c1 || r0 > r1) continue;
684 +
685 + for (let r = r0; r <= r1; r += 1) {
686 + const py = (r * cellH + cellH / 2 - cy) / radius;
687 + for (let c = c0; c <= c1; c += 1) {
688 + const px = (c * cellW + cellW / 2 - cx) / radius;
689 + const x = px * cos + py * sin;
690 + const y = -px * sin + py * cos;
691 + const index = r * cols + c;
692 + const d2 = x * x + y * y;
693 + if (d2 < 2.25) {
694 + const still = fade * (d2 < 1.21 ? 1 : (2.25 - d2) / 1.04);
695 + if (still > quiet[index]) quiet[index] = still;
696 + }
697 + if (!smileyAt(smiley.kind, x, y)) continue;
698 + if (fade > streak[index]) streak[index] = fade;
699 + }
700 + }
701 + extendBox(c0, c1, r0, r1);
702 + }
703 + smileys.length = keep;
704 + }
705 +
706 + function onPointerDown(event) {
707 + if (reducedMotion.matches || event.button !== 0) return;
708 + if (options.isBackground && !options.isBackground(event)) return;
709 + if (nuke) {
710 + // In de nasleep geen nieuwe explosie; na nukeSmileyAfter mogen de smileys wel weer.
711 + if (nukeAge(performance.now()) >= options.nukeSmileyAfter) spawnSmiley(event.clientX, event.clientY);
712 + return;
713 + }
714 +
715 + const now = performance.now();
716 + clickTimes = clickTimes.filter((time) => now - time < options.nukeClickWindow * 1000);
717 + clickTimes.push(now);
718 + if (clickTimes.length >= options.nukeClicks) {
719 + clickTimes = [];
720 + startNuke(now, event.clientX, event.clientY);
721 + return;
722 + }
723 + spawnSmiley(event.clientX, event.clientY);
724 + }
725 +
726 + function startNuke(now, x, y) {
727 + nuke = { start: now, x, y, puffs: [], puffClock: 0 };
728 + smileys = [];
729 + rings = [];
730 + ecg = null;
731 + pointer = null;
732 + lastPointer = null;
733 + trail.fill(0);
734 + pulse.fill(0);
735 + nextEcgAt = now + options.nukeEcgAfter * 1000;
736 + }
737 +
738 + // Hoe ver de nasleep is, in seconden; Infinity als er geen explosie is geweest.
739 + function nukeAge(now) {
740 + return nuke ? (now - nuke.start) / 1000 : Infinity;
741 + }
742 +
743 + function heartbeat(now) {
744 + if (nukeAge(now) < options.nukeHeartAfter) return;
745 + if (heartHome) {
746 + const sinceBeat = now - lastBeatAt;
747 + const moved = lastBeatSpot
748 + ? Math.hypot(heartHome.x - lastBeatSpot.x, heartHome.y - lastBeatSpot.y)
749 + : Infinity;
750 + if (
751 + sinceBeat >= options.heartPeriod * 1000 ||
752 + (moved >= options.heartSpacing && sinceBeat >= options.heartMinGap * 1000)
753 + ) {
754 + spawnBeat(now, heartHome.x, heartHome.y);
755 + }
756 + }
757 +
758 + const reach = Math.max(window.innerWidth, window.innerHeight) * options.heartReach;
759 + let keep = 0;
760 + for (const ring of rings) {
761 + const age = (now - ring.start) / 1000;
762 + if (age >= options.heartDuration) continue;
763 + rings[keep] = ring;
764 + keep += 1;
765 + if (age < 0) continue;
766 + const progress = age / options.heartDuration;
767 + const size = reach * (1 - (1 - progress) * (1 - progress));
768 + const strength = options.heartStrength * ring.weight * (1 - progress) ** 1.3;
769 + // Een bijna uitgedoofde ring is groot en kost veel cellen voor weinig beeld.
770 + if (size > 4 && strength > 0.06) stampRing(ring.x, ring.y, size, strength);
771 + }
772 + rings.length = keep;
773 + }
774 +
775 + function paintPointer() {
776 + const from = lastPointer || pointer;
777 + const dx = pointer.x - from.x;
778 + const dy = pointer.y - from.y;
779 + const step = options.brushRadius * 0.35;
780 + const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
781 + for (let s = 1; s <= steps; s += 1) {
782 + stamp(from.x + (dx * s) / steps, from.y + (dy * s) / steps);
783 + }
784 + lastPointer = pointer;
785 + pointer = null;
786 + }
787 +
788 + // Het spoor: alleen de cellen binnen de rechthoek met spoor. De cel wordt eerst leeg
789 + // gemaakt, zodat het teken uit de rustlaag er niet dubbel onder staat.
790 + function drawTrail(dt) {
791 + if (minC > maxC) return;
792 + const decay = dt > 0 ? Math.exp(-dt / options.trailSeconds) : 1;
793 + const pulseDecay = dt > 0 ? Math.exp(-dt / options.heartSeconds) : 1;
794 + const streakDecay = dt > 0 ? Math.exp(-dt / options.streakSeconds) : 1;
795 + const neutralMax = options.trailNeutral[theme.mode];
796 + const accentMax = options.trailAccent[theme.mode];
797 + const streakNeutral = options.streakNeutral[theme.mode];
798 + const streakAccent = options.streakAccent[theme.mode];
799 + let nextMinC = cols;
800 + let nextMaxC = -1;
801 + let nextMinR = rows;
802 + let nextMaxR = -1;
803 +
804 + for (let r = minR; r <= maxR; r += 1) {
805 + const y = cellY(r);
806 + for (let c = minC; c <= maxC; c += 1) {
807 + const index = r * cols + c;
808 + let heat = trail[index];
809 + let beat = pulse[index];
810 + let flash = streak[index];
811 + let pulseLine = lineTrail[index];
812 + let hush = quiet[index];
813 + if (heat <= 0 && beat <= 0 && flash <= 0 && pulseLine <= 0 && hush <= 0) continue;
814 +
815 + if (heat > 0) {
816 + heat *= decay;
817 + if (heat < 0.01) heat = 0;
818 + trail[index] = heat;
819 + }
820 + if (beat > 0) {
821 + beat *= pulseDecay;
822 + if (beat < 0.04) beat = 0;
823 + pulse[index] = beat;
824 + }
825 + if (flash > 0) {
826 + flash *= streakDecay;
827 + if (flash < 0.03) flash = 0;
828 + streak[index] = flash;
829 + }
830 + if (pulseLine > 0) {
831 + pulseLine *= streakDecay;
832 + if (pulseLine < 0.03) pulseLine = 0;
833 + lineTrail[index] = pulseLine;
834 + }
835 + if (hush > 0) {
836 + hush *= streakDecay;
837 + if (hush < 0.03) hush = 0;
838 + quiet[index] = hush;
839 + }
840 + if (heat === 0 && beat === 0 && flash === 0 && pulseLine === 0 && hush === 0) continue;
841 +
842 + if (c < nextMinC) nextMinC = c;
843 + if (c > nextMaxC) nextMaxC = c;
844 + if (r < nextMinR) nextMinR = r;
845 + if (r > nextMaxR) nextMaxR = r;
846 +
847 + // Onder het spoor en de hartslag wisselen de tekens snel.
848 + if (dt > 0 && Math.random() < (heat * 9 + beat * 5 + (flash + pulseLine) * 6) * dt) glyphs[index] = randomGlyph();
849 +
850 + // Een verse ring is roder dan het spoor; verder weg is hij al zwakker en daarmee
851 + // vanzelf grijzer.
852 + // In de stille cirkel van een smiley (hush) en tijdens de nasleep van een explosie
853 + // (calm) wijken het spoor en de ringen; de smiley en de hartslaglijn blijven.
854 + heat *= calm * (1 - hush);
855 + beat *= calm * (1 - hush);
856 + const lift = heat > beat ? heat : beat;
857 + const ring = Math.min(1, beat * options.heartRed);
858 + const glow = heat > ring ? heat : ring;
859 + const x = cellX(c);
860 + const sx = glyphs[index] * tileW;
861 + // Achter een smiley wordt het rustende veld stilgezet (hush), zodat het gezicht
862 + // niet in de wolken verdwijnt. De smileys (flash) en de hartslaglijn (pulseLine) zijn vooral rood; de lijn in --glyph-line.
863 + const neutral = ambient[index] * calm * (1 - lift) * (1 - hush) + neutralMax * lift + streakNeutral * Math.max(flash, pulseLine);
864 + const red = Math.max(glow * glow * accentMax, flash * streakAccent);
865 + const lineRed = pulseLine * streakAccent;
866 + ctx.clearRect(x, y, tileW, tileH);
867 + if (neutral >= MIN_ALPHA) {
868 + ctx.globalAlpha = neutral;
869 + ctx.drawImage(neutralAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
870 + }
871 + if (red > 0.01) {
872 + ctx.globalAlpha = Math.min(1, red);
873 + ctx.drawImage(accentAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
874 + }
875 + if (lineRed > 0.01) {
876 + ctx.globalAlpha = Math.min(1, lineRed);
877 + ctx.drawImage(lineAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
878 + }
879 + }
880 + }
881 +
882 + ctx.globalAlpha = 1;
883 + minC = nextMinC;
884 + maxC = nextMaxC;
885 + minR = nextMinR;
886 + maxR = nextMaxR;
887 + }
888 +
889 + /* Zwarte rook die uit de kop van de paddenstoel spuwt en naar de bovenkant van het scherm
890 + klimt. Losse wolkjes die opstijgen, opzwellen en onderweg dunner worden. */
891 + function drawSmoke(t, dt) {
892 + const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
893 + const capY = nuke.y - 300 * size * easeOut(t / 2.2);
894 + const capRx = (50 + 190 * easeOut(t / 1.4)) * size;
895 +
896 + // Spuwen tussen 0,5 en 3,2 seconden, zo'n vijfentwintig wolkjes per seconde. Alle rook is
897 + // weg rond 5,5 seconden, als de letters gevallen zijn.
898 + if (dt > 0 && t > 0.5 && t < 3.2) {
899 + nuke.puffClock += dt * 25;
900 + while (nuke.puffClock >= 1) {
901 + nuke.puffClock -= 1;
902 + const startY = capY - Math.random() * 40 * size;
903 + nuke.puffs.push({
904 + x: nuke.x + (Math.random() - 0.5) * capRx * 0.9,
905 + y: startY,
906 + // Snel genoeg om in een paar seconden de bovenkant te halen.
907 + vy: -(startY + 120) / (1.1 + Math.random() * 0.8),
908 + vx: (Math.random() - 0.5) * 50 * size,
909 + radius: (14 + Math.random() * 18) * size,
910 + born: t,
911 + seed: Math.random() * 10,
912 + });
913 + }
914 + }
915 +
916 + const life = 2.4;
917 + const allFade = t < 4.2 ? 1 : Math.max(0, 1 - (t - 4.2) / 1.3);
918 + let keep = 0;
919 + for (const puff of nuke.puffs) {
920 + const age = t - puff.born;
921 + if (age >= life) continue;
922 + nuke.puffs[keep] = puff;
923 + keep += 1;
924 +
925 + const px = puff.x + puff.vx * age;
926 + const py = puff.y + puff.vy * age * (1 - age / (life * 2.4));
927 + const radius = puff.radius * (1 + age * 0.85);
928 + const strength = 0.75 * (1 - age / life) * Math.min(1, age / 0.15) * allFade;
929 + if (py + radius < 0 || strength <= 0.01) continue;
930 +
931 + const c0 = Math.max(0, Math.floor((px - radius) / cellW));
932 + const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
933 + const r0 = Math.max(0, Math.floor((py - radius) / cellH));
934 + const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
935 + for (let r = r0; r <= r1; r += 1) {
936 + const dy = r * cellH + cellH / 2 - py;
937 + for (let c = c0; c <= c1; c += 1) {
938 + const dx = c * cellW + cellW / 2 - px;
939 + const angle = Math.atan2(dy, dx);
940 + const edge = 1 + 0.18 * Math.sin(angle * 5 + puff.seed + t * 2);
941 + const d = Math.sqrt(dx * dx + dy * dy) / (radius * edge);
942 + if (d >= 1) continue;
943 + ctx.globalAlpha = strength * (1 - d * d);
944 + ctx.drawImage(smokeAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
945 + }
946 + }
947 + }
948 + nuke.puffs.length = keep;
949 + }
950 +
951 + // 1 als het veld er gewoon staat; 0 tijdens de nasleep van een explosie.
952 + function fieldPresence(now) {
953 + const age = nukeAge(now);
954 + if (age < options.nukeFallAfter) return 1;
955 + if (age < options.nukeNormalAfter) return 0;
956 + return Math.min(1, (age - options.nukeNormalAfter) / 2);
957 + }
958 +
959 + function easeOut(value) {
960 + const v = Math.min(1, Math.max(0, value));
961 + return 1 - (1 - v) ** 3;
962 + }
963 +
964 + /* De explosie, rechtstreeks op het canvas: een rode flits over het hele veld, een
965 + schokgolf die over het scherm rolt, een paddenstoelwolk van kokende letters, het veld
966 + dat schudt, en daarna vallen alle letters van het scherm. */
967 + function drawNuke(now, dt) {
968 + const t = nukeAge(now);
969 + const accentAlpha = theme.mode === "light" ? 0.85 : 0.95;
970 +
971 + // Schudden: alleen het canvas, de inhoud van de pagina blijft staan.
972 + if (t < 1.3) {
973 + const shake = 12 * (1 - t / 1.3) ** 2;
974 + canvas.style.transform = `translate(${(Math.random() - 0.5) * 2 * shake}px, ${(Math.random() - 0.5) * 2 * shake}px)`;
975 + } else if (canvas.style.transform) {
976 + canvas.style.transform = "";
977 + }
978 +
979 + // Flits: het hele veld licht even rood op.
980 + if (t < 0.7) {
981 + const flashAtlas = t < 0.2 ? yellowAtlas : t < 0.42 ? orangeAtlas : accentAtlas;
982 + ctx.globalAlpha = 0.6 * (1 - t / 0.7) ** 2;
983 + for (let r = 0; r < rows; r += 1) {
984 + const y = cellY(r);
985 + for (let c = 0; c < cols; c += 1) {
986 + ctx.drawImage(flashAtlas, glyphs[r * cols + c] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
987 + }
988 + }
989 + }
990 +
991 + // Schokgolf: een ring die in anderhalve seconde voorbij de randen van het scherm rolt.
992 + if (t < 1.8) {
993 + const progress = t / 1.8;
994 + const reach = Math.hypot(window.innerWidth, window.innerHeight);
995 + const radius = reach * easeOut(progress);
996 + const band = 30 + 90 * (1 - progress);
997 + const strength = 0.95 * (1 - progress);
998 + const r0 = Math.max(0, Math.floor((nuke.y - radius - band) / cellH));
999 + const r1 = Math.min(rows - 1, Math.ceil((nuke.y + radius + band) / cellH));
1000 + for (let r = r0; r <= r1; r += 1) {
1001 + const dy = r * cellH + cellH / 2 - nuke.y;
1002 + const outer = (radius + band) ** 2 - dy * dy;
1003 + if (outer <= 0) continue;
1004 + const reachX = Math.sqrt(outer);
1005 + const c0 = Math.max(0, Math.floor((nuke.x - reachX) / cellW));
1006 + const c1 = Math.min(cols - 1, Math.ceil((nuke.x + reachX) / cellW));
1007 + for (let c = c0; c <= c1; c += 1) {
1008 + const dx = c * cellW + cellW / 2 - nuke.x;
1009 + const off = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius) / band;
1010 + if (off >= 1) continue;
1011 + const front = Math.sqrt(dx * dx + dy * dy) - radius;
1012 + const waveAtlas = front > band * 0.3 ? yellowAtlas : front > -band * 0.3 ? orangeAtlas : accentAtlas;
1013 + ctx.globalAlpha = strength * (1 - off);
1014 + ctx.drawImage(waveAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
1015 + }
1016 + }
1017 + }
1018 +
1019 + // Paddenstoelwolk: een kokende kop die opstijgt, een steel en een wolk over de grond.
1020 + if (t < 4.2) {
1021 + const grow = easeOut(t / 1.4);
1022 + const fade = t < 2.8 ? 1 : Math.max(0, 1 - (t - 2.8) / 1.4);
1023 + // Groot op een groot scherm, passend op een klein scherm.
1024 + const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
1025 + const rise = 300 * size * easeOut(t / 2.2);
1026 + const capY = nuke.y - rise;
1027 + const capRx = (50 + 190 * grow) * size;
1028 + const capRy = (35 + 85 * grow) * size;
1029 + const baseRx = (70 + 230 * grow) * size;
1030 + const baseRy = (22 + 34 * grow) * size;
1031 + const spanX = Math.max(capRx * 1.25, baseRx);
1032 + const c0 = Math.max(0, Math.floor((nuke.x - spanX) / cellW));
1033 + const c1 = Math.min(cols - 1, Math.ceil((nuke.x + spanX) / cellW));
1034 + const r0 = Math.max(0, Math.floor((capY - capRy * 1.25) / cellH));
1035 + const r1 = Math.min(rows - 1, Math.ceil((nuke.y + baseRy) / cellH));
1036 +
1037 + for (let r = r0; r <= r1; r += 1) {
1038 + const py = r * cellH + cellH / 2;
1039 + for (let c = c0; c <= c1; c += 1) {
1040 + const dx = c * cellW + cellW / 2 - nuke.x;
1041 + let core = 0;
1042 +
1043 + const capDy = py - capY;
1044 + const angle = Math.atan2(capDy, dx);
1045 + const edge = 1 + 0.14 * Math.sin(angle * 7 + t * 2.5) + 0.08 * Math.sin(angle * 13 - t * 4);
1046 + const capD = Math.sqrt((dx / capRx) ** 2 + (capDy / capRy) ** 2) / edge;
1047 + if (capD < 1) core = Math.max(core, 1 - capD * 0.6);
1048 +
1049 + if (py > capY && py < nuke.y) {
1050 + const along = (py - capY) / Math.max(rise, 1);
1051 + const half = (18 + 14 * grow + 30 * grow * along * along) * size;
1052 + if (Math.abs(dx) < half) core = Math.max(core, 0.75);
1053 + }
1054 +
1055 + const baseD = Math.sqrt((dx / baseRx) ** 2 + ((py - nuke.y) / baseRy) ** 2);
1056 + if (baseD < 1 + 0.12 * Math.sin(dx * 0.05 + t * 3)) core = Math.max(core, 0.85 - baseD * 0.4);
1057 +
1058 + if (core <= 0) continue;
1059 + const x = cellX(c);
1060 + const y = cellY(r);
1061 + const glyph = randomGlyph() * tileW;
1062 + // Heet in de kern; naarmate de wolk afkoelt schuift alles richting rood.
1063 + const heatLevel = core - Math.max(0, t - 1.2) * 0.12 + (Math.random() - 0.5) * 0.12;
1064 + const fireAtlas = heatLevel > 0.8 ? yellowAtlas : heatLevel > 0.58 ? orangeAtlas : accentAtlas;
1065 + ctx.globalAlpha = Math.min(1, accentAlpha * core * fade);
1066 + ctx.drawImage(fireAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
1067 + ctx.globalAlpha = 0.35 * core * core * fade;
1068 + ctx.drawImage(neutralAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
1069 + }
1070 + }
1071 + }
1072 +
1073 + drawSmoke(t, dt);
1074 +
1075 + // De letters vallen: elk teken uit het rustende veld krijgt een eigen moment en valt
1076 + // dan met zwaartekracht van het scherm.
1077 + if (t >= options.nukeFallAfter && !fallers) {
1078 + const list = [];
1079 + for (let i = 0; i < ambient.length; i += 1) if (ambient[i] >= 0.012) list.push(i);
1080 + fallers = {
1081 + index: Int32Array.from(list),
1082 + y: new Float32Array(list.length),
1083 + vy: new Float32Array(list.length),
1084 + delay: Float32Array.from(list, () => Math.random() * 1.8),
1085 + };
1086 + }
1087 + if (fallers && t < options.nukeNormalAfter) {
1088 + const since = t - options.nukeFallAfter;
1089 + const floor = canvas.height;
1090 + for (let i = 0; i < fallers.index.length; i += 1) {
1091 + const index = fallers.index[i];
1092 + if (since > fallers.delay[i] && dt > 0) {
1093 + fallers.vy[i] += 1600 * dpr * dt;
1094 + fallers.y[i] += fallers.vy[i] * dt;
1095 + }
1096 + const r = (index / cols) | 0;
1097 + const y = cellY(r) + fallers.y[i];
1098 + if (y > floor) continue;
1099 + // Tijdens het vallen iets duidelijker, anders zie je ze bijna niet gaan.
1100 + const boost = since > fallers.delay[i] ? 3.5 : 1;
1101 + ctx.globalAlpha = Math.min(0.5, ambient[index] * boost);
1102 + ctx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(index % cols), y, tileW, tileH);
1103 + }
1104 + }
1105 +
1106 + ctx.globalAlpha = 1;
1107 + if (t >= options.nukeNormalAfter + 2) {
1108 + nuke = null;
1109 + fallers = null;
1110 + canvas.style.transform = "";
1111 + }
1112 + }
1113 +
1114 + function render(now, dt) {
1115 + // Een venster zonder afmeting (geminimaliseerd of verborgen bij het laden): niets tekenen.
1116 + // drawImage van een leeg canvas gooit een fout, en die fout brak hier createGlyphField af.
1117 + // Bij de eerste resize gaat het vanzelf verder.
1118 + if (!neutralAtlas || !cols || !canvas.width || !canvas.height) return;
1119 + const hadTrail = minC <= maxC;
1120 + const ambientDue = now - lastAmbient >= options.ambientIntervalMs;
1121 +
1122 + // Los aangeroepen (thema, formaat) terwijl er niets veranderd is: niets tekenen.
1123 + if (!hadTrail && !ambientDue && dt === 0) return;
1124 +
1125 + if (ambientDue) {
1126 + const elapsed = Number.isFinite(lastAmbient) ? (now - lastAmbient) / 1000 : 0;
1127 + drawAmbient(now, elapsed);
1128 + lastAmbient = now;
1129 + }
1130 +
1131 + ctx.setTransform(1, 0, 0, 1, 0, 0);
1132 + ctx.clearRect(0, 0, canvas.width, canvas.height);
1133 + // Na een explosie ligt het veld stil tot nukeNormalAfter en komt het daarna terug. Een
1134 + // smiley maakt alleen een cirkel om zichzelf stil (quiet), niet het hele veld.
1135 + calm = fieldPresence(now);
1136 + if (calm > 0.01) {
1137 + ctx.globalAlpha = calm;
1138 + ctx.drawImage(layer, 0, 0);
1139 + ctx.globalAlpha = 1;
1140 + }
1141 + if (dt > 0) {
1142 + heartbeat(now);
1143 + heartLine(now);
1144 + drawSmileys(now);
1145 + }
1146 + drawTrail(dt);
1147 + if (nuke) drawNuke(now, dt);
1148 + }
1149 +
1150 + function loop(now) {
1151 + if (!running) return;
1152 + frameId = requestAnimationFrame(loop);
1153 + if (pointer && nukeAge(now) >= options.nukeHeartAfter) paintPointer();
1154 + else if (nukeAge(now) < options.nukeHeartAfter) pointer = null;
1155 + // Dertig beelden per seconde is genoeg voor wisselende tekens; dat halveert het werk
1156 + // op een 60 Hz-scherm en meer op een sneller scherm.
1157 + if (lastFrame && now - lastFrame < 1000 / options.maxFps - 2) return;
1158 + const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.25) : 0;
1159 + lastFrame = now;
1160 + render(now, dt);
1161 + }
1162 +
1163 + function start() {
1164 + if (running || destroyed || reducedMotion.matches || document.hidden) return;
1165 + running = true;
1166 + lastFrame = 0;
1167 + frameId = requestAnimationFrame(loop);
1168 + }
1169 +
1170 + function stop() {
1171 + running = false;
1172 + cancelAnimationFrame(frameId);
1173 + }
1174 +
1175 + function onPointerMove(event) {
1176 + if (reducedMotion.matches) return;
1177 + const next = { x: event.clientX, y: event.clientY };
1178 + // Na het verlaten van het venster geen streep trekken vanaf de oude plek.
1179 + if (!lastPointer) lastPointer = next;
1180 + pointer = next;
1181 + heartHome = next;
1182 + }
1183 +
1184 + function onPointerLeave() {
1185 + lastPointer = null;
1186 + pointer = null;
1187 + heartHome = null;
1188 + lastBeatSpot = null;
1189 + }
1190 +
1191 + function onVisibility() {
1192 + if (document.hidden) stop();
1193 + else start();
1194 + }
1195 +
1196 + function onMotionChange() {
1197 + if (reducedMotion.matches) {
1198 + stop();
1199 + trail.fill(0);
1200 + pulse.fill(0);
1201 + rings = [];
1202 + smileys = [];
1203 + nuke = null;
1204 + fallers = null;
1205 + canvas.style.transform = "";
1206 + streak.fill(0);
1207 + lineTrail.fill(0);
1208 + quiet.fill(0);
1209 + minC = minR = 1;
1210 + maxC = maxR = 0;
1211 + lastAmbient = -Infinity;
1212 + render(performance.now(), 0);
1213 + } else {
1214 + start();
1215 + }
1216 + }
1217 +
1218 + let resizeTimer = 0;
1219 + function onResize() {
1220 + window.clearTimeout(resizeTimer);
1221 + resizeTimer = window.setTimeout(resize, 120);
1222 + }
1223 +
1224 + // Het thema wisselt via data-appearance op <html>; zie layout.css.
1225 + const themeObserver = new MutationObserver(applyTheme);
1226 + themeObserver.observe(document.documentElement, {
1227 + attributes: true,
1228 + attributeFilter: ["data-appearance", "class", "style"],
1229 + });
1230 +
1231 + window.addEventListener("pointermove", onPointerMove, { passive: true });
1232 + window.addEventListener("pointerdown", onPointerDown, { passive: true });
1233 + document.documentElement.addEventListener("pointerleave", onPointerLeave);
1234 + window.addEventListener("blur", onPointerLeave);
1235 + window.addEventListener("resize", onResize);
1236 + document.addEventListener("visibilitychange", onVisibility);
1237 + reducedMotion.addEventListener("change", onMotionChange);
1238 +
1239 + /* Andere intensiteit of ander thema zonder het veld opnieuw te starten. Een nieuw thema zet nieuwe
1240 + tekens in elke cel; een andere intensiteit tekent alleen de rustlaag opnieuw. */
1241 + function configure(next = {}) {
1242 + if (destroyed) return;
1243 + Object.assign(options, next);
1244 + if (next.glyphs) {
1245 + const nextSet = checkGlyphs(next.glyphs);
1246 + const changed = nextSet.length !== glyphSet.length || nextSet.some((glyph, index) => glyph !== glyphSet[index]);
1247 + if (changed) {
1248 + glyphSet = nextSet;
1249 + for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
1250 + buildAtlases();
1251 + }
1252 + }
1253 + lastAmbient = -Infinity;
1254 + render(performance.now(), 0);
1255 + }
1256 +
1257 + resize();
1258 + start();
1259 +
1260 + return { destroy, configure };
1261 +
1262 + function destroy() {
1263 + destroyed = true;
1264 + stop();
1265 + window.clearTimeout(resizeTimer);
1266 + themeObserver.disconnect();
1267 + window.removeEventListener("pointermove", onPointerMove);
1268 + window.removeEventListener("pointerdown", onPointerDown);
1269 + document.documentElement.removeEventListener("pointerleave", onPointerLeave);
1270 + window.removeEventListener("blur", onPointerLeave);
1271 + window.removeEventListener("resize", onResize);
1272 + document.removeEventListener("visibilitychange", onVisibility);
1273 + reducedMotion.removeEventListener("change", onMotionChange);
1274 + }
1275 +}
1276 +
1277 +// Opstart van het tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld).
1278 +// maak-tekenveld.js zet dit achter de engine van Ember; samen vormen ze de JavaScriptExtension.
1279 +// De engine zelf (createGlyphField, glyphFieldSettings) komt ongewijzigd uit Ember.
1280 +
1281 +// Waar een klik iets doet of waar inhoud staat: geen achtergrond, dus geen smiley.
1282 +const KB_NIET_ACHTERGROND = [
1283 + "a",
1284 + "button",
1285 + "input",
1286 + "select",
1287 + "textarea",
1288 + "label",
1289 + "form",
1290 + "summary",
1291 + "[role='button']",
1292 + "[role='dialog']",
1293 + ".navbar",
1294 + "#leftPanels",
1295 + "#rightPanels",
1296 + ".document-header",
1297 + ".wb-zoek > *",
1298 + ".wb-tegels li",
1299 + ".wb-home-onder",
1300 +].join(",");
1301 +
1302 +// De blokken van de startpagina; de hartslaglijn loopt bij voorkeur waar die niet staan.
1303 +const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar";
1304 +
1305 +// Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en
1306 +// beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer.
1307 +const KB_STILSTAND_MS = 60000;
1308 +
1309 +// De explosie van de engine: vijf klikken op de lege achtergrond binnen 1,6 seconde, met een
1310 +// nasleep van een minuut. Dezelfde getallen als de standaard van de engine; hier alleen om
1311 +// tijdens de explosie het vervagen naar het midden weg te halen en het veld wakker te houden.
1312 +const KB_BOEM_KLIKKEN = 5;
1313 +const KB_BOEM_VENSTER_MS = 1600;
1314 +const KB_BOEM_DUUR_MS = 62000;
1315 +
1316 +function kbTekenveld() {
1317 + // Alleen op de startpagina; de extensie wordt ook alleen daar geladen.
1318 + if (!document.querySelector(".wb-home")) return;
1319 +
1320 + // De Kennisbank heeft alleen een lichte stand; de engine leest die van <html>.
1321 + document.documentElement.setAttribute("data-appearance", "light");
1322 +
1323 + const canvas = document.createElement("canvas");
1324 + canvas.className = "kb-tekenveld";
1325 + canvas.setAttribute("aria-hidden", "true");
1326 + document.body.prepend(canvas);
1327 +
1328 + const isAchtergrond = (event) => event.target instanceof Element && !event.target.closest(KB_NIET_ACHTERGROND);
1329 + const opties = {
1330 + ...glyphFieldSettings({ intensity: "lijst" }),
1331 + isBackground: isAchtergrond,
1332 + obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()),
1333 + };
1334 +
1335 + let veld = null;
1336 + let rustTimer = 0;
1337 + let klikken = [];
1338 + let boemTot = 0;
1339 +
1340 + function slaap() {
1341 + // Tijdens een explosie niet: de nasleep duurt een minuut, ook zonder muis.
1342 + if (Date.now() < boemTot) {
1343 + rustTimer = window.setTimeout(slaap, boemTot - Date.now());
1344 + return;
1345 + }
1346 + // Het laatste beeld blijft staan; alleen de animatie stopt.
1347 + veld.destroy();
1348 + veld = null;
1349 + }
1350 +
1351 + // De klik die een slapend veld wekt, ziet de engine zelf niet (hij luistert pas daarna);
1352 + // die telt hier dus ook niet mee, anders lopen de twee tellers uiteen.
1353 + let wekklik = null;
1354 +
1355 + function wakker(event) {
1356 + if (!veld) {
1357 + veld = createGlyphField(canvas, opties);
1358 + wekklik = event || null;
1359 + }
1360 + window.clearTimeout(rustTimer);
1361 + rustTimer = window.setTimeout(slaap, KB_STILSTAND_MS);
1362 + }
1363 +
1364 + function telKlik(event) {
1365 + const nu = Date.now();
1366 + if (event === wekklik || event.button !== 0 || nu < boemTot || !isAchtergrond(event)) return;
1367 + klikken = klikken.filter((tijd) => nu - tijd < KB_BOEM_VENSTER_MS);
1368 + klikken.push(nu);
1369 + if (klikken.length < KB_BOEM_KLIKKEN) return;
1370 + klikken = [];
1371 + boemTot = nu + KB_BOEM_DUUR_MS;
1372 + // Tijdens de explosie het hele scherm, niet alleen de randen.
1373 + canvas.classList.add("kb-tekenveld--boem");
1374 + window.setTimeout(() => canvas.classList.remove("kb-tekenveld--boem"), KB_BOEM_DUUR_MS);
1375 + }
1376 +
1377 + window.addEventListener("pointermove", wakker, { passive: true });
1378 + // Eerst wakker maken, zodat de engine de klik ook krijgt als het veld sliep.
1379 + window.addEventListener("pointerdown", wakker, { passive: true });
1380 + window.addEventListener("pointerdown", telKlik, { passive: true });
1381 + wakker();
1382 +}
1383 +
1384 +if (document.readyState === "loading") {
1385 + document.addEventListener("DOMContentLoaded", kbTekenveld);
1386 +} else {
1387 + kbTekenveld();
1388 +}
1389 +
1390 +})();
1391 +
name
... ... @@ -1,0 +1,1 @@
1 +Wardenburg tekenveld
parse
... ... @@ -1,0 +1,1 @@
1 +Nee
use
... ... @@ -1,0 +1,1 @@
1 +onDemand
XWiki.StyleSheetExtension[0]
Caching policy
... ... @@ -1,0 +1,1 @@
1 +long
Code
... ... @@ -1,0 +1,45 @@
1 +/* Tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld); alleen geladen op
2 + de startpagina. Het canvas ligt achter de inhoud; de vlakken eromheen worden doorzichtig. */
3 +
4 +/* Kleuren voor de engine: de tekstkleur en het donkere oranje (brand-warning) uit het kleurthema. */
5 +html {
6 + --text: #202124;
7 + --brand-red: #e8920c;
8 + background: #ffffff;
9 +}
10 +
11 +body,
12 +#body,
13 +#contentcontainer,
14 +#contentcolumn,
15 +#mainContentArea,
16 +.main,
17 +#xwikicontent,
18 +.wb-home {
19 + background: transparent !important;
20 +}
21 +
22 +canvas.kb-tekenveld {
23 + position: fixed;
24 + inset: 0;
25 + z-index: -1;
26 + pointer-events: none;
27 + /* Naar het midden, waar het zoekveld en de tegels staan, vervaagt het veld. */
28 + -webkit-mask-image: radial-gradient(ellipse 48% 58% at 50% 55%, transparent 45%, #000 100%);
29 + mask-image: radial-gradient(ellipse 48% 58% at 50% 55%, transparent 45%, #000 100%);
30 +}
31 +
32 +/* Leesbaarheid: een zachte witte gloed achter de kop, het zoekveld en elke tegel. */
33 +.wb-zoek > h1,
34 +.wb-zoek > p,
35 +.wb-zoek > form,
36 +.wb-tegels > li > a {
37 + background: radial-gradient(closest-side, rgba(255, 255, 255, .97) 62%, rgba(255, 255, 255, 0) 100%);
38 +}
39 +
40 +.wb-home-onder {
41 + background: #ffffff;
42 + border-radius: 16px;
43 + box-shadow: 0 0 36px 28px #ffffff;
44 +}
45 +
Content Type
... ... @@ -1,0 +1,1 @@
1 +CSS
Naam
... ... @@ -1,0 +1,1 @@
1 +Wardenburg tekenveld
Inhoud verwerken
... ... @@ -1,0 +1,1 @@
1 +Nee
Gebruik deze uitbreiding
... ... @@ -1,0 +1,1 @@
1 +onDemand
XWiki.XWikiRights[0]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
levels
... ... @@ -1,0 +1,1 @@
1 +view
users
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiGuest
XWiki.XWikiRights[1]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
groups
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiAllGroup
levels
... ... @@ -1,0 +1,1 @@
1 +view
XWiki.XWikiRights[2]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
groups
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiAdminGroup
levels
... ... @@ -1,0 +1,1 @@
1 +view,edit,comment,script