Wijzigen voor document Tekenveld

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

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

Samenvatting

Details

Paginaeigenschappen
Inhoud
... ... @@ -1,6 +1,6 @@
1 -Tekenveld op de startpagina en de inlogpagina van de Kennisbank: een bijna onzichtbaar veld van letters en cijfers achter de inhoud, met een muisspoor, hartslagringen, af en toe een rode ECG-lijn en een smiley bij een klik op de lege achtergrond. Vijf snelle klikken op de lege achtergrond geven een explosie; daarna komt het veld in een minuut terug. Rond feestdagen en herdenkingen (Sint-Maarten, Sinterklaas, Kerst, Nieuwjaar, Pasen, Koningsdag, 4 en 5 mei) neemt een seizoensthema het over, met de kalender uit Ember; ?tekenveld=kerst in de adresbalk toont een thema meteen. Het is hetzelfde veld als in Ember, in een rustige variant: het vervaagt naar het midden en ligt stil na een minuut zonder muis.
1 +Tekenveld op de startpagina van de Kennisbank: een bijna onzichtbaar veld van letters en cijfers achter de inhoud, met een muisspoor, hartslagringen, af en toe een ECG-lijn en een smiley bij een klik op de lege achtergrond. Het is hetzelfde veld als in Ember, in een rustige variant: het vervaagt naar het midden en ligt stil na een minuut zonder muis.
2 2  
3 -Het object //JavaScriptExtension// op deze pagina is de engine van Ember plus een korte opstart; het object //StyleSheetExtension// zet het canvas achter de inhoud. Beide staan op //onDemand// en worden alleen geladen door [[Startpagina>>doc:Wardenburg.Code.Startpagina]] en, op de inlogpagina, door de lader [[TekenveldInloggen>>doc:Wardenburg.Code.TekenveldInloggen]].
3 +Het object //JavaScriptExtension// op deze pagina is de engine van Ember plus een korte opstart; het object //StyleSheetExtension// zet het canvas achter de inhoud. Beide staan op //onDemand// en worden alleen geladen door [[Startpagina>>doc:Wardenburg.Code.Startpagina]].
4 4  
5 -Niet hier wijzigen: de bron staat in de skill xwiki-wardenburg-admin (map bron\tekenveld) en wordt gemaakt met maak-tekenveld.js. Uitzetten: op de startpagina de twee regels met //Wardenburg.Code.Tekenveld// weghalen; op de inlogpagina de pagina TekenveldInloggen verwijderen. Zie het [[register van eigen aanpassingen>>doc:Wardenburg.Code.WebHome]].
5 +Niet hier wijzigen: de bron staat in de skill xwiki-wardenburg-admin (map bron\tekenveld) en wordt gemaakt met maak-tekenveld.js. Uitzetten: de twee regels met //Wardenburg.Code.Tekenveld// van de startpagina weghalen. Zie het [[register van eigen aanpassingen>>doc:Wardenburg.Code.WebHome]].
6 6  
XWiki.JavaScriptExtension[0]
cache
... ... @@ -1,1 +1,0 @@
1 -long
code
... ... @@ -1,3553 +1,0 @@
1 -/* Tekenveld voor de Kennisbank. Gemaakt met maak-tekenveld.js uit de skill xwiki-wardenburg-admin.
2 - Engine en seizoenen: Ember src/lib/glyphField.js en src/lib/glyphSeasons.js, commit fdeea1a.
3 - Niet hier wijzigen: pas de bron aan en maak opnieuw. */
4 -(function () {
5 -"use strict";
6 -// /src/lib/glyphField.js
7 -
8 -/* Het tekenveld (Home, het Dashboard, de lijstpagina's, inloggen en de foutpagina's; welke pagina's
9 - het krijgen staat bij de route, zie src/components/RouteBackground.jsx): een raster van willekeurige letters en cijfers in een monospace
10 - letter, bijna onzichtbaar, met langzaam drijvende dichtere wolken. Waar de muis langs
11 - gaat, worden de tekens donkerder, kleuren ze naar het merkrood en wisselen ze snel; dat
12 - spoor dooft in een paar seconden weer uit. Daaronder klopt een rustige hartslag: twee
13 - hartvormige ringen die vanuit de muis uitdijen en uitdoven. Af en toe schiet er een rode
14 - hartmonitorlijn over het scherm, en een klik op de achtergrond laat een smiley opspringen.
15 -
16 - Bewust zonder React: de engine tekent op een canvas en leest het thema uit de CSS-tokens
17 - (--text en --brand-red), zodat hij licht en donker vanzelf volgt.
18 -
19 - Zuinig met CPU en GPU, want het veld staat open zolang de pagina openstaat:
20 - - de tekens staan eenmalig in een atlas en worden per cel gekopieerd (drawImage) in
21 - plaats van steeds opnieuw gezet (fillText);
22 - - het rustende veld staat op een eigen laag die maar vier keer per seconde opnieuw wordt
23 - getekend; per frame gaat die laag in één keer over het canvas;
24 - - per frame worden alleen de cellen onder het spoor en de hartslag bijgewerkt, binnen de
25 - kleinste rechthoek waarin nog iets ligt;
26 - - op een verborgen tabblad tekent het veld niet, en bij prefers-reduced-motion staat er
27 - één stilstaand beeld zonder spoor. */
28 -
29 -const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
30 -
31 -/* Het thema bepaalt welke tekens het veld gebruikt. Nu alleen de standaardset; een seizoensset
32 - (sneeuwvlokken rond kerst, paaseitjes rond Pasen) komt hier later bij als eigen thema, zonder dat
33 - een pagina iets hoeft te weten. Elk teken krijgt één cel; hooguit 256 tekens (Uint8Array). */
34 -const GLYPH_THEMES = Object.freeze({
35 - standaard: Object.freeze({ label: "Standaard", glyphs: Object.freeze(Array.from(GLYPHS)) }),
36 -});
37 -
38 -const DEFAULT_GLYPH_THEME = "standaard";
39 -
40 -/* Intensiteit per pagina. "vol" is het veld van Home zoals het is; "lijst" heeft in rust minder
41 - tekens (de wolken liggen hoger, zodat er minder cellen boven de drempel komen), maar elk teken is
42 - even duidelijk en het muisspoor, de hartslag, de hartslaglijn, de smileys en de explosie blijven
43 - precies gelijk. Gemeten: in rust staan er op "lijst" ongeveer 70% van de tekens van "vol". */
44 -const GLYPH_INTENSITIES = Object.freeze({
45 - vol: Object.freeze({}),
46 - lijst: Object.freeze({ ambientLift: -0.05 }),
47 -});
48 -
49 -const DEFAULT_GLYPH_INTENSITY = "vol";
50 -
51 -/* De opties voor createGlyphField bij een intensiteit en een thema. Een onbekende naam is een
52 - programmeerfout; die wordt gemeld en valt terug op de standaard, zodat de pagina niet stukgaat. */
53 -function glyphFieldSettings({ intensity = DEFAULT_GLYPH_INTENSITY, theme = DEFAULT_GLYPH_THEME } = {}) {
54 - let intensityOptions = GLYPH_INTENSITIES[intensity];
55 - if (!intensityOptions) {
56 - console.error(`tekenveld: onbekende intensiteit "${intensity}"; ${DEFAULT_GLYPH_INTENSITY} gebruikt`);
57 - intensityOptions = GLYPH_INTENSITIES[DEFAULT_GLYPH_INTENSITY];
58 - }
59 - let themeOptions = GLYPH_THEMES[theme];
60 - if (!themeOptions) {
61 - console.error(`tekenveld: onbekend thema "${theme}"; ${DEFAULT_GLYPH_THEME} gebruikt`);
62 - themeOptions = GLYPH_THEMES[DEFAULT_GLYPH_THEME];
63 - }
64 - return { ...intensityOptions, glyphs: themeOptions.glyphs };
65 -}
66 -
67 -const DEFAULTS = {
68 - // De tekens van het veld; zie GLYPH_THEMES.
69 - glyphs: GLYPH_THEMES.standaard.glyphs,
70 - fontSize: 12,
71 - lineHeight: 15,
72 - fontFamily: 'ui-monospace, "Cascadia Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
73 - // Zichtbaarheid van de wolken in het rustende veld; donker oogt sneller sterker.
74 - ambientMax: { light: 0.1, dark: 0.075 },
75 - // Hoe hoog de wolken liggen: lager betekent minder tekens in rust (zie GLYPH_INTENSITIES).
76 - ambientLift: 0.05,
77 - // Zichtbaarheid onder het spoor van de muis.
78 - trailNeutral: { light: 0.38, dark: 0.3 },
79 - trailAccent: { light: 0.4, dark: 0.45 },
80 - brushRadius: 70,
81 - trailSeconds: 2.2,
82 - ambientIntervalMs: 250,
83 - maxFps: 30,
84 - // Bepaalt of een klik op de achtergrond viel (en dus een smiley geeft); zonder deze
85 - // functie telt elke klik.
86 - isBackground: null,
87 - // Seizoensthema als gegevens (zie src/lib/glyphSeasons.js), of null voor het gewone veld.
88 - season: null,
89 - // Vanaf deze hoogte (px) begint het decor, zodat een slinger niet onder de topbalk verdwijnt.
90 - decorTop: 0,
91 - // Geeft de rechthoeken (getBoundingClientRect) van de inhoud; de hartslaglijn kiest bij
92 - // voorkeur een strook waar die niet staan.
93 - obstacles: null,
94 - // Hartslag: elke slag twee hartvormige ringen vlak na elkaar (lub-dub) die vanaf de muis
95 - // uitdijen en onderweg uitdoven. In rust een slag per heartPeriod; tijdens bewegen een
96 - // slag per heartSpacing pixels, maar niet vaker dan per heartMinGap seconden.
97 - heartPeriod: 4,
98 - heartDubDelay: 0.2,
99 - // De tweede slag (dub) is een zachte naklank van de eerste, geen tweede volle ring.
100 - heartDubWeight: 0.3,
101 - heartSpacing: 420,
102 - heartMinGap: 1.4,
103 - heartMaxRings: 12,
104 - // Hartslaglijn: af en toe trekt er op een willekeurige hoogte een rode lijn in de vorm van
105 - // een hartmonitor van links naar rechts over het scherm; het spoor dooft vanzelf uit.
106 - ecgEverySeconds: [10, 24],
107 - ecgFirstSeconds: [3, 7],
108 - ecgCrossSeconds: 0.7,
109 - ecgBeatWidth: 165,
110 - ecgAmplitude: 105,
111 - ecgRadius: 10,
112 - // De hartslaglijn en de smileys hebben een eigen, kort spoor; ze moeten flitsen, niet
113 - // blijven hangen zoals het spoor van de muis.
114 - streakSeconds: 0.3,
115 - streakNeutral: { light: 0.1, dark: 0.08 },
116 - streakAccent: { light: 1, dark: 1 },
117 - // Smileys: een klik op de achtergrond laat er een opspringen; spammen mag.
118 - smileyRadius: 145,
119 - // Een smiley of klikvorm zweeft smileyRise px per seconde omhoog en blijft smileySeconds staan.
120 - smileySeconds: 3,
121 - smileyRise: 55,
122 - smileyMax: 40,
123 - // Variatie in de hartslaglijn; een op de zes lijnen is wild.
124 - ecgWildChance: 0.17,
125 - // Vijf snelle klikken: een atoomexplosie. De letters vallen van het scherm; na 15 s mogen
126 - // de smileys weer, na 30 s komt de hartslaglijn terug, na 45 s de hartslag en het
127 - // muisspoor, na 60 s het hele veld.
128 - nukeClicks: 5,
129 - nukeClickWindow: 1.6,
130 - nukeFallAfter: 2.4,
131 - nukeSmileyAfter: 15,
132 - nukeEcgAfter: 30,
133 - nukeHeartAfter: 45,
134 - nukeNormalAfter: 60,
135 - ecgStrength: 1,
136 - heartDuration: 1.9,
137 - heartReach: 0.42,
138 - heartThickness: 40,
139 - heartStrength: 0.6,
140 - heartSeconds: 0.15,
141 - // Hoe rood een verse ring is ten opzichte van het muisspoor.
142 - heartRed: 1.3,
143 -};
144 -
145 -const MIN_ALPHA = 0.01;
146 -
147 -/* Straal van een hart per hoek, vanuit het midden van het hart en met de punt naar
148 - beneden; de grootste straal is 1. Gemaakt uit de bekende parametrische hartkromme. */
149 -const HEART_BINS = 360;
150 -const HEART_RADIUS = (() => {
151 - const points = [];
152 - for (let i = 0; i < 2000; i += 1) {
153 - const t = (i / 2000) * Math.PI * 2;
154 - const x = 16 * Math.sin(t) ** 3;
155 - const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
156 - points.push([x, y]);
157 - }
158 - const ys = points.map((p) => p[1]);
159 - const midY = (Math.min(...ys) + Math.max(...ys)) / 2;
160 - const table = new Float32Array(HEART_BINS);
161 - for (const [x, y] of points) {
162 - const angle = Math.atan2(y - midY, x);
163 - const bin = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS;
164 - table[bin] = Math.max(table[bin], Math.hypot(x, y - midY));
165 - }
166 - for (let i = 0; i < HEART_BINS; i += 1) {
167 - if (!table[i]) table[i] = table[(i + HEART_BINS - 1) % HEART_BINS];
168 - }
169 - const max = Math.max(...table);
170 - for (let i = 0; i < HEART_BINS; i += 1) table[i] /= max;
171 - return table;
172 -})();
173 -const HEART_MIN = Math.min(...HEART_RADIUS);
174 -
175 -function randomBetween([min, max]) {
176 - return min + Math.random() * (max - min);
177 -}
178 -
179 -function bump(u, center, width, height) {
180 - const d = (u - center) / width;
181 - return height * Math.exp(-d * d);
182 -}
183 -
184 -/* Een slag van een hartmonitor over een fase u van 0 tot 1: ronde P-golf, de scherpe QRS-piek
185 - met een diepe S, een licht oplopend ST-stuk, een brede T-golf en een kleine U-golf. Omhoog
186 - is positief. */
187 -function ecgShape(u) {
188 - return (
189 - bump(u, 0.18, 0.05, 0.16) +
190 - bump(u, 0.36, 0.012, -0.14) +
191 - bump(u, 0.4, 0.013, 1) +
192 - bump(u, 0.44, 0.014, -0.34) +
193 - bump(u, 0.5, 0.05, 0.04) +
194 - bump(u, 0.66, 0.075, 0.32) +
195 - bump(u, 0.82, 0.035, 0.05)
196 - );
197 -}
198 -
199 -/* Smileys in genormaliseerde coördinaten: het midden is (0, 0), de rand van het gezicht
200 - ligt op straal 1 en y loopt naar beneden. Elke vorm geeft per punt 1 (teken) of 0. */
201 -function inDot(x, y, cx, cy, radius) {
202 - return Math.hypot(x - cx, y - cy) < radius;
203 -}
204 -
205 -function inSmile(x, y) {
206 - return y > 0.12 && Math.abs(Math.hypot(x, y + 0.05) - 0.56) < 0.09;
207 -}
208 -
209 -/* Een hartje als oog: de klassieke hartvorm (x^2 + y^2 - 1)^3 - x^2 y^3 <= 0, met een diepe inkeping
210 - bovenin en een punt onderaan. Alleen de rand, met een lege binnenkant: zo blijft het een hart en
211 - geen vlek. */
212 -function heartValue(dx, dy) {
213 - const x = dx * 1.15;
214 - const y = -dy * 1.15 + 0.15;
215 - return (x * x + y * y - 1) ** 3 - x * x * y ** 3;
216 -}
217 -
218 -function inHeartEye(x, y, cx, cy) {
219 - const dx = (x - cx) / 0.33;
220 - const dy = (y - cy) / 0.33;
221 - return heartValue(dx, dy) <= 0 && heartValue(dx / 0.62, (dy + 0.06) / 0.62) > 0;
222 -}
223 -
224 -const SMILEYS = [
225 - // blij
226 - (x, y) => inDot(x, y, -0.33, -0.28, 0.13) || inDot(x, y, 0.33, -0.28, 0.13) || inSmile(x, y),
227 - // grote lach
228 - (x, y) =>
229 - inDot(x, y, -0.33, -0.3, 0.13) ||
230 - inDot(x, y, 0.33, -0.3, 0.13) ||
231 - // open mond: de rand van een D, met een gat erin
232 - (y > 0.06 && Math.hypot(x, y - 0.05) < 0.62 && !(y > 0.2 && Math.hypot(x, y - 0.05) < 0.44)),
233 - // knipoog
234 - (x, y) =>
235 - (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),
236 - // tong uit
237 - (x, y) =>
238 - inDot(x, y, -0.33, -0.28, 0.13) ||
239 - inDot(x, y, 0.33, -0.28, 0.13) ||
240 - (Math.abs(y - 0.28) < 0.08 && Math.abs(x) < 0.42) ||
241 - (y > 0.28 && inDot(x, y, 0.14, 0.46, 0.21)),
242 - // verliefd
243 - (x, y) => inHeartEye(x, y, -0.36, -0.16) || inHeartEye(x, y, 0.36, -0.16) || (y > 0.24 && inSmile(x, y)),
244 - // verbaasd
245 - (x, y) =>
246 - inDot(x, y, -0.33, -0.3, 0.13) ||
247 - inDot(x, y, 0.33, -0.3, 0.13) ||
248 - Math.abs(Math.hypot(x, y - 0.4) - 0.19) < 0.08,
249 -];
250 -
251 -const SMILEY_VERLIEFD = 4;
252 -
253 -function smileyAt(kind, x, y) {
254 - const r = Math.hypot(x, y);
255 - // De rand is minstens één rasterregel dik, anders valt hij onderaan tussen de regels door.
256 - if (Math.abs(r - 0.9) < 0.12) return true;
257 - if (r > 0.78) return false;
258 - return SMILEYS[kind](x, y);
259 -}
260 -function hash(value) {
261 - const x = Math.sin(value * 12.9898) * 43758.5453;
262 - return x - Math.floor(x);
263 -}
264 -
265 -function heartRadius(dx, dy) {
266 - const bin = Math.floor(((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS;
267 - return HEART_RADIUS[bin];
268 -}
269 -
270 -function clamp01(value) {
271 - return value < 0 ? 0 : value > 1 ? 1 : value;
272 -}
273 -
274 -// Geen seizoen: alle seizoensonderdelen staan uit.
275 -const NO_SEASON = Object.freeze({});
276 -// Tekens die een cel goed vullen; voor figuren en vormen (zie denseGlyph).
277 -const DENSE_GLYPHS = "MWNHBQR8DK0X";
278 -// Schittering als de glazen klinken.
279 -const CLINK_SYMBOLS = ["\u2726", "\u2727", "\u2605", "*"];
280 -// Sneeuwvlokjes voor het decor "sneeuw"; de browser valt voor ontbrekende tekens terug op een symbolenletter.
281 -const SNOW_SYMBOLS = ["\u2744", "\u2745", "\u2746", "\u273b", "\u273c", "*"];
282 -const GOLD_BALL = "#c99a2e";
283 -
284 -function readTheme() {
285 - const root = document.documentElement;
286 - const styles = getComputedStyle(root);
287 - const text = styles.getPropertyValue("--text").trim() || "#111827";
288 - const accent = styles.getPropertyValue("--brand-red").trim() || "#e62b27";
289 - // Een eigen kleur voor de hartslaglijn (ECG); zonder --glyph-line is dat het accent, zoals in Ember.
290 - const line = styles.getPropertyValue("--glyph-line").trim() || accent;
291 - // :root zonder attribuut is in Ember donker; zie layout.css.
292 - const mode = root.getAttribute("data-appearance") === "light" ? "light" : "dark";
293 - return { text, accent, line, mode };
294 -}
295 -
296 -/* De tekens van een thema: een niet-lege lijst van hooguit 256 tekens (de index staat in een Uint8Array). */
297 -function checkGlyphs(glyphs) {
298 - const list = Array.from(glyphs || []);
299 - if (!list.length || list.length > 256) throw new Error("tekenveld: een thema heeft 1 tot 256 tekens");
300 - return list;
301 -}
302 -
303 -/* Wat geen achtergrond is, staat met data-glyph-block op het element: een blok, een tegel, een kop.
304 - Een klik daarop geeft geen smiley, en de hartslaglijn loopt bij voorkeur waar die niet staan. De
305 - pagina markeert zelf; het veld kent geen vaste selectors van een pagina. */
306 -const GLYPH_BLOCK_SELECTOR = "[data-glyph-block]";
307 -
308 -/* De rechthoeken van alle gemarkeerde blokken die zichtbaar zijn; voor de optie obstacles. */
309 -function glyphBlockRects(root = document) {
310 - return Array.from(root.querySelectorAll(GLYPH_BLOCK_SELECTOR), (element) => element.getBoundingClientRect())
311 - .filter((rect) => rect.width > 0 && rect.height > 0);
312 -}
313 -
314 -/* Start het veld op een canvas. Geeft { destroy, configure } terug: configure past de intensiteit of
315 - het thema aan terwijl het veld doorloopt (een andere pagina met hetzelfde veld), zonder opnieuw te
316 - beginnen. */
317 -function createGlyphField(canvas, overrides = {}) {
318 - const options = { ...DEFAULTS, ...overrides };
319 - let glyphSet = checkGlyphs(options.glyphs);
320 - const randomGlyph = () => (Math.random() * glyphSet.length) | 0;
321 - // Figuren, vlag en klikvormen gebruiken vaste, dichte tekens (M, W, 8, ...), zodat de vorm vol en
322 - // strak leest; per cel vast via een hash, zodat niets flikkert.
323 - let denseIndices = [];
324 - function refreshDense() {
325 - denseIndices = [];
326 - glyphSet.forEach((glyph, index) => {
327 - if (DENSE_GLYPHS.includes(glyph)) denseIndices.push(index);
328 - });
329 - }
330 - refreshDense();
331 - function denseGlyph(c, r) {
332 - if (!denseIndices.length) return glyphs[r * cols + c];
333 - return denseIndices[Math.floor(hash(c * 131.7 + r * 7.31) * denseIndices.length)];
334 - }
335 - const ctx = canvas.getContext("2d");
336 - const layer = document.createElement("canvas");
337 - const layerCtx = layer.getContext("2d");
338 - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
339 -
340 - let dpr = 1;
341 - let cellW = 7;
342 - let cellH = options.lineHeight;
343 - let tileW = 7;
344 - let tileH = 15;
345 - let cols = 0;
346 - let rows = 0;
347 - let glyphs = new Uint8Array(0);
348 - let ambient = new Float32Array(0);
349 - let trail = new Float32Array(0);
350 - let pulse = new Float32Array(0);
351 - let streak = new Float32Array(0);
352 - // Het spoor van de hartslaglijn apart van dat van de smileys, zodat de lijn een eigen kleur kan hebben.
353 - let lineTrail = new Float32Array(0);
354 - let quiet = new Float32Array(0);
355 - let neutralAtlas = null;
356 - let accentAtlas = null;
357 - let lineAtlas = null;
358 - let yellowAtlas = null;
359 - let orangeAtlas = null;
360 - let smokeAtlas = null;
361 - let theme = readTheme();
362 -
363 - // Rechthoek (in cellen) waarbinnen nog spoor ligt; leeg als minC > maxC.
364 - let minC = 1;
365 - let maxC = 0;
366 - let minR = 1;
367 - let maxR = 0;
368 -
369 - let pointer = null;
370 - let lastPointer = null;
371 - let heartHome = null;
372 - let rings = [];
373 - let lastBeatAt = -Infinity;
374 - let lastBeatSpot = null;
375 - let ecg = null;
376 - let smileys = [];
377 - let calm = 1;
378 - let nuke = null;
379 - // Proosten op Nieuwjaar (vijf snelle klikken): vuurwerkshow en twee klinkende glazen.
380 - let proost = null;
381 - let clickTimes = [];
382 - let fallers = null;
383 - // Tijd tussen twee frames voor het vuurwerk, gezet door drawDecor.
384 - let fxDt = 0;
385 - // De doorkomer van een seizoen (een figuur in plaats van de ECG-lijn) en de atlassen per kleur.
386 - let figure = null;
387 - const paletteAtlases = new Map();
388 - let nextEcgAt = performance.now() + randomBetween(options.ecgFirstSeconds) * 1000;
389 - let frameId = 0;
390 - let lastFrame = 0;
391 - let lastAmbient = -Infinity;
392 - let running = false;
393 - let destroyed = false;
394 -
395 - // set: de tekens in de atlas; standaard die van het veld, voor symbolen (sneeuwvlokken) een eigen lijst.
396 - function buildAtlas(color, set = glyphSet) {
397 - const atlas = document.createElement("canvas");
398 - atlas.width = tileW * set.length;
399 - atlas.height = tileH;
400 - const a = atlas.getContext("2d");
401 - a.font = `${options.fontSize * dpr}px ${options.fontFamily}`;
402 - a.textBaseline = "middle";
403 - a.textAlign = "center";
404 - a.fillStyle = color;
405 - for (let i = 0; i < set.length; i += 1) {
406 - a.fillText(set[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5);
407 - }
408 - return atlas;
409 - }
410 -
411 - function buildAtlases() {
412 - neutralAtlas = buildAtlas(theme.text);
413 - // Een seizoen kan het accent en de lijnkleur zetten; anders gelden die van de pagina.
414 - const s = season();
415 - const accent = s.accent || theme.accent;
416 - const line = s.line || (s.accent ? s.accent : theme.line);
417 - accentAtlas = buildAtlas(accent);
418 - lineAtlas = line === accent ? accentAtlas : buildAtlas(line);
419 - paletteAtlases.clear();
420 - // Vuurkleuren voor de explosie.
421 - yellowAtlas = buildAtlas(theme.mode === "light" ? "#eab308" : "#facc15");
422 - orangeAtlas = buildAtlas("#f97316");
423 - // Rook: zwart op licht; op donker donkergrijs, anders zie je hem niet.
424 - smokeAtlas = buildAtlas(theme.mode === "light" ? "#0b0b0f" : "#52525b");
425 - }
426 -
427 - function resize() {
428 - dpr = Math.min(window.devicePixelRatio || 1, 2);
429 - const width = window.innerWidth;
430 - const height = window.innerHeight;
431 -
432 - canvas.width = layer.width = Math.round(width * dpr);
433 - canvas.height = layer.height = Math.round(height * dpr);
434 - canvas.style.width = `${width}px`;
435 - canvas.style.height = `${height}px`;
436 -
437 - ctx.font = `${options.fontSize}px ${options.fontFamily}`;
438 - cellW = Math.max(6, Math.round(ctx.measureText("M").width * 10) / 10);
439 - cellH = options.lineHeight;
440 - tileW = Math.ceil(cellW * dpr);
441 - tileH = Math.ceil(cellH * dpr);
442 -
443 - cols = Math.ceil(width / cellW) + 1;
444 - rows = Math.ceil(height / cellH) + 1;
445 - glyphs = new Uint8Array(cols * rows);
446 - for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
447 - ambient = new Float32Array(cols * rows);
448 - trail = new Float32Array(cols * rows);
449 - pulse = new Float32Array(cols * rows);
450 - streak = new Float32Array(cols * rows);
451 - lineTrail = new Float32Array(cols * rows);
452 - quiet = new Float32Array(cols * rows);
453 - minC = minR = 1;
454 - maxC = maxR = 0;
455 -
456 - buildAtlases();
457 - // Sneeuw, lampions en eieren opnieuw verdelen over de nieuwe breedte.
458 - resetSeasonFx();
459 - lastAmbient = -Infinity;
460 - render(performance.now(), 0);
461 - }
462 -
463 - function applyTheme() {
464 - const next = readTheme();
465 - if (next.text === theme.text && next.accent === theme.accent && next.line === theme.line && next.mode === theme.mode) return;
466 - theme = next;
467 - buildAtlases();
468 - lastAmbient = -Infinity;
469 - render(performance.now(), 0);
470 - }
471 -
472 - function cellX(c) {
473 - return Math.round(c * cellW * dpr);
474 - }
475 -
476 - function cellY(r) {
477 - return Math.round(r * cellH * dpr);
478 - }
479 -
480 - // Het rustende veld: twee trage golven per as en een diagonale band. Alleen de toppen
481 - // worden zichtbaar, zodat er losse wolken ontstaan en geen egaal behang.
482 - function drawAmbient(time, elapsed) {
483 - const t = time / 1000;
484 - const max = options.ambientMax[theme.mode];
485 - const colWave = new Float32Array(cols);
486 - const rowWave = new Float32Array(rows);
487 - for (let c = 0; c < cols; c += 1) {
488 - colWave[c] = Math.sin(c * 0.045 + t * 0.21) + 0.6 * Math.sin(c * 0.013 - t * 0.09);
489 - }
490 - for (let r = 0; r < rows; r += 1) {
491 - rowWave[r] = Math.sin(r * 0.11 - t * 0.17) + 0.6 * Math.sin(r * 0.031 + t * 0.07);
492 - }
493 -
494 - // Tekens wisselen ook in rust, heel af en toe.
495 - const rerollChance = elapsed > 0 ? Math.min(0.05 * elapsed, 0.2) : 0;
496 -
497 - layerCtx.setTransform(1, 0, 0, 1, 0, 0);
498 - layerCtx.clearRect(0, 0, layer.width, layer.height);
499 - let currentAlpha = -1;
500 -
501 - for (let r = 0; r < rows; r += 1) {
502 - const y = cellY(r);
503 - const rw = rowWave[r];
504 - for (let c = 0; c < cols; c += 1) {
505 - const index = r * cols + c;
506 - const wave = (colWave[c] * rw + Math.sin((c + r * 2.2) * 0.035 + t * 0.12)) / 3.2;
507 - const cloud = clamp01((wave + options.ambientLift) * 2.2);
508 - const alpha = Math.round(max * cloud * cloud * 128) / 128;
509 - ambient[index] = alpha;
510 - if (rerollChance && Math.random() < rerollChance) glyphs[index] = randomGlyph();
511 - if (alpha < MIN_ALPHA) continue;
512 - if (alpha !== currentAlpha) {
513 - layerCtx.globalAlpha = alpha;
514 - currentAlpha = alpha;
515 - }
516 - layerCtx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
517 - }
518 - }
519 - layerCtx.globalAlpha = 1;
520 - }
521 -
522 - // Een zachte kwast op de plek van de muis; het spoor neemt het hoogste van oud en nieuw.
523 - // flat: een harde kern zonder zachte rand, voor een scherpe lijn in plaats van een vlek.
524 - function stamp(px, py, radius = options.brushRadius, strength = 1, flat = false, target = trail) {
525 - const c0 = Math.max(0, Math.floor((px - radius) / cellW));
526 - const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
527 - const r0 = Math.max(0, Math.floor((py - radius) / cellH));
528 - const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
529 - if (c0 > c1 || r0 > r1) return;
530 - const inv = 1 / (radius * radius);
531 -
532 - for (let r = r0; r <= r1; r += 1) {
533 - const dy = r * cellH + cellH / 2 - py;
534 - for (let c = c0; c <= c1; c += 1) {
535 - const dx = c * cellW + cellW / 2 - px;
536 - const d = (dx * dx + dy * dy) * inv;
537 - if (d >= 1) continue;
538 - // Rafelige rand: niet elke cel aan de buitenkant doet even hard mee.
539 - const edge = flat ? (d < 0.6 ? 1 : (1 - d) / 0.4) : (1 - d) * (1 - d) * (0.75 + Math.random() * 0.25);
540 - const value = edge * strength;
541 - const index = r * cols + c;
542 - if (value > target[index]) target[index] = value;
543 - }
544 - }
545 -
546 - extendBox(c0, c1, r0, r1);
547 - }
548 -
549 - function extendBox(c0, c1, r0, r1) {
550 - if (minC > maxC) {
551 - minC = c0;
552 - maxC = c1;
553 - minR = r0;
554 - maxR = r1;
555 - } else {
556 - minC = Math.min(minC, c0);
557 - maxC = Math.max(maxC, c1);
558 - minR = Math.min(minR, r0);
559 - maxR = Math.max(maxR, r1);
560 - }
561 - }
562 -
563 - // Een hartvormige ring met de grootste straal `size` rond (cx, cy). Per rij worden alleen
564 - // de kolommen bekeken die tussen de kleinste en grootste straal van de ring vallen.
565 - function stampRing(cx, cy, size, strength) {
566 - const half = options.heartThickness / 2;
567 - const outer = size + half;
568 - const inner = Math.max(0, size * HEART_MIN - half);
569 - const r0 = Math.max(0, Math.floor((cy - outer) / cellH));
570 - const r1 = Math.min(rows - 1, Math.ceil((cy + outer) / cellH));
571 - let touched = false;
572 -
573 - for (let r = r0; r <= r1; r += 1) {
574 - const dy = r * cellH + cellH / 2 - cy;
575 - const outerSpan = outer * outer - dy * dy;
576 - if (outerSpan <= 0) continue;
577 - const reach = Math.sqrt(outerSpan);
578 - const innerSpan = inner * inner - dy * dy;
579 - const gap = innerSpan > 0 ? Math.sqrt(innerSpan) : 0;
580 - const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
581 - const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
582 -
583 - for (let c = c0; c <= c1; c += 1) {
584 - const dx = c * cellW + cellW / 2 - cx;
585 - if (dx > -gap && dx < gap) {
586 - c = Math.max(c, Math.floor((cx + gap) / cellW) - 1);
587 - continue;
588 - }
589 - const distance = Math.sqrt(dx * dx + dy * dy);
590 - const offset = Math.abs(distance - size * heartRadius(dx, dy)) / half;
591 - if (offset >= 1) continue;
592 - const value = (1 - offset * offset) * strength;
593 - const index = r * cols + c;
594 - if (value > pulse[index]) pulse[index] = value;
595 - touched = true;
596 - }
597 - }
598 -
599 - if (touched) {
600 - extendBox(
601 - Math.max(0, Math.floor((cx - outer) / cellW)),
602 - Math.min(cols - 1, Math.ceil((cx + outer) / cellW)),
603 - r0,
604 - r1
605 - );
606 - }
607 - }
608 -
609 - // Een slag: twee ringen vanaf dezelfde plek, de tweede iets later en veel zwakker (lub-dub).
610 - function spawnBeat(now, x, y) {
611 - rings.push({ x, y, start: now, weight: 1 });
612 - rings.push({ x, y, start: now + options.heartDubDelay * 1000, weight: options.heartDubWeight });
613 - if (rings.length > options.heartMaxRings) rings.splice(0, rings.length - options.heartMaxRings);
614 - lastBeatAt = now;
615 - lastBeatSpot = { x, y };
616 - }
617 -
618 - /* De hartslag komt uit de muis. Staat de muis stil, dan klopt hij in een rustig ritme op
619 - die plek; beweegt hij, dan komt er een slag bij zodra hij een stuk verder is, zodat er
620 - hartjes langs het pad liggen. Buiten het venster klopt er niets. */
621 - /* De hartslaglijn loopt los van de muis. De baan wordt per 2 px gevolgd, anders valt de
622 - smalle piek tussen twee frames door; een stempel volgt zodra de lijn een stukje verder
623 - is. */
624 - /* Hoogte voor de hartslaglijn. Een lichte voorkeur voor stroken waar weinig inhoud staat:
625 - uit vijf willekeurige hoogtes wint meestal de vrijste, maar een op de vier keer gewoon
626 - een willekeurige, zodat de lijn overal kan verschijnen. */
627 - // above en below: hoeveel ruimte boven en onder de lijn vrij moet zijn; randomShare: hoe vaak een
628 - // willekeurige hoogte wint (de ECG-lijn mag overal, een figuur liever niet achter een kaart).
629 - function pickLineY(height, above = options.ecgAmplitude, below = options.ecgAmplitude * 0.4, randomShare = 0.25) {
630 - const candidates = [];
631 - for (let i = 0; i < 9; i += 1) candidates.push(height * (0.15 + Math.random() * 0.8));
632 - const rects = options.obstacles ? options.obstacles() : [];
633 - if (!rects.length || Math.random() < randomShare) return candidates[0];
634 -
635 - const width = window.innerWidth;
636 - const top = above;
637 - const bottom = below;
638 - let best = candidates[0];
639 - let bestCover = Infinity;
640 - for (const y of candidates) {
641 - let cover = 0;
642 - for (const rect of rects) {
643 - if (rect.bottom < y - top || rect.top > y + bottom) continue;
644 - cover += Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
645 - }
646 - if (cover < bestCover) {
647 - bestCover = cover;
648 - best = y;
649 - }
650 - }
651 - return best;
652 - }
653 -
654 - // Elke slag een eigen grootte, vast per slag zodat hij niet trilt tussen frames.
655 - function beatScale(line, beat) {
656 - const roll = hash(line.seed + beat * 7.31);
657 - let scale = 0.82 + roll * 0.36;
658 - if (line.wild && hash(line.seed - beat * 3.17) > 0.62) scale *= 1.6 + roll * 0.5;
659 - return scale;
660 - }
661 -
662 - function heartLine(now) {
663 - if (nuke && now - nuke.start < options.nukeEcgAfter * 1000) return;
664 - const s = season();
665 - if (s.quiet) return;
666 - // Een seizoen met een doorkomer: die figuur in plaats van de lijn.
667 - if (s.traverser && s.traverser.kind === "vuurwerk") {
668 - runFireworks(now, s.traverser);
669 - return;
670 - }
671 - if (s.traverser) {
672 - runFigure(now, s.traverser);
673 - return;
674 - }
675 - if (!ecg) {
676 - if (now < nextEcgAt) return;
677 - const height = window.innerHeight;
678 - ecg = {
679 - start: now,
680 - baseY: pickLineY(height),
681 - lastX: -options.ecgRadius,
682 - stampX: -Infinity,
683 - stampY: 0,
684 - offset: Math.random(),
685 - seed: Math.random() * 1000,
686 - // Elke lijn een eigen hoogte en tempo; zo nu en dan een wilde met uitschieters.
687 - amplitude: options.ecgAmplitude * (0.8 + Math.random() * 0.4),
688 - beatWidth: options.ecgBeatWidth * (0.8 + Math.random() * 0.45),
689 - wild: Math.random() < options.ecgWildChance,
690 - };
691 - }
692 -
693 - const width = window.innerWidth;
694 - const travel = width + options.ecgRadius * 2;
695 - const progress = (now - ecg.start) / (options.ecgCrossSeconds * 1000);
696 - const targetX = -options.ecgRadius + travel * Math.min(progress, 1);
697 -
698 - for (let x = ecg.lastX; x <= targetX; x += 2) {
699 - // Een echte lijn ligt nooit stil: de basislijn deint zacht mee (ademhaling).
700 - const sway = ecg.wild ? 2 : 1;
701 - const wander = (Math.sin(x * 0.006 + ecg.offset * 6) * 0.1 + Math.sin(x * 0.017 + ecg.offset * 3) * 0.04) * sway;
702 - const phase = x / ecg.beatWidth + ecg.offset;
703 - const y = ecg.baseY - (ecgShape(phase % 1) * beatScale(ecg, Math.floor(phase)) + wander) * ecg.amplitude;
704 - if (Math.hypot(x - ecg.stampX, y - ecg.stampY) >= options.ecgRadius * 0.3) {
705 - stamp(x, y, options.ecgRadius, options.ecgStrength, true, lineTrail);
706 - ecg.stampX = x;
707 - ecg.stampY = y;
708 - }
709 - ecg.lastX = x + 2;
710 - }
711 -
712 - if (progress >= 1) {
713 - ecg = null;
714 - nextEcgAt = now + randomBetween(options.ecgEverySeconds) * 1000;
715 - }
716 - }
717 -
718 - function spawnSmiley(x, y) {
719 - const art = season().clickArt;
720 - if (art && art.burst) {
721 - spawnBurst(x, y);
722 - return;
723 - }
724 - if (art) {
725 - // Een seizoen geeft zijn eigen vorm bij een klik, in plaats van een smiley.
726 - const shape = art.shapes[(Math.random() * art.shapes.length) | 0];
727 - smileys.push({ x, y, art: shape, cellScale: art.cellScale || 1, start: performance.now(), tilt: 0 });
728 - if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax);
729 - return;
730 - }
731 - let kind = (Math.random() * SMILEYS.length) | 0;
732 - // Niet twee keer achter elkaar dezelfde.
733 - if (smileys.length && smileys[smileys.length - 1].kind === kind) kind = (kind + 1) % SMILEYS.length;
734 - // De verliefde smiley is groter, anders zijn de hartjes niet te zien.
735 - const size = kind === SMILEY_VERLIEFD ? 1.3 : 1;
736 - smileys.push({ x, y, kind, size, start: performance.now(), tilt: (Math.random() - 0.5) * 0.5 });
737 - if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax);
738 - }
739 -
740 -
741 - /* Een smiley springt op (even groter dan zijn eindmaat), zweeft een stukje omhoog en
742 - dooft uit. Hij wordt elk frame opnieuw in het korte spoor gezet; als hij weg is, flitst
743 - hij in een paar tienden van een seconde uit. */
744 - function drawSmileys(now) {
745 - let keep = 0;
746 - for (const smiley of smileys) {
747 - const age = (now - smiley.start) / 1000;
748 - if (age >= options.smileySeconds) continue;
749 - smileys[keep] = smiley;
750 - keep += 1;
751 -
752 - const pop = Math.min(age / 0.22, 1);
753 - const scale = pop < 1 ? 0.3 + 0.9 * Math.sin((pop * Math.PI) / 2) + 0.1 * Math.sin(pop * Math.PI) : 1;
754 - const fade = Math.min(1, (options.smileySeconds - age) / 0.45);
755 - const cx = smiley.x;
756 - const cy = smiley.y - age * options.smileyRise;
757 - if (smiley.art) {
758 - drawArtShape(smiley, cx, cy, scale, fade);
759 - continue;
760 - }
761 - const radius = options.smileyRadius * scale * (smiley.size || 1);
762 - const cos = Math.cos(smiley.tilt);
763 - const sin = Math.sin(smiley.tilt);
764 - // Stille cirkel: binnen 1,1 keer de straal helemaal stil, tot 1,5 keer zacht aflopend.
765 - const reach = radius * 1.5;
766 -
767 - const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
768 - const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
769 - const r0 = Math.max(0, Math.floor((cy - reach) / cellH));
770 - const r1 = Math.min(rows - 1, Math.ceil((cy + reach) / cellH));
771 - if (c0 > c1 || r0 > r1) continue;
772 -
773 - for (let r = r0; r <= r1; r += 1) {
774 - const py = (r * cellH + cellH / 2 - cy) / radius;
775 - for (let c = c0; c <= c1; c += 1) {
776 - const px = (c * cellW + cellW / 2 - cx) / radius;
777 - const x = px * cos + py * sin;
778 - const y = -px * sin + py * cos;
779 - const index = r * cols + c;
780 - const d2 = x * x + y * y;
781 - if (d2 < 2.25) {
782 - const still = fade * (d2 < 1.21 ? 1 : (2.25 - d2) / 1.04);
783 - if (still > quiet[index]) quiet[index] = still;
784 - }
785 - if (!smileyAt(smiley.kind, x, y)) {
786 - // De smiley zweeft omhoog; wat hij net verliet dooft meteen uit, anders smeert hij uit en lopen
787 - // open vormen (de holle hartjesogen) dicht.
788 - if (d2 < 1.21) streak[index] *= 0.3;
789 - continue;
790 - }
791 - if (fade > streak[index]) streak[index] = fade;
792 - }
793 - }
794 - extendBox(c0, c1, r0, r1);
795 - }
796 - smileys.length = keep;
797 - }
798 -
799 - function onPointerDown(event) {
800 - if (reducedMotion.matches || event.button !== 0) return;
801 - if (options.isBackground && !options.isBackground(event)) return;
802 - // Op een stille dag (4 mei) doet een klik niets.
803 - if (season().quiet) return;
804 - // Pasen: een klik op een verstopt ei laat het openspringen.
805 - const egg = findEgg(event.clientX, event.clientY);
806 - if (egg) {
807 - egg.found = performance.now();
808 - return;
809 - }
810 - if (nuke) {
811 - // In de nasleep geen nieuwe explosie; na nukeSmileyAfter mogen de smileys wel weer.
812 - if (nukeAge(performance.now()) >= options.nukeSmileyAfter) spawnSmiley(event.clientX, event.clientY);
813 - return;
814 - }
815 -
816 - const now = performance.now();
817 - clickTimes = clickTimes.filter((time) => now - time < options.nukeClickWindow * 1000);
818 - clickTimes.push(now);
819 - // Tijdens een seizoen geen explosie, tenzij het seizoen nuke: true zet.
820 - const s = season();
821 - // Een seizoen kan vijf snelle klikken een eigen feest geven (Nieuwjaar: proosten).
822 - if (s.fiveClicks === "proost" && clickTimes.length >= options.nukeClicks) {
823 - clickTimes = [];
824 - if (!proost) startProost(now);
825 - return;
826 - }
827 - const nukeAllowed = !s.noNuke && (!options.season || s.nuke);
828 - if (nukeAllowed && clickTimes.length >= options.nukeClicks) {
829 - clickTimes = [];
830 - startNuke(now, event.clientX, event.clientY);
831 - return;
832 - }
833 - spawnSmiley(event.clientX, event.clientY);
834 - }
835 -
836 - function startNuke(now, x, y) {
837 - nuke = { start: now, x, y, puffs: [], puffClock: 0 };
838 - smileys = [];
839 - rings = [];
840 - ecg = null;
841 - pointer = null;
842 - lastPointer = null;
843 - trail.fill(0);
844 - pulse.fill(0);
845 - nextEcgAt = now + options.nukeEcgAfter * 1000;
846 - }
847 -
848 - // Hoe ver de nasleep is, in seconden; Infinity als er geen explosie is geweest.
849 - function nukeAge(now) {
850 - return nuke ? (now - nuke.start) / 1000 : Infinity;
851 - }
852 -
853 - function heartbeat(now) {
854 - if (nukeAge(now) < options.nukeHeartAfter) return;
855 - // Tijdens een seizoen kloppen de hartjesringen niet, tenzij het seizoen heart: true zet.
856 - const s = season();
857 - if (s.quiet || (options.season && !s.heart)) return;
858 - if (heartHome) {
859 - const sinceBeat = now - lastBeatAt;
860 - const moved = lastBeatSpot
861 - ? Math.hypot(heartHome.x - lastBeatSpot.x, heartHome.y - lastBeatSpot.y)
862 - : Infinity;
863 - if (
864 - sinceBeat >= options.heartPeriod * 1000 ||
865 - (moved >= options.heartSpacing && sinceBeat >= options.heartMinGap * 1000)
866 - ) {
867 - spawnBeat(now, heartHome.x, heartHome.y);
868 - }
869 - }
870 -
871 - const reach = Math.max(window.innerWidth, window.innerHeight) * options.heartReach;
872 - let keep = 0;
873 - for (const ring of rings) {
874 - const age = (now - ring.start) / 1000;
875 - if (age >= options.heartDuration) continue;
876 - rings[keep] = ring;
877 - keep += 1;
878 - if (age < 0) continue;
879 - const progress = age / options.heartDuration;
880 - const size = reach * (1 - (1 - progress) * (1 - progress));
881 - const strength = options.heartStrength * ring.weight * (1 - progress) ** 1.3;
882 - // Een bijna uitgedoofde ring is groot en kost veel cellen voor weinig beeld.
883 - if (size > 4 && strength > 0.06) stampRing(ring.x, ring.y, size, strength);
884 - }
885 - rings.length = keep;
886 - }
887 -
888 - function paintPointer() {
889 - const from = lastPointer || pointer;
890 - const dx = pointer.x - from.x;
891 - const dy = pointer.y - from.y;
892 - const step = options.brushRadius * 0.35;
893 - const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
894 - for (let s = 1; s <= steps; s += 1) {
895 - stamp(from.x + (dx * s) / steps, from.y + (dy * s) / steps);
896 - }
897 - lastPointer = pointer;
898 - pointer = null;
899 - }
900 -
901 - // Het spoor: alleen de cellen binnen de rechthoek met spoor. De cel wordt eerst leeg
902 - // gemaakt, zodat het teken uit de rustlaag er niet dubbel onder staat.
903 - function drawTrail(dt) {
904 - if (minC > maxC) return;
905 - const decay = dt > 0 ? Math.exp(-dt / options.trailSeconds) : 1;
906 - const pulseDecay = dt > 0 ? Math.exp(-dt / options.heartSeconds) : 1;
907 - const streakDecay = dt > 0 ? Math.exp(-dt / options.streakSeconds) : 1;
908 - const neutralMax = options.trailNeutral[theme.mode];
909 - const accentMax = options.trailAccent[theme.mode];
910 - const streakNeutral = options.streakNeutral[theme.mode];
911 - const streakAccent = options.streakAccent[theme.mode];
912 - let nextMinC = cols;
913 - let nextMaxC = -1;
914 - let nextMinR = rows;
915 - let nextMaxR = -1;
916 -
917 - for (let r = minR; r <= maxR; r += 1) {
918 - const y = cellY(r);
919 - for (let c = minC; c <= maxC; c += 1) {
920 - const index = r * cols + c;
921 - let heat = trail[index];
922 - let beat = pulse[index];
923 - let flash = streak[index];
924 - let pulseLine = lineTrail[index];
925 - let hush = quiet[index];
926 - if (heat <= 0 && beat <= 0 && flash <= 0 && pulseLine <= 0 && hush <= 0) continue;
927 -
928 - if (heat > 0) {
929 - heat *= decay;
930 - if (heat < 0.01) heat = 0;
931 - trail[index] = heat;
932 - }
933 - if (beat > 0) {
934 - beat *= pulseDecay;
935 - if (beat < 0.04) beat = 0;
936 - pulse[index] = beat;
937 - }
938 - if (flash > 0) {
939 - flash *= streakDecay;
940 - if (flash < 0.03) flash = 0;
941 - streak[index] = flash;
942 - }
943 - if (pulseLine > 0) {
944 - pulseLine *= streakDecay;
945 - if (pulseLine < 0.03) pulseLine = 0;
946 - lineTrail[index] = pulseLine;
947 - }
948 - if (hush > 0) {
949 - hush *= streakDecay;
950 - if (hush < 0.03) hush = 0;
951 - quiet[index] = hush;
952 - }
953 - if (heat === 0 && beat === 0 && flash === 0 && pulseLine === 0 && hush === 0) continue;
954 -
955 - if (c < nextMinC) nextMinC = c;
956 - if (c > nextMaxC) nextMaxC = c;
957 - if (r < nextMinR) nextMinR = r;
958 - if (r > nextMaxR) nextMaxR = r;
959 -
960 - // Onder het spoor en de hartslag wisselen de tekens snel.
961 - if (dt > 0 && Math.random() < (heat * 9 + beat * 5 + (flash + pulseLine) * 6) * dt) glyphs[index] = randomGlyph();
962 -
963 - // Een verse ring is roder dan het spoor; verder weg is hij al zwakker en daarmee
964 - // vanzelf grijzer.
965 - // In de stille cirkel van een smiley (hush) en tijdens de nasleep van een explosie
966 - // (calm) wijken het spoor en de ringen; de smiley en de hartslaglijn blijven.
967 - heat *= calm * (1 - hush);
968 - beat *= calm * (1 - hush);
969 - const lift = heat > beat ? heat : beat;
970 - const ring = Math.min(1, beat * options.heartRed);
971 - const glow = heat > ring ? heat : ring;
972 - const x = cellX(c);
973 - const sx = glyphs[index] * tileW;
974 - // Achter een smiley wordt het rustende veld stilgezet (hush), zodat het gezicht
975 - // niet in de wolken verdwijnt. De smileys (flash) en de hartslaglijn (pulseLine) zijn vooral rood; de lijn in --glyph-line.
976 - const neutral = ambient[index] * calm * (1 - lift) * (1 - hush) + neutralMax * lift + streakNeutral * Math.max(flash, pulseLine);
977 - const red = Math.max(glow * glow * accentMax, flash * streakAccent);
978 - const lineRed = pulseLine * streakAccent;
979 - ctx.clearRect(x, y, tileW, tileH);
980 - if (neutral >= MIN_ALPHA) {
981 - ctx.globalAlpha = neutral;
982 - ctx.drawImage(neutralAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
983 - }
984 - if (red > 0.01) {
985 - ctx.globalAlpha = Math.min(1, red);
986 - ctx.drawImage(accentAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
987 - }
988 - if (lineRed > 0.01) {
989 - ctx.globalAlpha = Math.min(1, lineRed);
990 - ctx.drawImage(lineAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH);
991 - }
992 - }
993 - }
994 -
995 - ctx.globalAlpha = 1;
996 - minC = nextMinC;
997 - maxC = nextMaxC;
998 - minR = nextMinR;
999 - maxR = nextMaxR;
1000 - }
1001 -
1002 - /* Zwarte rook die uit de kop van de paddenstoel spuwt en naar de bovenkant van het scherm
1003 - klimt. Losse wolkjes die opstijgen, opzwellen en onderweg dunner worden. */
1004 - function drawSmoke(t, dt) {
1005 - const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
1006 - const capY = nuke.y - 300 * size * easeOut(t / 2.2);
1007 - const capRx = (50 + 190 * easeOut(t / 1.4)) * size;
1008 -
1009 - // Spuwen tussen 0,5 en 3,2 seconden, zo'n vijfentwintig wolkjes per seconde. Alle rook is
1010 - // weg rond 5,5 seconden, als de letters gevallen zijn.
1011 - if (dt > 0 && t > 0.5 && t < 3.2) {
1012 - nuke.puffClock += dt * 25;
1013 - while (nuke.puffClock >= 1) {
1014 - nuke.puffClock -= 1;
1015 - const startY = capY - Math.random() * 40 * size;
1016 - nuke.puffs.push({
1017 - x: nuke.x + (Math.random() - 0.5) * capRx * 0.9,
1018 - y: startY,
1019 - // Snel genoeg om in een paar seconden de bovenkant te halen.
1020 - vy: -(startY + 120) / (1.1 + Math.random() * 0.8),
1021 - vx: (Math.random() - 0.5) * 50 * size,
1022 - radius: (14 + Math.random() * 18) * size,
1023 - born: t,
1024 - seed: Math.random() * 10,
1025 - });
1026 - }
1027 - }
1028 -
1029 - const life = 2.4;
1030 - const allFade = t < 4.2 ? 1 : Math.max(0, 1 - (t - 4.2) / 1.3);
1031 - let keep = 0;
1032 - for (const puff of nuke.puffs) {
1033 - const age = t - puff.born;
1034 - if (age >= life) continue;
1035 - nuke.puffs[keep] = puff;
1036 - keep += 1;
1037 -
1038 - const px = puff.x + puff.vx * age;
1039 - const py = puff.y + puff.vy * age * (1 - age / (life * 2.4));
1040 - const radius = puff.radius * (1 + age * 0.85);
1041 - const strength = 0.75 * (1 - age / life) * Math.min(1, age / 0.15) * allFade;
1042 - if (py + radius < 0 || strength <= 0.01) continue;
1043 -
1044 - const c0 = Math.max(0, Math.floor((px - radius) / cellW));
1045 - const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW));
1046 - const r0 = Math.max(0, Math.floor((py - radius) / cellH));
1047 - const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH));
1048 - for (let r = r0; r <= r1; r += 1) {
1049 - const dy = r * cellH + cellH / 2 - py;
1050 - for (let c = c0; c <= c1; c += 1) {
1051 - const dx = c * cellW + cellW / 2 - px;
1052 - const angle = Math.atan2(dy, dx);
1053 - const edge = 1 + 0.18 * Math.sin(angle * 5 + puff.seed + t * 2);
1054 - const d = Math.sqrt(dx * dx + dy * dy) / (radius * edge);
1055 - if (d >= 1) continue;
1056 - ctx.globalAlpha = strength * (1 - d * d);
1057 - ctx.drawImage(smokeAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
1058 - }
1059 - }
1060 - }
1061 - nuke.puffs.length = keep;
1062 - }
1063 -
1064 - /* ---- Seizoenen (zie src/lib/glyphSeasons.js) ----
1065 - Alles hieronder volgt de gegevens van options.season; zonder seizoen doet het niets. */
1066 -
1067 - function season() {
1068 - return options.season || NO_SEASON;
1069 - }
1070 -
1071 - // Een kleur uit een palette: "#rrggbb", "text" (de tekstkleur), met een optionele zichtbaarheid
1072 - // erachter ("text:0.5"). De atlas per kleur wordt een keer gemaakt.
1073 - function paletteAtlas(spec) {
1074 - let entry = paletteAtlases.get(spec);
1075 - if (!entry) {
1076 - const [color, alpha] = String(spec).split(":");
1077 - const resolved = color === "text" ? theme.text : color;
1078 - entry = { atlas: buildAtlas(resolved), alpha: alpha === undefined ? 1 : Number(alpha) };
1079 - paletteAtlases.set(spec, entry);
1080 - }
1081 - return entry;
1082 - }
1083 -
1084 - // Een cel van het veld vervangen door een teken in een kleur; het teken van het veld onder die cel
1085 - // gaat eerst weg, zodat er niets dubbel staat.
1086 - function drawCell(c, r, entry, strength = 1, dense = false) {
1087 - if (c < 0 || r < 0 || c >= cols || r >= rows) return;
1088 - const x = cellX(c);
1089 - const y = cellY(r);
1090 - const glyph = dense ? denseGlyph(c, r) : glyphs[r * cols + c];
1091 - ctx.clearRect(x, y, tileW, tileH);
1092 - ctx.globalAlpha = Math.min(1, entry.alpha * strength * calm);
1093 - ctx.drawImage(entry.atlas, glyph * tileW, 0, tileW, tileH, x, y, tileW, tileH);
1094 - }
1095 -
1096 - // Een atlas met symbolen die niet in het veld zitten (sneeuwvlokken), in een kleur uit een palette.
1097 - function symbolAtlas(spec, symbols) {
1098 - const key = `${spec}|${symbols.join("")}`;
1099 - let entry = paletteAtlases.get(key);
1100 - if (!entry) {
1101 - const [color, alpha] = String(spec).split(":");
1102 - entry = { atlas: buildAtlas(color === "text" ? theme.text : color, symbols), alpha: alpha === undefined ? 1 : Number(alpha) };
1103 - paletteAtlases.set(key, entry);
1104 - }
1105 - return entry;
1106 - }
1107 -
1108 - function drawSymbol(c, r, entry, index, strength = 1) {
1109 - if (c < 0 || r < 0 || c >= cols || r >= rows) return;
1110 - const x = cellX(c);
1111 - const y = cellY(r);
1112 - ctx.clearRect(x, y, tileW, tileH);
1113 - ctx.globalAlpha = Math.min(1, entry.alpha * strength * calm);
1114 - ctx.drawImage(entry.atlas, index * tileW, 0, tileW, tileH, x, y, tileW, tileH);
1115 - }
1116 -
1117 - /* Een figuur verkleinen met een factor step (groter dan 1): elke doelcel neemt het vaakst voorkomende
1118 - teken uit zijn blok broncellen; een blok met iets erin blijft gevuld, zodat dunne lijnen niet
1119 - wegvallen. Per figuur en factor een keer berekend. */
1120 - const scaledFrames = new WeakMap();
1121 - function scaleFrames(frames, step) {
1122 - let perStep = scaledFrames.get(frames);
1123 - if (!perStep) {
1124 - perStep = new Map();
1125 - scaledFrames.set(frames, perStep);
1126 - }
1127 - const key = step.toFixed(2);
1128 - let out = perStep.get(key);
1129 - if (out) return out;
1130 - out = frames.map((frame) => {
1131 - const srcW = artWidth(frame);
1132 - const srcH = frame.length;
1133 - const dstW = Math.max(1, Math.round(srcW / step));
1134 - const dstH = Math.max(1, Math.round(srcH / step));
1135 - const rows = [];
1136 - for (let r = 0; r < dstH; r += 1) {
1137 - let row = "";
1138 - const r0 = Math.floor(r * step);
1139 - const r1 = Math.max(r0 + 1, Math.floor((r + 1) * step));
1140 - for (let c = 0; c < dstW; c += 1) {
1141 - const c0 = Math.floor(c * step);
1142 - const c1 = Math.max(c0 + 1, Math.floor((c + 1) * step));
1143 - const counts = new Map();
1144 - for (let yy = r0; yy < r1 && yy < srcH; yy += 1) {
1145 - for (let xx = c0; xx < c1; xx += 1) {
1146 - const ch = frame[yy][xx];
1147 - if (ch && ch !== " ") counts.set(ch, (counts.get(ch) || 0) + 1);
1148 - }
1149 - }
1150 - let best = " ";
1151 - let bestCount = 0;
1152 - for (const [ch, count] of counts) {
1153 - if (count > bestCount) {
1154 - best = ch;
1155 - bestCount = count;
1156 - }
1157 - }
1158 - row += best;
1159 - }
1160 - rows.push(row);
1161 - }
1162 - return rows;
1163 - });
1164 - perStep.set(key, out);
1165 - return out;
1166 - }
1167 -
1168 - function artWidth(picture) {
1169 - return picture.reduce((max, row) => Math.max(max, row.length), 0);
1170 - }
1171 -
1172 - // De doorkomer: een figuur die van links naar rechts over het scherm gaat, op dezelfde plekken en
1173 - // tijden als de ECG-lijn. Hier alleen de plaats en de stand; drawFigure tekent hem.
1174 - /* Stroken over de hele breedte waar geen inhoud staat, van boven (onder het decor) naar beneden. Een
1175 - blok inhoud bezet zijn hoogte over de hele breedte, want een figuur trekt van links naar rechts. */
1176 - function freeBands(height) {
1177 - const rects = (options.obstacles ? options.obstacles() : [])
1178 - .filter((r) => r.bottom > options.decorTop && r.top < height && r.width > 0 && r.height > 0)
1179 - .map((r) => [Math.max(r.top, options.decorTop), Math.min(r.bottom, height)])
1180 - .sort((a, b) => a[0] - b[0]);
1181 - const bands = [];
1182 - let y = options.decorTop;
1183 - for (const [top, bottom] of rects) {
1184 - if (top > y) bands.push({ top: y, bottom: top });
1185 - y = Math.max(y, bottom);
1186 - }
1187 - if (y < height) bands.push({ top: y, bottom: height });
1188 - return bands;
1189 - }
1190 -
1191 - function runFigure(now, part) {
1192 - if (!figure) {
1193 - if (now < nextEcgAt) return;
1194 - const height = window.innerHeight;
1195 - let frames = part.frames;
1196 - /* De figuur gaat over de hele breedte, dus hij moet in een strook zonder inhoud passen. Lopende
1197 - figuren (ground) nemen de strook langs de onderrand, vliegende de hoogste vrije strook. Past de
1198 - figuur (met zijn golf) er niet in, dan wordt hij verkleind, zodat hij helemaal in beeld komt. */
1199 - const bands = freeBands(height);
1200 - const band = part.ground
1201 - ? bands.find((b) => b.bottom >= height - 2) || bands[bands.length - 1]
1202 - : bands.reduce((best, b) => (!best || b.bottom - b.top > best.bottom - best.top ? b : best), null);
1203 - let wave = part.wave || 0;
1204 - if (band) {
1205 - const room = band.bottom - band.top - 12;
1206 - const need = frames[0].length * cellH + 2 * wave;
1207 - if (room > 8 * cellH && need > room) {
1208 - frames = scaleFrames(frames, need / room);
1209 - wave = wave * (room / need);
1210 - }
1211 - }
1212 - const heightPx = frames[0].length * cellH;
1213 - const widthPx = Math.max(...frames.map(artWidth)) * cellW;
1214 - const baseY = band ? band.bottom - 6 - wave : height - 6;
1215 - figure = { start: now, baseY, widthPx, heightPx, frames, wave, x: -widthPx, lift: 0, frame: 0 };
1216 - }
1217 -
1218 - const elapsed = now - figure.start;
1219 - const progress = elapsed / (part.crossSeconds * 1000);
1220 - if (progress >= 1) {
1221 - figure = null;
1222 - nextEcgAt = now + randomBetween(part.everySeconds || options.ecgEverySeconds) * 1000;
1223 - return;
1224 - }
1225 - figure.x = -figure.widthPx + (window.innerWidth + figure.widthPx) * progress;
1226 - figure.frame = Math.floor(elapsed / part.frameMs) % figure.frames.length;
1227 - /* Spoor: sterren die achter de figuur ontstaan en in een golf blijven hangen, zachtjes twinkelen en
1228 - uitdoven (de kerstman). De golf volgt de tijd, zodat het spoor als een lint achter de slee kronkelt. */
1229 - if (part.trail) {
1230 - const t = part.trail;
1231 - const since = figure.lastTrail ? (now - figure.lastTrail) / 1000 : 0;
1232 - figure.lastTrail = now;
1233 - let due = (figure.trailDebt || 0) + since * t.perSecond;
1234 - const fromX = figure.x + figure.widthPx * (t.at ? t.at[0] : 0);
1235 - const fromY = figure.baseY - figure.lift - figure.heightPx * (t.at ? t.at[1] : 0.5);
1236 - const wave = Math.sin(now / (t.waveMs || 380)) * (t.waveAmp || 26);
1237 - while (due >= 1) {
1238 - due -= 1;
1239 - sparks.push({
1240 - x: fromX + (Math.random() - 0.5) * 10,
1241 - y: fromY + wave + (Math.random() - 0.5) * (t.spread || 14),
1242 - vx: -(10 + Math.random() * 20),
1243 - vy: (Math.random() - 0.5) * 8,
1244 - g: 0,
1245 - born: now,
1246 - life: (t.life || 2.2) * (0.7 + Math.random() * 0.6),
1247 - color: t.colors[(Math.random() * t.colors.length) | 0],
1248 - symbols: t.symbols,
1249 - symbol: (Math.random() * t.symbols.length) | 0,
1250 - twinkle: Math.random() * Math.PI * 2,
1251 - });
1252 - }
1253 - figure.trailDebt = due;
1254 - if (sparks.length > 900) sparks.splice(0, sparks.length - 900);
1255 - }
1256 - // Strooien: deeltjes die achter de figuur uit vliegen en omlaag tuimelen (pepernoten).
1257 - if (part.scatter) {
1258 - const since = figure.lastScatter ? (now - figure.lastScatter) / 1000 : 0;
1259 - figure.lastScatter = now;
1260 - let due = (figure.scatterDebt || 0) + since * part.scatter.perSecond;
1261 - const fromX = figure.x + figure.widthPx * (part.scatter.at ? part.scatter.at[0] : 0.5);
1262 - const fromY = figure.baseY - figure.heightPx * (part.scatter.at ? part.scatter.at[1] : 0.5);
1263 - while (due >= 1) {
1264 - due -= 1;
1265 - const colors = part.scatter.colors;
1266 - sparks.push({
1267 - x: fromX + (Math.random() - 0.5) * 20,
1268 - y: fromY,
1269 - vx: -(40 + Math.random() * 110),
1270 - vy: -(90 + Math.random() * 120),
1271 - g: 340,
1272 - dense: true,
1273 - born: now,
1274 - life: 1.8 + Math.random() * 0.8,
1275 - color: colors[(Math.random() * colors.length) | 0],
1276 - });
1277 - }
1278 - figure.scatterDebt = due;
1279 - if (sparks.length > 900) sparks.splice(0, sparks.length - 900);
1280 - }
1281 - const bob = (part.bob || 0) * Math.abs(Math.sin((elapsed / part.frameMs) * Math.PI));
1282 - const wave = (figure.wave || 0) * Math.sin((elapsed / ((part.waveSeconds || 1) * 1000)) * Math.PI * 2);
1283 - figure.lift = bob + wave;
1284 - }
1285 -
1286 - function drawFigure() {
1287 - const part = season().traverser;
1288 - if (!figure || !part) return;
1289 - const picture = figure.frames[figure.frame];
1290 - const c0 = Math.round(figure.x / cellW);
1291 - const r0 = Math.round((figure.baseY - figure.heightPx - figure.lift) / cellH);
1292 - for (let i = 0; i < picture.length; i += 1) {
1293 - const row = picture[i];
1294 - for (let j = 0; j < row.length; j += 1) {
1295 - const ch = row[j];
1296 - if (ch === " ") continue;
1297 - drawCell(c0 + j, r0 + i, paletteAtlas(part.palette[ch]), 1, true);
1298 - }
1299 - }
1300 - ctx.globalAlpha = 1;
1301 - }
1302 -
1303 - // Een klikvorm van tekst-art: zelfde opspringen en uitdoven als een smiley. Elke letter van de vorm
1304 - // beslaat cellScale bij cellScale cellen; de vorm komt in het korte spoor, in de accentkleur.
1305 - function drawArtShape(shape, cx, cy, scale, fade) {
1306 - const picture = shape.art;
1307 - const unitW = cellW * shape.cellScale * scale;
1308 - const unitH = cellH * shape.cellScale * scale;
1309 - const widthPx = artWidth(picture) * unitW;
1310 - const heightPx = picture.length * unitH;
1311 - const left = cx - widthPx / 2;
1312 - const top = cy - heightPx / 2;
1313 - // Stille cirkel om de vorm, net als bij een smiley.
1314 - const radius = Math.max(widthPx, heightPx) * 0.62;
1315 - const reach = radius * 1.4;
1316 - const c0 = Math.max(0, Math.floor((cx - reach) / cellW));
1317 - const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW));
1318 - const r0 = Math.max(0, Math.floor((cy - reach) / cellH));
1319 - const r1 = Math.min(rows - 1, Math.ceil((cy + reach) / cellH));
1320 - if (c0 > c1 || r0 > r1) return;
1321 -
1322 - for (let r = r0; r <= r1; r += 1) {
1323 - const py = r * cellH + cellH / 2;
1324 - for (let c = c0; c <= c1; c += 1) {
1325 - const px = c * cellW + cellW / 2;
1326 - const index = r * cols + c;
1327 - const d2 = ((px - cx) ** 2 + (py - cy) ** 2) / (radius * radius);
1328 - if (d2 < 1.96) {
1329 - const still = fade * (d2 < 1 ? 1 : (1.96 - d2) / 0.96);
1330 - if (still > quiet[index]) quiet[index] = still;
1331 - }
1332 - }
1333 - }
1334 - extendBox(c0, c1, r0, r1);
1335 - // Het tekenen zelf gebeurt na het spoor (drawArtShapes), in de kleuren van de palette.
1336 - shape.place = { left, top, unitW, unitH, fade };
1337 - }
1338 -
1339 - function drawArtShapes() {
1340 - const art = season().clickArt;
1341 - if (!art) return;
1342 - for (const shape of smileys) {
1343 - if (!shape.art || !shape.place) continue;
1344 - const { left, top, unitW, unitH, fade } = shape.place;
1345 - const picture = shape.art;
1346 - const c0 = Math.max(0, Math.floor(left / cellW));
1347 - const c1 = Math.min(cols - 1, Math.ceil((left + artWidth(picture) * unitW) / cellW));
1348 - const r0 = Math.max(0, Math.floor(top / cellH));
1349 - const r1 = Math.min(rows - 1, Math.ceil((top + picture.length * unitH) / cellH));
1350 - for (let r = r0; r <= r1; r += 1) {
1351 - const ay = Math.floor((r * cellH + cellH / 2 - top) / unitH);
1352 - const row = picture[ay];
1353 - if (!row) continue;
1354 - for (let c = c0; c <= c1; c += 1) {
1355 - const ch = row[Math.floor((c * cellW + cellW / 2 - left) / unitW)];
1356 - if (!ch || ch === " ") continue;
1357 - drawCell(c, r, paletteAtlas(art.palette[ch] || "text"), fade, true);
1358 - }
1359 - }
1360 - }
1361 - ctx.globalAlpha = 1;
1362 - }
1363 -
1364 - /* ---- Bewegend decor en vuurwerk van een seizoen ----
1365 - Deeltjes in pixels; ze worden per frame op het raster gezet. fxTime houdt de tijd tussen frames bij,
1366 - zodat de snelheid niet van de framerate afhangt. */
1367 -
1368 - let fxTime = 0;
1369 - let flakes = null;
1370 - let lanterns = null;
1371 - let eggs = null;
1372 - let rockets = [];
1373 - let sparks = [];
1374 -
1375 - function resetSeasonFx() {
1376 - proost = null;
1377 - nextShowerAt = 0;
1378 - flakes = null;
1379 - lanterns = null;
1380 - eggs = null;
1381 - rockets = [];
1382 - sparks = [];
1383 - }
1384 -
1385 - function fxStep(now) {
1386 - const dt = fxTime ? Math.min((now - fxTime) / 1000, 0.1) : 0;
1387 - fxTime = now;
1388 - return dt;
1389 - }
1390 -
1391 - // Sneeuw: tekens die zacht naar beneden dwarrelen; wat onder uit beeld valt, begint weer bovenaan.
1392 - function drawSnow(now, item, dt) {
1393 - const width = window.innerWidth;
1394 - const height = window.innerHeight;
1395 - const symbols = item.symbols || SNOW_SYMBOLS;
1396 - if (!flakes) {
1397 - // Een deel van de vlokken is een echt sneeuwvlokje, de rest een letter uit het veld.
1398 - const share = item.symbolShare === undefined ? 0.5 : item.symbolShare;
1399 - flakes = Array.from({ length: item.count || 60 }, () => ({
1400 - x: Math.random() * width,
1401 - y: options.decorTop + Math.random() * (height - options.decorTop),
1402 - speed: 18 + Math.random() * 26,
1403 - drift: Math.random() * Math.PI * 2,
1404 - symbol: Math.random() < share ? (Math.random() * symbols.length) | 0 : -1,
1405 - }));
1406 - }
1407 - const flake = paletteAtlas(item.color || "text:0.4");
1408 - const symbolEntry = symbolAtlas(item.symbolColor || "text:0.6", symbols);
1409 - for (const f of flakes) {
1410 - f.y += f.speed * dt;
1411 - f.x += Math.sin(now / 1400 + f.drift) * 10 * dt;
1412 - if (f.y > height) {
1413 - f.y = options.decorTop;
1414 - f.x = Math.random() * width;
1415 - }
1416 - if (f.symbol >= 0) drawSymbol(Math.floor(f.x / cellW), Math.floor(f.y / cellH), symbolEntry, f.symbol);
1417 - else drawCell(Math.floor(f.x / cellW), Math.floor(f.y / cellH), flake);
1418 - }
1419 - }
1420 -
1421 - // Lampionnetjes die van onder naar boven zweven, licht heen en weer, en zacht gloeien.
1422 - function drawLanterns(now, item, dt) {
1423 - const width = window.innerWidth;
1424 - const height = window.innerHeight;
1425 - const picture = item.art;
1426 - const heightPx = picture.length * cellH;
1427 - if (!lanterns) {
1428 - lanterns = Array.from({ length: item.count || 6 }, (_, i) => ({
1429 - x: ((i + 0.5) / (item.count || 6)) * width + (Math.random() - 0.5) * 80,
1430 - // Bij de start al over de hele hoogte verdeeld, niet allemaal onder het scherm.
1431 - y: options.decorTop + Math.random() * (height - options.decorTop + heightPx),
1432 - speed: 22 + Math.random() * 16,
1433 - sway: Math.random() * Math.PI * 2,
1434 - }));
1435 - }
1436 - for (const l of lanterns) {
1437 - l.y -= l.speed * dt;
1438 - if (l.y < options.decorTop - heightPx) {
1439 - l.y = height + heightPx;
1440 - l.x = Math.random() * width;
1441 - }
1442 - const x = l.x + Math.sin(now / 1800 + l.sway) * 18;
1443 - const glow = 0.75 + 0.25 * Math.sin(now / 300 + l.sway * 3);
1444 - const c0 = Math.round(x / cellW);
1445 - const r0 = Math.round(l.y / cellH);
1446 - picture.forEach((row, i) => {
1447 - for (let j = 0; j < row.length; j += 1) {
1448 - const ch = row[j];
1449 - if (ch === " ") continue;
1450 - drawCell(c0 + j, r0 + i, paletteAtlas(item.palette[ch]), ch === "K" ? 1 : glow, true);
1451 - }
1452 - });
1453 - }
1454 - }
1455 -
1456 - // Eieren verstopt in het veld: half zichtbaar op vrije plekken. Een klik erop (findEgg) laat het
1457 - // openspringen; daarna ligt het even later ergens anders.
1458 - function placeEgg(egg, item) {
1459 - const width = window.innerWidth;
1460 - const height = window.innerHeight;
1461 - const rects = options.obstacles ? options.obstacles() : [];
1462 - const w = artWidth(item.art) * cellW;
1463 - const h = item.art.length * cellH;
1464 - for (let tries = 0; tries < 40; tries += 1) {
1465 - const x = 10 + Math.random() * (width - w - 20);
1466 - const y = options.decorTop + 20 + Math.random() * (height - h - options.decorTop - 30);
1467 - const hit = rects.some((r) => x < r.right && x + w > r.left && y < r.bottom && y + h > r.top);
1468 - if (!hit || tries === 39) {
1469 - egg.x = x;
1470 - egg.y = y;
1471 - break;
1472 - }
1473 - }
1474 - egg.color = item.colors[(Math.random() * item.colors.length) | 0];
1475 - egg.stripe = item.colors[(Math.random() * item.colors.length) | 0];
1476 - egg.found = 0;
1477 - }
1478 -
1479 - function drawEggs(now, item) {
1480 - if (!eggs) {
1481 - eggs = Array.from({ length: item.count || 5 }, () => {
1482 - const egg = {};
1483 - placeEgg(egg, item);
1484 - return egg;
1485 - });
1486 - }
1487 - for (const egg of eggs) {
1488 - let strength = 0.45;
1489 - let lift = 0;
1490 - if (egg.found) {
1491 - const age = (now - egg.found) / 1000;
1492 - if (age > 4) {
1493 - placeEgg(egg, item);
1494 - } else {
1495 - // Gevonden: helder, een sprongetje, en daarna weg.
1496 - strength = age < 1.4 ? 1 : 0;
1497 - lift = age < 0.6 ? Math.sin((age / 0.6) * Math.PI) * 14 : 0;
1498 - }
1499 - }
1500 - if (strength <= 0) continue;
1501 - const c0 = Math.round(egg.x / cellW);
1502 - const r0 = Math.round((egg.y - lift) / cellH);
1503 - item.art.forEach((row, i) => {
1504 - for (let j = 0; j < row.length; j += 1) {
1505 - const ch = row[j];
1506 - if (ch === " ") continue;
1507 - drawCell(c0 + j, r0 + i, paletteAtlas(ch === "D" ? egg.stripe : egg.color), strength, true);
1508 - }
1509 - });
1510 - }
1511 - }
1512 -
1513 - function findEgg(x, y) {
1514 - if (!eggs) return null;
1515 - const decor = season().decor;
1516 - const item = (Array.isArray(decor) ? decor : [decor]).find((d) => d && d.kind === "eieren");
1517 - if (!item) return null;
1518 - const w = artWidth(item.art) * cellW;
1519 - const h = item.art.length * cellH;
1520 - return eggs.find((egg) => !egg.found && x >= egg.x - 10 && x <= egg.x + w + 10 && y >= egg.y - 10 && y <= egg.y + h + 10) || null;
1521 - }
1522 -
1523 - const FIREWORK_COLORS = ["#e0b000", "#c8102e", "#3b82c4", "#2f7d32", "#f97316", "#c9a7eb"];
1524 -
1525 - /* Buien: af en toe spat ergens op een vrije plek een handvol deeltjes uit elkaar die omlaag tuimelen
1526 - (pepernoten). */
1527 - let nextShowerAt = 0;
1528 - function burstShower(now, item) {
1529 - if (!nextShowerAt) nextShowerAt = now + randomBetween(item.firstSeconds || [1, 3]) * 1000;
1530 - if (now < nextShowerAt) return;
1531 - nextShowerAt = now + randomBetween(item.everySeconds || [4, 9]) * 1000;
1532 - const width = window.innerWidth;
1533 - const height = window.innerHeight;
1534 - // Een vrije plek: niet achter de inhoud (met marge), anders zie je de bui niet.
1535 - const rects = options.obstacles ? options.obstacles() : [];
1536 - const margin = 60;
1537 - let x = width * 0.12;
1538 - let y = height * 0.75;
1539 - for (let tries = 0; tries < 40; tries += 1) {
1540 - const px = width * (0.05 + Math.random() * 0.9);
1541 - const py = options.decorTop + 80 + Math.random() * (height - options.decorTop - 160);
1542 - const hit = rects.some((r) => px > r.left - margin && px < r.right + margin && py > r.top - margin && py < r.bottom + margin);
1543 - if (!hit) {
1544 - x = px;
1545 - y = py;
1546 - break;
1547 - }
1548 - }
1549 - const count = item.count || 24;
1550 - for (let i = 0; i < count; i += 1) {
1551 - const angle = Math.random() * Math.PI * 2;
1552 - const speed = 60 + Math.random() * 190;
1553 - // Grote en kleine noten door elkaar: een cel, twee cellen breed, of twee bij twee.
1554 - const roll = Math.random();
1555 - sparks.push({
1556 - x,
1557 - y,
1558 - vx: Math.cos(angle) * speed,
1559 - vy: Math.sin(angle) * speed - 80,
1560 - g: 300,
1561 - dense: true,
1562 - w: roll < 0.3 ? 1 : 2,
1563 - h: roll > 0.75 ? 2 : 1,
1564 - born: now,
1565 - life: 2.4 + Math.random() * 1.2,
1566 - color: item.colors[(Math.random() * item.colors.length) | 0],
1567 - });
1568 - }
1569 - if (sparks.length > 900) sparks.splice(0, sparks.length - 900);
1570 - }
1571 -
1572 - // Een vuurwerkbol: een ring van vonken die uitwaaieren, vallen en uitdoven.
1573 - function spawnBurst(x, y) {
1574 - const color = FIREWORK_COLORS[(Math.random() * FIREWORK_COLORS.length) | 0];
1575 - const second = FIREWORK_COLORS[(Math.random() * FIREWORK_COLORS.length) | 0];
1576 - const count = 46;
1577 - for (let i = 0; i < count; i += 1) {
1578 - const angle = (i / count) * Math.PI * 2 + Math.random() * 0.1;
1579 - const speed = 120 + Math.random() * 90;
1580 - sparks.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, born: performance.now(), life: 1.3 + Math.random() * 0.5, color: i % 3 ? color : second });
1581 - }
1582 - if (sparks.length > 900) sparks.splice(0, sparks.length - 900);
1583 - }
1584 -
1585 - // Vuurpijlen als doorkomer: af en toe een of twee pijlen die opstijgen en openspatten.
1586 - /* ---- Proosten op Nieuwjaar (vijf snelle klikken) ----
1587 - Zes seconden een vuurwerkshow over de hele achtergrond. In de grootste vrije strook schuiven twee
1588 - kristallen glazen naar elkaar toe, kantelen en klinken; op dat moment schittert het en ploffen er twee
1589 - extra bollen. Daarna dooft alles uit. */
1590 - const PROOST_SHOW = 6;
1591 - const PROOST_IN = 1.1;
1592 - const PROOST_TILT = 1.7;
1593 - const PROOST_OUT = 4.2;
1594 - const PROOST_END = 5;
1595 -
1596 - function mirrorFrames(frames) {
1597 - const width = Math.max(...frames.map(artWidth));
1598 - return frames.map((frame) => frame.map((row) => row.padEnd(width).split("").reverse().join("").replace(/\s+$/, "")));
1599 - }
1600 -
1601 - function startProost(now) {
1602 - const art = season().proostArt;
1603 - if (!art) return;
1604 - const height = window.innerHeight;
1605 - const bands = freeBands(height);
1606 - const band = bands.reduce((best, b) => (!best || b.bottom - b.top > best.bottom - best.top ? b : best), null) || { top: options.decorTop, bottom: height };
1607 - let frames = art.frames;
1608 - const room = band.bottom - band.top - 20;
1609 - const need = frames[0].length * cellH;
1610 - if (room > 8 * cellH && need > room) frames = scaleFrames(frames, need / room);
1611 - proost = {
1612 - start: now,
1613 - left: frames,
1614 - right: mirrorFrames(frames),
1615 - palette: art.palette,
1616 - centerX: window.innerWidth / 2,
1617 - bottomY: band.bottom - 10,
1618 - nextRocket: now,
1619 - clinked: false,
1620 - };
1621 - }
1622 -
1623 - function drawProost(now) {
1624 - const t = (now - proost.start) / 1000;
1625 - const width = window.innerWidth;
1626 - const height = window.innerHeight;
1627 - if (t < PROOST_SHOW) {
1628 - while (now >= proost.nextRocket) {
1629 - launchRocket(width, height, options.decorTop + 30 + Math.random() * height * 0.5);
1630 - proost.nextRocket += 140 + Math.random() * 200;
1631 - }
1632 - }
1633 - if (!proost.clinked && t >= PROOST_TILT) {
1634 - proost.clinked = true;
1635 - const x = proost.centerX;
1636 - const y = proost.bottomY - proost.left[0].length * cellH * 0.82;
1637 - spawnBurst(x - 120, y - 80);
1638 - spawnBurst(x + 120, y - 90);
1639 - // Schittering op het punt waar de glazen elkaar raken.
1640 - for (let i = 0; i < 26; i += 1) {
1641 - const angle = Math.random() * Math.PI * 2;
1642 - const speed = 60 + Math.random() * 140;
1643 - sparks.push({
1644 - x,
1645 - y,
1646 - vx: Math.cos(angle) * speed,
1647 - vy: Math.sin(angle) * speed,
1648 - g: 1,
1649 - born: now,
1650 - life: 1 + Math.random() * 0.8,
1651 - color: Math.random() < 0.5 ? "#f6d86b" : "#cfe6f5",
1652 - symbols: CLINK_SYMBOLS,
1653 - symbol: (Math.random() * CLINK_SYMBOLS.length) | 0,
1654 - twinkle: Math.random() * Math.PI * 2,
1655 - });
1656 - }
1657 - }
1658 - if (t > Math.max(PROOST_SHOW, PROOST_END) + 0.5) proost = null;
1659 - }
1660 -
1661 - function drawGlasses(now) {
1662 - const t = (now - proost.start) / 1000;
1663 - if (t >= PROOST_END) return;
1664 - // Stand: rechtop tijdens het binnenschuiven, dan kantelen tot ze klinken.
1665 - const frameCount = proost.left.length;
1666 - const tilt = t < PROOST_IN ? 0 : Math.min(1, (t - PROOST_IN) / (PROOST_TILT - PROOST_IN));
1667 - const frame = Math.min(frameCount - 1, Math.round(tilt * (frameCount - 1)));
1668 - const slide = t < PROOST_IN ? 1 - easeOut(t / PROOST_IN) : 0;
1669 - const fade = t < PROOST_OUT ? 1 : Math.max(0, 1 - (t - PROOST_OUT) / (PROOST_END - PROOST_OUT));
1670 - // Na het klinken deinen ze nog even mee.
1671 - const bob = t > PROOST_TILT ? Math.sin((t - PROOST_TILT) * 6) * 3 * Math.max(0, 1 - (t - PROOST_TILT) / 1.5) : 0;
1672 - const left = proost.left[frame];
1673 - const right = proost.right[frame];
1674 - const leftW = artWidth(left);
1675 - const centerCol = Math.round(proost.centerX / cellW);
1676 - const offset = Math.round(slide * (window.innerWidth * 0.35) / cellW);
1677 - const r0 = Math.round((proost.bottomY - left.length * cellH + bob) / cellH);
1678 - // Het veld rond de glazen stil, zodat het kristal goed leest.
1679 - const c0 = centerCol - leftW - offset - 2;
1680 - const c1 = centerCol + leftW + offset + 2;
1681 - for (let r = Math.max(0, r0 - 1); r <= Math.min(rows - 1, r0 + left.length); r += 1) {
1682 - for (let c = Math.max(0, c0); c <= Math.min(cols - 1, c1); c += 1) {
1683 - const index = r * cols + c;
1684 - if (fade > quiet[index]) quiet[index] = fade;
1685 - }
1686 - }
1687 - extendBox(Math.max(0, c0), Math.min(cols - 1, c1), Math.max(0, r0 - 1), Math.min(rows - 1, r0 + left.length));
1688 - const paint = (picture, startCol) => {
1689 - picture.forEach((row, i) => {
1690 - for (let j = 0; j < row.length; j += 1) {
1691 - const ch = row[j];
1692 - if (ch === " ") continue;
1693 - drawCell(startCol + j, r0 + i, paletteAtlas(proost.palette[ch]), fade, true);
1694 - }
1695 - });
1696 - };
1697 - paint(left, centerCol - leftW - offset);
1698 - paint(right, centerCol + offset);
1699 - ctx.globalAlpha = 1;
1700 - }
1701 - function launchRocket(width, height, top) {
1702 - rockets.push({
1703 - x: width * (0.06 + Math.random() * 0.88),
1704 - y: height,
1705 - top: top === undefined ? options.decorTop + 40 + Math.random() * height * 0.4 : top,
1706 - speed: 380 + Math.random() * 160,
1707 - });
1708 - }
1709 -
1710 - function launchSidewaysRocket(width, height) {
1711 - const fromLeft = Math.random() < 0.5;
1712 - rockets.push({
1713 - sideways: true,
1714 - dir: fromLeft ? 1 : -1,
1715 - x: fromLeft ? -10 : width + 10,
1716 - // In de grootste vrije strook, zodat je hem ziet slingeren en ontploffen.
1717 - baseY: (() => {
1718 - const band = freeBands(height).reduce((best, b) => (!best || b.bottom - b.top > best.bottom - best.top ? b : best), null);
1719 - if (!band || band.bottom - band.top < 120) return options.decorTop + 60 + Math.random() * height * 0.45;
1720 - return band.top + 40 + Math.random() * (band.bottom - band.top - 120);
1721 - })(),
1722 - y: 0,
1723 - age: 0,
1724 - speed: 260 + Math.random() * 120,
1725 - endX: width * (fromLeft ? 0.4 + Math.random() * 0.4 : 0.2 + Math.random() * 0.4),
1726 - wobble: 14 + Math.random() * 14,
1727 - });
1728 - }
1729 -
1730 - function runFireworks(now, part) {
1731 - if (now >= nextEcgAt) {
1732 - const width = window.innerWidth;
1733 - const height = window.innerHeight;
1734 - const n = 1 + (Math.random() < 0.5 ? 1 : 0) + (Math.random() < 0.2 ? 1 : 0);
1735 - for (let i = 0; i < n; i += 1) launchRocket(width, height);
1736 - // Af en toe gaat er een mis: een pijl die horizontaal en slingerend over het scherm schiet.
1737 - if (Math.random() < (part.sideways || 0)) launchSidewaysRocket(width, height);
1738 - nextEcgAt = now + randomBetween(part.everySeconds) * 1000;
1739 - }
1740 - }
1741 -
1742 - function drawFireworks(now, dt) {
1743 - const trailEntry = paletteAtlas("#e0b000");
1744 - rockets = rockets.filter((rocket) => {
1745 - if (rocket.sideways) {
1746 - // Slingerend en een beetje zakkend; aan het eind ontploft hij alsnog.
1747 - rocket.age += dt;
1748 - rocket.x += rocket.dir * rocket.speed * dt;
1749 - rocket.y = rocket.baseY + Math.sin(rocket.age * 11) * rocket.wobble + rocket.age * rocket.age * 30;
1750 - if ((rocket.dir > 0 && rocket.x >= rocket.endX) || (rocket.dir < 0 && rocket.x <= rocket.endX)) {
1751 - spawnBurst(rocket.x, rocket.y);
1752 - return false;
1753 - }
1754 - const c = Math.floor(rocket.x / cellW);
1755 - const r = Math.floor(rocket.y / cellH);
1756 - drawCell(c, r, trailEntry);
1757 - drawCell(c - rocket.dir, r, trailEntry, 0.7);
1758 - drawCell(c - 2 * rocket.dir, r, trailEntry, 0.45);
1759 - drawCell(c - 3 * rocket.dir, r, trailEntry, 0.25);
1760 - return true;
1761 - }
1762 - rocket.y -= rocket.speed * dt;
1763 - if (rocket.y <= rocket.top) {
1764 - spawnBurst(rocket.x, rocket.y);
1765 - return false;
1766 - }
1767 - const c = Math.floor(rocket.x / cellW);
1768 - const r = Math.floor(rocket.y / cellH);
1769 - drawCell(c, r, trailEntry);
1770 - drawCell(c, r + 1, trailEntry, 0.6);
1771 - drawCell(c, r + 2, trailEntry, 0.3);
1772 - return true;
1773 - });
1774 - sparks = sparks.filter((spark) => {
1775 - const age = (now - spark.born) / 1000;
1776 - if (age >= spark.life) return false;
1777 - spark.vy += (spark.g || 90) * dt;
1778 - spark.vx *= 1 - 0.8 * dt;
1779 - spark.x += spark.vx * dt;
1780 - spark.y += spark.vy * dt;
1781 - const c = Math.floor(spark.x / cellW);
1782 - const r = Math.floor(spark.y / cellH);
1783 - const alpha = 1 - age / spark.life;
1784 - if (spark.symbols) {
1785 - // Een ster uit het spoor: een symbool dat zacht twinkelt terwijl het uitdooft.
1786 - const glow = 0.65 + 0.35 * Math.sin(now / 140 + spark.twinkle);
1787 - drawSymbol(c, r, symbolAtlas(spark.color, spark.symbols), spark.symbol, alpha * glow);
1788 - return true;
1789 - }
1790 - const entry = paletteAtlas(spark.color);
1791 - for (let i = 0; i < (spark.w || 1); i += 1) {
1792 - for (let j = 0; j < (spark.h || 1); j += 1) drawCell(c + i, r + j, entry, alpha, !!spark.dense);
1793 - }
1794 - return true;
1795 - });
1796 - ctx.globalAlpha = 1;
1797 - }
1798 - function drawDecor(now) {
1799 - const dt = fxStep(now);
1800 - // De tijdstap voor alle deeltjes (vuurwerk, buien), ook zonder decor.
1801 - fxDt = dt;
1802 - const decor = season().decor;
1803 - if (!decor) return;
1804 - for (const item of Array.isArray(decor) ? decor : [decor]) {
1805 - if (item.kind === "slinger") drawGarland(now, item);
1806 - else if (item.kind === "vlag") drawFlag(now, item);
1807 - else if (item.kind === "sneeuw") drawSnow(now, item, dt);
1808 - else if (item.kind === "lampions") drawLanterns(now, item, dt);
1809 - else if (item.kind === "eieren") drawEggs(now, item);
1810 - else if (item.kind === "buien") burstShower(now, item);
1811 - else if (item.kind === "figuur") drawStill(now, item);
1812 - }
1813 - ctx.globalAlpha = 1;
1814 - }
1815 -
1816 - // Een slinger bovenin: bogen over de hele breedte, met vlaggetjes of lampjes eraan; hij deint zacht.
1817 - function drawGarland(now, item) {
1818 - const width = window.innerWidth;
1819 - const swag = Math.min(320, Math.max(180, width / 5));
1820 - const string = paletteAtlas("text:0.35");
1821 - const colors = item.colors || ["text:0.6"];
1822 - let n = 0;
1823 - for (let k = 0; k * swag < width; k += 1) {
1824 - const x0 = k * swag;
1825 - const sag = 26 + 4 * Math.sin(now / 1300 + k * 1.7);
1826 - const first = Math.floor(x0 / cellW);
1827 - const last = Math.floor((x0 + swag) / cellW);
1828 - for (let c = first; c < last; c += 1) {
1829 - const u = (c * cellW + cellW / 2 - x0) / swag;
1830 - const r = Math.floor((options.decorTop + 6 + sag * 4 * u * (1 - u)) / cellH);
1831 - drawCell(c, r, string);
1832 - if ((c - first) % 3 !== 1 || u < 0.08 || u > 0.92) continue;
1833 - const entry = paletteAtlas(colors[n % colors.length]);
1834 - n += 1;
1835 - if (item.style === "lampjes") {
1836 - drawCell(c, r + 1, entry, 0.6 + 0.4 * Math.sin(now / 400 + c), true);
1837 - } else {
1838 - drawCell(c, r + 1, entry, 1, true);
1839 - drawCell(c + 1, r + 1, entry, 1, true);
1840 - drawCell(c, r + 2, entry, 1, true);
1841 - }
1842 - }
1843 - }
1844 - }
1845 -
1846 - /* De vrijste plek voor iets van w bij h pixels: van een raster van mogelijke plekken die met de minste
1847 - overlap met de inhoud; bij gelijke overlap liever rechts en hoger. Elke twee seconden opnieuw, zodat
1848 - iets mee schuift als de pagina verandert, maar niet elke frame. */
1849 - let spotCache = null;
1850 - function freeSpot(now, w, h) {
1851 - if (spotCache && spotCache.w === w && spotCache.h === h && now - spotCache.at < 2000) return spotCache;
1852 - const width = window.innerWidth;
1853 - const height = window.innerHeight;
1854 - const rects = options.obstacles ? options.obstacles() : [];
1855 - const minY = options.decorTop + 8;
1856 - let best = null;
1857 - for (let gy = 0; gy <= 6; gy += 1) {
1858 - const y = minY + ((height - h - minY - 8) * gy) / 6;
1859 - for (let gx = 0; gx <= 8; gx += 1) {
1860 - const x = 8 + ((width - w - 16) * (8 - gx)) / 8;
1861 - let cover = 0;
1862 - for (const rect of rects) {
1863 - const ox = Math.min(rect.right, x + w) - Math.max(rect.left, x);
1864 - const oy = Math.min(rect.bottom, y + h) - Math.max(rect.top, y);
1865 - if (ox > 0 && oy > 0) cover += ox * oy;
1866 - }
1867 - if (!best || cover < best.cover - 1) best = { x, y, cover };
1868 - }
1869 - }
1870 - spotCache = { ...(best || { x: width - w - 8, y: minY }), w, h, at: now };
1871 - return spotCache;
1872 - }
1873 -
1874 - /* Een stilstaande figuur van tekst-art (de kroon op Koningsdag) op de vrijste plek, met een zachte
1875 - glinstering: steeds lichten een paar cellen op, per cel op een eigen ritme. */
1876 - function drawStill(now, item) {
1877 - const picture = item.art;
1878 - const w = artWidth(picture);
1879 - const spot = freeSpot(now, (w + 4) * cellW, (picture.length + 2) * cellH);
1880 - const c0 = Math.floor(spot.x / cellW) + 2;
1881 - const r0 = Math.floor(spot.y / cellH) + 1;
1882 - const sparkle = item.sparkle ? symbolAtlas(item.sparkle.color, item.sparkle.symbols) : null;
1883 - picture.forEach((row, i) => {
1884 - for (let j = 0; j < row.length; j += 1) {
1885 - const ch = row[j];
1886 - if (ch === " ") continue;
1887 - const phase = hash(i * 31.7 + j * 7.1) * Math.PI * 2;
1888 - const shine = Math.sin(now / 420 + phase);
1889 - const glitter = item.glitter && shine > 0.92;
1890 - if (glitter && sparkle) {
1891 - drawSymbol(c0 + j, r0 + i, sparkle, (hash(i + j * 3.3) * item.sparkle.symbols.length) | 0, 1);
1892 - continue;
1893 - }
1894 - drawCell(c0 + j, r0 + i, paletteAtlas(item.palette[ch]), item.glitter ? 0.82 + 0.18 * shine : 1, true);
1895 - }
1896 - });
1897 - ctx.globalAlpha = 1;
1898 - }
1899 -
1900 - // De Nederlandse vlag aan een mast, op de vrijste plek, in de tekens van het veld. Hij wappert rustig: een
1901 - // golf die naar het vrije einde toe groter wordt, met wat schaduw in de dalen. Halfstok op 4 mei.
1902 - function drawFlag(now, item) {
1903 - let flagCols = Math.min(44, Math.max(26, Math.floor(cols * 0.18)));
1904 - let flagRows = Math.max(12, Math.round((flagCols * cellW * 0.66) / cellH / 3) * 3);
1905 - // Een lange mast (ruim twee keer de vlag), zodat goed te zien is of de vlag in top of halfstok hangt.
1906 - // Past de mast niet in de hoogste vrije strook, dan worden vlag en mast samen kleiner.
1907 - const room = freeBands(window.innerHeight).reduce((max, b) => Math.max(max, b.bottom - b.top), 0);
1908 - const roomRows = Math.floor(room / cellH) - 3;
1909 - if (roomRows > 0 && flagRows * 2.1 > roomRows) {
1910 - flagRows = Math.max(6, Math.floor(roomRows / 2.1 / 3) * 3);
1911 - flagCols = Math.max(14, Math.round((flagRows * cellH * 3) / (cellW * 0.66) / 3));
1912 - }
1913 - const poleRows = Math.round(flagRows * 2.1);
1914 - // De wimpel is langer dan de vlag; de plek moet voor allebei breed genoeg zijn.
1915 - const reachCols = item.wimpel ? Math.round(flagCols * 1.25) : flagCols;
1916 - // Rechts van de vlag blijft wat ruimte, zodat hij niet tegen de rand van het venster wappert.
1917 - const spot = freeSpot(now, (reachCols + 12) * cellW, (poleRows + 2) * cellH);
1918 - const poleCol = Math.floor(spot.x / cellW);
1919 - const poleTop = Math.max(Math.ceil((options.decorTop + 24) / cellH), Math.floor(spot.y / cellH) + 1);
1920 - // Halfstok: de vlag een derde van de mast omlaag; in top direct onder de knop, of onder de wimpel.
1921 - const pennantRows = item.wimpel ? 3 : 0;
1922 - const flagTop = item.halfstok ? poleTop + Math.round(poleRows * 0.3) : poleTop + 1 + pennantRows;
1923 - const stripe = flagRows / 3;
1924 - const red = paletteAtlas("#ae1c28");
1925 - const white = paletteAtlas("text:0.22");
1926 - const blue = paletteAtlas("#21468b");
1927 - const pole = paletteAtlas("text:0.5");
1928 -
1929 - drawCell(poleCol, poleTop - 1, paletteAtlas(GOLD_BALL));
1930 - for (let r = 0; r < poleRows; r += 1) drawCell(poleCol, poleTop + r, pole);
1931 -
1932 - // De oranje wimpel boven de vlag (5 mei): lang en smal, naar het einde toe dunner.
1933 - if (item.wimpel) {
1934 - const orange = paletteAtlas("#f97316");
1935 - const length = Math.round(flagCols * 1.25);
1936 - for (let j = 0; j < length; j += 1) {
1937 - const phase = j * 0.22 - now / 650;
1938 - const wave = Math.round(Math.sin(phase) * 1.4 * (j / length));
1939 - const thick = Math.max(1, Math.round(2 * (1 - j / length)));
1940 - for (let i = 0; i < thick; i += 1) drawCell(poleCol + 1 + j, poleTop + i + wave, orange, 0.72 + 0.28 * Math.cos(phase), true);
1941 - }
1942 - }
1943 -
1944 - for (let j = 0; j < flagCols; j += 1) {
1945 - const phase = j * 0.22 - now / 650;
1946 - const free = j / flagCols;
1947 - const wave = Math.round(Math.sin(phase) * 1.6 * free);
1948 - const shade = 0.72 + 0.28 * Math.cos(phase);
1949 - for (let i = 0; i < flagRows; i += 1) {
1950 - const entry = i < stripe ? red : i < stripe * 2 ? white : blue;
1951 - drawCell(poleCol + 1 + j, flagTop + i + wave, entry, shade, true);
1952 - }
1953 - }
1954 - }
1955 - // 1 als het veld er gewoon staat; 0 tijdens de nasleep van een explosie.
1956 - function fieldPresence(now) {
1957 - const age = nukeAge(now);
1958 - if (age < options.nukeFallAfter) return 1;
1959 - if (age < options.nukeNormalAfter) return 0;
1960 - return Math.min(1, (age - options.nukeNormalAfter) / 2);
1961 - }
1962 -
1963 - function easeOut(value) {
1964 - const v = Math.min(1, Math.max(0, value));
1965 - return 1 - (1 - v) ** 3;
1966 - }
1967 -
1968 - /* De explosie, rechtstreeks op het canvas: een rode flits over het hele veld, een
1969 - schokgolf die over het scherm rolt, een paddenstoelwolk van kokende letters, het veld
1970 - dat schudt, en daarna vallen alle letters van het scherm. */
1971 - function drawNuke(now, dt) {
1972 - const t = nukeAge(now);
1973 - const accentAlpha = theme.mode === "light" ? 0.85 : 0.95;
1974 -
1975 - // Schudden: alleen het canvas, de inhoud van de pagina blijft staan.
1976 - if (t < 1.3) {
1977 - const shake = 12 * (1 - t / 1.3) ** 2;
1978 - canvas.style.transform = `translate(${(Math.random() - 0.5) * 2 * shake}px, ${(Math.random() - 0.5) * 2 * shake}px)`;
1979 - } else if (canvas.style.transform) {
1980 - canvas.style.transform = "";
1981 - }
1982 -
1983 - // Flits: het hele veld licht even rood op.
1984 - if (t < 0.7) {
1985 - const flashAtlas = t < 0.2 ? yellowAtlas : t < 0.42 ? orangeAtlas : accentAtlas;
1986 - ctx.globalAlpha = 0.6 * (1 - t / 0.7) ** 2;
1987 - for (let r = 0; r < rows; r += 1) {
1988 - const y = cellY(r);
1989 - for (let c = 0; c < cols; c += 1) {
1990 - ctx.drawImage(flashAtlas, glyphs[r * cols + c] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH);
1991 - }
1992 - }
1993 - }
1994 -
1995 - // Schokgolf: een ring die in anderhalve seconde voorbij de randen van het scherm rolt.
1996 - if (t < 1.8) {
1997 - const progress = t / 1.8;
1998 - const reach = Math.hypot(window.innerWidth, window.innerHeight);
1999 - const radius = reach * easeOut(progress);
2000 - const band = 30 + 90 * (1 - progress);
2001 - const strength = 0.95 * (1 - progress);
2002 - const r0 = Math.max(0, Math.floor((nuke.y - radius - band) / cellH));
2003 - const r1 = Math.min(rows - 1, Math.ceil((nuke.y + radius + band) / cellH));
2004 - for (let r = r0; r <= r1; r += 1) {
2005 - const dy = r * cellH + cellH / 2 - nuke.y;
2006 - const outer = (radius + band) ** 2 - dy * dy;
2007 - if (outer <= 0) continue;
2008 - const reachX = Math.sqrt(outer);
2009 - const c0 = Math.max(0, Math.floor((nuke.x - reachX) / cellW));
2010 - const c1 = Math.min(cols - 1, Math.ceil((nuke.x + reachX) / cellW));
2011 - for (let c = c0; c <= c1; c += 1) {
2012 - const dx = c * cellW + cellW / 2 - nuke.x;
2013 - const off = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius) / band;
2014 - if (off >= 1) continue;
2015 - const front = Math.sqrt(dx * dx + dy * dy) - radius;
2016 - const waveAtlas = front > band * 0.3 ? yellowAtlas : front > -band * 0.3 ? orangeAtlas : accentAtlas;
2017 - ctx.globalAlpha = strength * (1 - off);
2018 - ctx.drawImage(waveAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH);
2019 - }
2020 - }
2021 - }
2022 -
2023 - // Paddenstoelwolk: een kokende kop die opstijgt, een steel en een wolk over de grond.
2024 - if (t < 4.2) {
2025 - const grow = easeOut(t / 1.4);
2026 - const fade = t < 2.8 ? 1 : Math.max(0, 1 - (t - 2.8) / 1.4);
2027 - // Groot op een groot scherm, passend op een klein scherm.
2028 - const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650));
2029 - const rise = 300 * size * easeOut(t / 2.2);
2030 - const capY = nuke.y - rise;
2031 - const capRx = (50 + 190 * grow) * size;
2032 - const capRy = (35 + 85 * grow) * size;
2033 - const baseRx = (70 + 230 * grow) * size;
2034 - const baseRy = (22 + 34 * grow) * size;
2035 - const spanX = Math.max(capRx * 1.25, baseRx);
2036 - const c0 = Math.max(0, Math.floor((nuke.x - spanX) / cellW));
2037 - const c1 = Math.min(cols - 1, Math.ceil((nuke.x + spanX) / cellW));
2038 - const r0 = Math.max(0, Math.floor((capY - capRy * 1.25) / cellH));
2039 - const r1 = Math.min(rows - 1, Math.ceil((nuke.y + baseRy) / cellH));
2040 -
2041 - for (let r = r0; r <= r1; r += 1) {
2042 - const py = r * cellH + cellH / 2;
2043 - for (let c = c0; c <= c1; c += 1) {
2044 - const dx = c * cellW + cellW / 2 - nuke.x;
2045 - let core = 0;
2046 -
2047 - const capDy = py - capY;
2048 - const angle = Math.atan2(capDy, dx);
2049 - const edge = 1 + 0.14 * Math.sin(angle * 7 + t * 2.5) + 0.08 * Math.sin(angle * 13 - t * 4);
2050 - const capD = Math.sqrt((dx / capRx) ** 2 + (capDy / capRy) ** 2) / edge;
2051 - if (capD < 1) core = Math.max(core, 1 - capD * 0.6);
2052 -
2053 - if (py > capY && py < nuke.y) {
2054 - const along = (py - capY) / Math.max(rise, 1);
2055 - const half = (18 + 14 * grow + 30 * grow * along * along) * size;
2056 - if (Math.abs(dx) < half) core = Math.max(core, 0.75);
2057 - }
2058 -
2059 - const baseD = Math.sqrt((dx / baseRx) ** 2 + ((py - nuke.y) / baseRy) ** 2);
2060 - if (baseD < 1 + 0.12 * Math.sin(dx * 0.05 + t * 3)) core = Math.max(core, 0.85 - baseD * 0.4);
2061 -
2062 - if (core <= 0) continue;
2063 - const x = cellX(c);
2064 - const y = cellY(r);
2065 - const glyph = randomGlyph() * tileW;
2066 - // Heet in de kern; naarmate de wolk afkoelt schuift alles richting rood.
2067 - const heatLevel = core - Math.max(0, t - 1.2) * 0.12 + (Math.random() - 0.5) * 0.12;
2068 - const fireAtlas = heatLevel > 0.8 ? yellowAtlas : heatLevel > 0.58 ? orangeAtlas : accentAtlas;
2069 - ctx.globalAlpha = Math.min(1, accentAlpha * core * fade);
2070 - ctx.drawImage(fireAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
2071 - ctx.globalAlpha = 0.35 * core * core * fade;
2072 - ctx.drawImage(neutralAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH);
2073 - }
2074 - }
2075 - }
2076 -
2077 - drawSmoke(t, dt);
2078 -
2079 - // De letters vallen: elk teken uit het rustende veld krijgt een eigen moment en valt
2080 - // dan met zwaartekracht van het scherm.
2081 - if (t >= options.nukeFallAfter && !fallers) {
2082 - const list = [];
2083 - for (let i = 0; i < ambient.length; i += 1) if (ambient[i] >= 0.012) list.push(i);
2084 - fallers = {
2085 - index: Int32Array.from(list),
2086 - y: new Float32Array(list.length),
2087 - vy: new Float32Array(list.length),
2088 - delay: Float32Array.from(list, () => Math.random() * 1.8),
2089 - };
2090 - }
2091 - if (fallers && t < options.nukeNormalAfter) {
2092 - const since = t - options.nukeFallAfter;
2093 - const floor = canvas.height;
2094 - for (let i = 0; i < fallers.index.length; i += 1) {
2095 - const index = fallers.index[i];
2096 - if (since > fallers.delay[i] && dt > 0) {
2097 - fallers.vy[i] += 1600 * dpr * dt;
2098 - fallers.y[i] += fallers.vy[i] * dt;
2099 - }
2100 - const r = (index / cols) | 0;
2101 - const y = cellY(r) + fallers.y[i];
2102 - if (y > floor) continue;
2103 - // Tijdens het vallen iets duidelijker, anders zie je ze bijna niet gaan.
2104 - const boost = since > fallers.delay[i] ? 3.5 : 1;
2105 - ctx.globalAlpha = Math.min(0.5, ambient[index] * boost);
2106 - ctx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(index % cols), y, tileW, tileH);
2107 - }
2108 - }
2109 -
2110 - ctx.globalAlpha = 1;
2111 - if (t >= options.nukeNormalAfter + 2) {
2112 - nuke = null;
2113 - fallers = null;
2114 - canvas.style.transform = "";
2115 - }
2116 - }
2117 -
2118 - function render(now, dt) {
2119 - // Een venster zonder afmeting (geminimaliseerd of verborgen bij het laden): niets tekenen.
2120 - // drawImage van een leeg canvas gooit een fout, en die fout brak hier createGlyphField af.
2121 - // Bij de eerste resize gaat het vanzelf verder.
2122 - if (!neutralAtlas || !cols || !canvas.width || !canvas.height) return;
2123 - const hadTrail = minC <= maxC;
2124 - const ambientDue = now - lastAmbient >= options.ambientIntervalMs;
2125 -
2126 - // Los aangeroepen (thema, formaat) terwijl er niets veranderd is: niets tekenen.
2127 - if (!hadTrail && !ambientDue && dt === 0) return;
2128 -
2129 - if (ambientDue) {
2130 - const elapsed = Number.isFinite(lastAmbient) ? (now - lastAmbient) / 1000 : 0;
2131 - drawAmbient(now, elapsed);
2132 - lastAmbient = now;
2133 - }
2134 -
2135 - ctx.setTransform(1, 0, 0, 1, 0, 0);
2136 - ctx.clearRect(0, 0, canvas.width, canvas.height);
2137 - // Na een explosie ligt het veld stil tot nukeNormalAfter en komt het daarna terug. Een
2138 - // smiley maakt alleen een cirkel om zichzelf stil (quiet), niet het hele veld.
2139 - calm = fieldPresence(now);
2140 - if (calm > 0.01) {
2141 - ctx.globalAlpha = calm;
2142 - ctx.drawImage(layer, 0, 0);
2143 - ctx.globalAlpha = 1;
2144 - }
2145 - drawDecor(now);
2146 - if (dt > 0) {
2147 - heartbeat(now);
2148 - heartLine(now);
2149 - drawSmileys(now);
2150 - }
2151 - drawTrail(dt);
2152 - drawArtShapes();
2153 - drawFigure();
2154 - if (proost) drawProost(now);
2155 - if (rockets.length || sparks.length) drawFireworks(now, fxDt);
2156 - if (proost) drawGlasses(now);
2157 - if (nuke) drawNuke(now, dt);
2158 - }
2159 -
2160 - function loop(now) {
2161 - if (!running) return;
2162 - frameId = requestAnimationFrame(loop);
2163 - // Op een stille dag en tijdens een seizoen geen muisspoor (tenzij het seizoen trail: true zet):
2164 - // zo komt de figuur beter tot zijn recht.
2165 - if (season().quiet || (options.season && !season().trail)) pointer = null;
2166 - if (pointer && nukeAge(now) >= options.nukeHeartAfter) paintPointer();
2167 - else if (nukeAge(now) < options.nukeHeartAfter) pointer = null;
2168 - // Dertig beelden per seconde is genoeg voor wisselende tekens; dat halveert het werk
2169 - // op een 60 Hz-scherm en meer op een sneller scherm.
2170 - if (lastFrame && now - lastFrame < 1000 / options.maxFps - 2) return;
2171 - const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.25) : 0;
2172 - lastFrame = now;
2173 - render(now, dt);
2174 - }
2175 -
2176 - function start() {
2177 - if (running || destroyed || reducedMotion.matches || document.hidden) return;
2178 - running = true;
2179 - lastFrame = 0;
2180 - frameId = requestAnimationFrame(loop);
2181 - }
2182 -
2183 - function stop() {
2184 - running = false;
2185 - cancelAnimationFrame(frameId);
2186 - }
2187 -
2188 - function onPointerMove(event) {
2189 - if (reducedMotion.matches) return;
2190 - const next = { x: event.clientX, y: event.clientY };
2191 - // Na het verlaten van het venster geen streep trekken vanaf de oude plek.
2192 - if (!lastPointer) lastPointer = next;
2193 - pointer = next;
2194 - heartHome = next;
2195 - }
2196 -
2197 - function onPointerLeave() {
2198 - lastPointer = null;
2199 - pointer = null;
2200 - heartHome = null;
2201 - lastBeatSpot = null;
2202 - }
2203 -
2204 - function onVisibility() {
2205 - if (document.hidden) stop();
2206 - else start();
2207 - }
2208 -
2209 - function onMotionChange() {
2210 - if (reducedMotion.matches) {
2211 - stop();
2212 - trail.fill(0);
2213 - pulse.fill(0);
2214 - rings = [];
2215 - smileys = [];
2216 - nuke = null;
2217 - fallers = null;
2218 - canvas.style.transform = "";
2219 - streak.fill(0);
2220 - lineTrail.fill(0);
2221 - quiet.fill(0);
2222 - minC = minR = 1;
2223 - maxC = maxR = 0;
2224 - lastAmbient = -Infinity;
2225 - render(performance.now(), 0);
2226 - } else {
2227 - start();
2228 - }
2229 - }
2230 -
2231 - let resizeTimer = 0;
2232 - function onResize() {
2233 - window.clearTimeout(resizeTimer);
2234 - resizeTimer = window.setTimeout(resize, 120);
2235 - }
2236 -
2237 - // Het thema wisselt via data-appearance op <html>; zie layout.css.
2238 - const themeObserver = new MutationObserver(applyTheme);
2239 - themeObserver.observe(document.documentElement, {
2240 - attributes: true,
2241 - attributeFilter: ["data-appearance", "class", "style"],
2242 - });
2243 -
2244 - window.addEventListener("pointermove", onPointerMove, { passive: true });
2245 - window.addEventListener("pointerdown", onPointerDown, { passive: true });
2246 - document.documentElement.addEventListener("pointerleave", onPointerLeave);
2247 - window.addEventListener("blur", onPointerLeave);
2248 - window.addEventListener("resize", onResize);
2249 - document.addEventListener("visibilitychange", onVisibility);
2250 - reducedMotion.addEventListener("change", onMotionChange);
2251 -
2252 - /* Andere intensiteit of ander thema zonder het veld opnieuw te starten. Een nieuw thema zet nieuwe
2253 - tekens in elke cel; een andere intensiteit tekent alleen de rustlaag opnieuw. */
2254 - function configure(next = {}) {
2255 - if (destroyed) return;
2256 - Object.assign(options, next);
2257 - if ("season" in next) {
2258 - // Ander seizoen: andere kleuren, en de doorkomer, het decor en de klikvormen beginnen opnieuw.
2259 - figure = null;
2260 - resetSeasonFx();
2261 - smileys = [];
2262 - const first = (options.season && options.season.traverser && options.season.traverser.firstSeconds) || options.ecgFirstSeconds;
2263 - nextEcgAt = performance.now() + randomBetween(first) * 1000;
2264 - buildAtlases();
2265 - }
2266 - if (next.glyphs) {
2267 - const nextSet = checkGlyphs(next.glyphs);
2268 - const changed = nextSet.length !== glyphSet.length || nextSet.some((glyph, index) => glyph !== glyphSet[index]);
2269 - if (changed) {
2270 - glyphSet = nextSet;
2271 - refreshDense();
2272 - for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
2273 - buildAtlases();
2274 - }
2275 - }
2276 - lastAmbient = -Infinity;
2277 - render(performance.now(), 0);
2278 - }
2279 -
2280 - resize();
2281 - start();
2282 -
2283 - return { destroy, configure };
2284 -
2285 - function destroy() {
2286 - destroyed = true;
2287 - stop();
2288 - window.clearTimeout(resizeTimer);
2289 - themeObserver.disconnect();
2290 - window.removeEventListener("pointermove", onPointerMove);
2291 - window.removeEventListener("pointerdown", onPointerDown);
2292 - document.documentElement.removeEventListener("pointerleave", onPointerLeave);
2293 - window.removeEventListener("blur", onPointerLeave);
2294 - window.removeEventListener("resize", onResize);
2295 - document.removeEventListener("visibilitychange", onVisibility);
2296 - reducedMotion.removeEventListener("change", onMotionChange);
2297 - }
2298 -}
2299 -
2300 -// /src/lib/glyphSeasons.js
2301 -
2302 -/* Seizoensthema's van het tekenveld: welke periode welk thema heeft, en wat dat thema is. Dit bestand is
2303 - puur (geen browser, geen engine), zodat de datumregels zonder browser te testen zijn
2304 - (tests/glyphSeasons.test.mjs). De engine (src/lib/glyphField.js) kent geen thema's bij naam; hij krijgt
2305 - een seizoen als gegevens (optie season) en tekent wat erin staat:
2306 -
2307 - - accent en line: de kleur van het muisspoor, de ringen en de lijn;
2308 - - traverser: iets wat af en toe over het scherm gaat, in plaats van de ECG-lijn: een figuur van
2309 - tekst-art (frames; ground: over de onderrand in plaats van door een vrije strook; trail: een golvend
2310 - spoor van symbolen erachter), of vuurpijlen (kind: "vuurwerk");
2311 - - decor: wat blijft staan of steeds terugkomt: een stilstaande figuur (kind "figuur", optioneel
2312 - glinsterend, zoals de kroon), de vlag (halfstok op 4 mei, met de oranje wimpel op
2313 - 5 mei), sneeuw, lampionnetjes die opstijgen, eieren die verstopt liggen, of buien (een handvol
2314 - deeltjes die ergens uit elkaar spat en valt, zoals pepernoten); een slinger bovenin kan
2315 - ook (kind "slinger"), maar leest op celniveau slecht en wordt nu nergens gebruikt;
2316 - - clickArt: wat een klik op de achtergrond geeft, in plaats van een smiley (burst: vuurwerk);
2317 - - quiet: alleen het rustige veld en het decor, zonder spoor, hartslag, lijn, smileys of explosie.
2318 -
2319 - Tijdens elk seizoen staan de hartjesringen, de hartslaglijn, de explosie en het muisspoor uit; het
2320 - feest is de passieve animatie. Een seizoen dat iets toch wil, zet heart: true, nuke: true of trail: true.
2321 - De klikvormen blijven.
2322 -
2323 - Een nieuw thema is een blok gegevens hieronder, geen code. Tekst-art: elke letter is één cel van het
2324 - veld; een spatie is leeg, en de letter bepaalt de kleur via de palette ("text" is de tekstkleur van het
2325 - thema, met een optionele zichtbaarheid erachter, bijvoorbeeld "text:0.5"). De cellen zijn ongeveer twee
2326 - keer zo hoog als breed, net als letters in een editor. De grote figuren zijn gemaakt met
2327 - scripts/glyph-sprites.mjs; daar staan ze als vormen, hier als tekst.
2328 -
2329 - Een thema wordt pas vanzelf actief als het klaar is (klaar: true). Met ?tekenveld=<id> in de adresbalk
2330 - is elk thema meteen te zien, ook buiten zijn periode; ?tekenveld=geen zet de thema's uit. */
2331 -
2332 -/* Paaszondag volgens de gregoriaanse kalender (anonieme algoritme van Meeus/Jones/Butcher). */
2333 -function easterSunday(year) {
2334 - const a = year % 19;
2335 - const b = Math.floor(year / 100);
2336 - const c = year % 100;
2337 - const d = Math.floor(b / 4);
2338 - const e = b % 4;
2339 - const f = Math.floor((b + 8) / 25);
2340 - const g = Math.floor((b - f + 1) / 3);
2341 - const h = (19 * a + b - d - g + 15) % 30;
2342 - const i = Math.floor(c / 4);
2343 - const k = c % 4;
2344 - const l = (32 + 2 * e + 2 * i - h - k) % 7;
2345 - const m = Math.floor((a + 11 * h + 22 * l) / 451);
2346 - const month = Math.floor((h + l - 7 * m + 114) / 31);
2347 - const day = ((h + l - 7 * m + 114) % 31) + 1;
2348 - return new Date(year, month - 1, day);
2349 -}
2350 -
2351 -/* Koningsdag: 27 april, of 26 april als de 27e op een zondag valt. */
2352 -function kingsDay(year) {
2353 - const day = new Date(year, 3, 27);
2354 - return day.getDay() === 0 ? new Date(year, 3, 26) : day;
2355 -}
2356 -
2357 -function dayNumber(date) {
2358 - return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
2359 -}
2360 -
2361 -function monthDay(text, year) {
2362 - const [month, day] = text.split("-").map(Number);
2363 - return new Date(year, month - 1, day);
2364 -}
2365 -
2366 -/* Valt de datum in de periode van de regel? Regels:
2367 - - { from: "MM-DD", to: "MM-DD" }: vaste dagen, beide ingesloten; mag over de jaarwisseling heen;
2368 - - { easter: [vanaf, tot] }: dagen ten opzichte van Paaszondag, beide ingesloten;
2369 - - { kingsDay: true }: Koningsdag met de zondagregel. */
2370 -function inPeriod(date, rule) {
2371 - const today = dayNumber(date);
2372 - const year = date.getFullYear();
2373 - if (rule.kingsDay) return today === dayNumber(kingsDay(year));
2374 - if (rule.easter) {
2375 - const easter = dayNumber(easterSunday(year));
2376 - return today >= easter + rule.easter[0] && today <= easter + rule.easter[1];
2377 - }
2378 - const from = dayNumber(monthDay(rule.from, year));
2379 - const to = dayNumber(monthDay(rule.to, year));
2380 - // Over de jaarwisseling: van "12-27" tot "01-14".
2381 - if (from > to) return today >= from || today <= to;
2382 - return today >= from && today <= to;
2383 -}
2384 -
2385 -const RED = "#c8102e";
2386 -const GOLD = "#c99a2e";
2387 -const GREEN = "#2f7d32";
2388 -const ORANGE = "#f97316";
2389 -const NL_RED = "#ae1c28";
2390 -const NL_BLUE = "#21468b";
2391 -const BROWN = "#8b5a2b";
2392 -const YELLOW = "#e0b000";
2393 -
2394 -/* De figuren, gemaakt met scripts/glyph-sprites.mjs (node scripts/glyph-sprites.mjs <naam> js). */
2395 -const SINT_0 = [
2396 - " R GGGGGGG",
2397 - " RGR GG GG",
2398 - " RRRGRRR G GGGG F FH",
2399 - " RRRRGRRRR G MMMMFH HFH",
2400 - " RRRRRGRRRR G MMMMMMHHHHHFH",
2401 - " RRRRRGRRRR G MMMMMMMHHHHHH",
2402 - " GGGGGGGGGGGGG G MMMMMMHHHHHHHHHHH",
2403 - " SSSSSKS G MMMMM HHHHHHHHHHHHHH",
2404 - " SSSSSSS G MMMMM HHHHHHHHHHHHHHHHH",
2405 - " WWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHH",
2406 - " RRRRWWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHKH",
2407 - " RRRRRRWWWWWWWWWR G MMMMMHHHHHHHHHHH HHHHKLLH",
2408 - " RRRRRRRRRWWWWWRRRRRRRR GMMMM HHHHHHHHHHH LLLLL",
2409 - " RRRRRRRRRRRWWWRRRRRRRRRWWGMMMHHHHHHHHHHHLLLL",
2410 - " HHHHHRRRRRRRRRRRWWWRRRRR RWLLLLLHHHHHHHHLLL",
2411 - " HHHHHHHHRRRRRRRRRRRRWWWRRRRR MMMGMHHHHHHHHHHH",
2412 - " MMMMMHHHHHHHHHRRRRRRRRRRRRWWWRRRRRRRRRRMGHHHHHHHHHH",
2413 - " MMMMMMMHHHHHHHHHRRRRRRRRRRRRRWWWRRRRRRRRRRHGHHHHHHHHHH",
2414 - " MMMMMMMMHHHHHHHHHHHHHHHRRRRRRRRRRRRRRRRRRRRRHGHHHHHHHHHHH",
2415 - " MMMMMM HHHHHHHHHHHHHHHGGGGGRRRRRRRGGGGGGGGGHGHHHHHHHHHHH",
2416 - " MMMMM HHHHHHHHHHHHHHHHGHHGRRRRRRRHGHHGHHGHHHHHHHHHHHHHH",
2417 - " MMMM HHHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHHH",
2418 - " MMMM HHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHH",
2419 - " MMMM HHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHH",
2420 - " MMM HHHHHHFFF HHKKKKKHHHHHH HHHHHHHHHFF",
2421 - " MMM HHHHHFFF KKKKK HHHHFFFF",
2422 - " MMM HHHHFF GGGGGGG HHHHHFFF",
2423 - " MMMM HHHHH HHHHHFF",
2424 - " MMM HHHHH HHHHHF",
2425 - " MMM HHHH HHHHHH",
2426 - " M KKK HKKK",
2427 - "",
2428 -];
2429 -const SINT_1 = [
2430 - " R GGGGGGG",
2431 - " RGR GG GG",
2432 - " RRRGRRR G GGGG F FH",
2433 - " RRRRGRRRR G MMMFH HFH",
2434 - " RRRRRGRRRR G MMMMMMHHHHHFH",
2435 - " RRRRRGRRRR G MMMMMMHHHHHH",
2436 - " GGGGGGGGGGGGG G MMMMMMHHHHHHHHHHH",
2437 - " SSSSSKS G MMMMMMHHHHHHHHHHHHHH",
2438 - " SSSSSSS G MMMMM HHHHHHHHHHHHHHHHH",
2439 - " WWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHH",
2440 - " RRRRWWWWWWWWW G MMMM HHHHHHHHHHHHHHHHHHHHKH",
2441 - " RRRRRRWWWWWWWWWR G MMMM HHHHHHHHHHH HHHHKLLH",
2442 - " RRRRRRRRRWWWWWRRRRRRRR GMMMM HHHHHHHHHHH LLLLL",
2443 - " RRRRRRRRRRRRWWWRRRRRRRRRWWGMMMHHHHHHHHHHHLLLL",
2444 - " HHHHRRRRRRRRRRRRWWWRRRRR RWLLLLLHHHHHHHHLLL",
2445 - " HHHHHHHRRRRRRRRRRRRRWWWRRRRR MMMGMHHHHHHHHHHH",
2446 - " MMMMHHHHHHHHRRRRRRRRRRRRRWWWRRRRRRRRRRMGHHHHHHHHHH",
2447 - " MMMMMMHHHHHHHHRRRRRRRRRRRRRRWWWRRRRRRRRRRHGHHHHHHHHHH",
2448 - " MMMMMMMHHHHHHHHHHHHHHHRRRRRRRRRRRRRRRRRRRRRHGHHHHHHHHHHH",
2449 - " MMMMMMM HHHHHHHHHHHHHHHGGGGGRRRRRRRGGGGGGGGGHGHHHHHHHHHHH",
2450 - " MMMMMM HHHHHHHHHHHHHHHHGHHGRRRRRRRHGHHGHHGHHHHHHHHHHHHHH",
2451 - " MMMM HHHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHHH",
2452 - " MMMM HHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHH",
2453 - " MMMM HHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHH",
2454 - " MMMM HHHHHHHFFF HHKKKKKHHHHHH HHHHHHHHHF",
2455 - " MMM HHHHHFFFF KKKKK HHHHFFF",
2456 - " MMM HHHH FFFF GGGGGGG HHHHFFF",
2457 - " MMMM KKK FFFFF HHHHHF",
2458 - " MMMMM FFFFF HHHH",
2459 - " MMMM KFFF KKHHHH",
2460 - " M KKK KKK",
2461 - "",
2462 -];
2463 -const SINT_2 = [
2464 - " R GGGGGGG",
2465 - " RGR GG GG",
2466 - " RRRGRRR G GGGG F FH",
2467 - " RRRRGRRRR G MMMMFH HFH",
2468 - " RRRRRGRRRR G MMMMMMHHHHHFH",
2469 - " RRRRRGRRRR G MMMMMMMHHHHHH",
2470 - " GGGGGGGGGGGGG G MMMMMMHHHHHHHHHHH",
2471 - " SSSSSKS G MMMMM HHHHHHHHHHHHHH",
2472 - " SSSSSSS G MMMMM HHHHHHHHHHHHHHHHH",
2473 - " WWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHH",
2474 - " RRRRWWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHKH",
2475 - " RRRRRRWWWWWWWWWR G MMMMMHHHHHHHHHHH HHHHKLLH",
2476 - " RRRRRRRRRWWWWWRRRRRRRR GMMMM HHHHHHHHHHH LLLLL",
2477 - " RRRRRRRRRRRWWWRRRRRRRRRWWGMMMHHHHHHHHHHHLLLL",
2478 - " HHHHHRRRRRRRRRRRWWWRRRRR RWLLLLLHHHHHHHHLLL",
2479 - " HHHHHHHHRRRRRRRRRRRRWWWRRRRR MMMGMHHHHHHHHHHH",
2480 - " MMMMMHHHHHHHHHRRRRRRRRRRRRWWWRRRRRRRRRRMGHHHHHHHHHH",
2481 - " MMMMMMMHHHHHHHHHRRRRRRRRRRRRRWWWRRRRRRRRRRHGHHHHHHHHHH",
2482 - " MMMMMMMMHHHHHHHHHHHHHHHRRRRRRRRRRRRRRRRRRRRRHGHHHHHHHHHHH",
2483 - " MMMMMM HHHHHHHHHHHHHHHGGGGGRRRRRRRGGGGGGGGGHGHHHHHHHHHHH",
2484 - " MMMMM HHHHHHHHHHHHHHHHGHHGRRRRRRRHGHHGHHGHHHHHHHHHHHHHH",
2485 - " MMMM HHHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHHH",
2486 - " MMMM HHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHH",
2487 - " MMMM HHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHH",
2488 - " MMM HHHHHHHHFFFF HHKKKKKHHHHHH HHHHHHHHHF",
2489 - " MMM HHHHH FFFF KKKKK HHHHH FFFF",
2490 - " MMM HHHHH FFF GGGGGGG HHHHH FFFF",
2491 - " MMMM HHHHH FFF HHHH KKK",
2492 - " MMM HHHHH FFF HHHH",
2493 - " MMM HHHH FFF KKK",
2494 - " M KKK KKK",
2495 - "",
2496 -];
2497 -const SINT_3 = [
2498 - " R GGGGGGG",
2499 - " RGR GG GG",
2500 - " RRRGRRR G GGGG F FH",
2501 - " RRRRGRRRR G MMMMFH HFH",
2502 - " RRRRRGRRRR G MMMMMMMHHHHHFH",
2503 - " RRRRRGRRRR G MMMMMMMHHHHHH",
2504 - " GGGGGGGGGGGGG G MMMMM HHHHHHHHHHH",
2505 - " SSSSSKS G MMMMM HHHHHHHHHHHHHH",
2506 - " SSSSSSS G MMMMM HHHHHHHHHHHHHHHHH",
2507 - " WWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHH",
2508 - " RRRRWWWWWWWWW G MMMMMHHHHHHHHHHHHHHHHHHHHKH",
2509 - " RRRRRWWWWWWWWWR G MMMMMHHHHHHHHHHH HHHHKLLH",
2510 - " RRRRRRRRWWWWWRRRRRRRR GMMMM HHHHHHHHHHH LLLLL",
2511 - " RRRRRRRRRRWWWRRRRRRRRRWWGMMMHHHHHHHHHHHLLLL",
2512 - " HHHHH RRRRRRRRRRWWWRRRRR RWLLLLLHHHHHHHHLLL",
2513 - " HHHHHHHHHRRRRRRRRRRRWWWRRRRR MMMG HHHHHHHHHHH",
2514 - " MMMMMMHHHHHHHHHHRRRRRRRRRRRWWWRRRRRRRRRRMGHHHHHHHHHH",
2515 - " MMMMMMMMMHHHHHHHHHHRRRRRRRRRRRRWWWRRRRRRRRRRHGHHHHHHHHHH",
2516 - " MMMMMMMMMHHHHHHHHHHHHHHHRRRRRRRRRRRRRRRRRRRRRHGHHHHHHHHHHH",
2517 - " MMMMM HHHHHHHHHHHHHHHGGGGGRRRRRRRGGGGGGGGGHGHHHHHHHHHHH",
2518 - " MMMMM HHHHHHHHHHHHHHHHGHHGRRRRRRRHGHHGHHGHHHHHHHHHHHHHH",
2519 - " MMMM HHHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHHH",
2520 - " MMMM HHHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHHH",
2521 - " MMM HHHHHHHHHHHHHHHHHRRRRRRRHHHHHHHHHHHHHHHHHHHH",
2522 - " MMM HHHHHHFFFFF HHKKKKKHHHHHH HHHHHHHHHFF",
2523 - " MMM HHHH FFFFF KKKKK HHHHH FFFF",
2524 - " MMM HHHHH FFFF GGGGGGG HHHHH FFFF",
2525 - " MMM HHH FFFFF KKK FFFF",
2526 - " MMM HHHH FFFFF FFFF",
2527 - " MMM HHH FFFK FFFF",
2528 - " M KKK KKK KKK",
2529 - "",
2530 -];
2531 -const DUIF_0 = [
2532 - " WWWWWW",
2533 - " WWWWWWWWW",
2534 - " WWWWWWWW",
2535 - " WWWWWWW",
2536 - " WWWWWWW",
2537 - " WWWWWWWW WW",
2538 - " WWWWWWW WWWWWWWWW WWWWKW",
2539 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWOOOG",
2540 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWW GGGGG",
2541 - " WWWWWWWW WWWWWWWWWWWWW GGG",
2542 - " WWW GG",
2543 - "",
2544 - "",
2545 - "",
2546 -];
2547 -const DUIF_1 = [
2548 - "",
2549 - "",
2550 - "",
2551 - " WWWWWW",
2552 - " WWWWWWW",
2553 - " WWWWWWWW WW",
2554 - " WWWWWWW WWWWWWWWWW WWWWKW",
2555 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWOOOG",
2556 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWW GGGGG",
2557 - " WWWWWWWW WWWWWWWWWWWWW GGG",
2558 - " WWW GG",
2559 - "",
2560 - "",
2561 - "",
2562 -];
2563 -const DUIF_2 = [
2564 - "",
2565 - "",
2566 - "",
2567 - "",
2568 - "",
2569 - " WW",
2570 - " WWWWWWW WWWWWWWWW WWWWKW",
2571 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWOOOG",
2572 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWW GGGGG",
2573 - " WWWWWWWW WWWWWWWWWWWWW GGG",
2574 - " WWW WWWWWWWW GG",
2575 - " WWWWWWWW",
2576 - " WWWWWWWWW",
2577 - " WWWWWWWWWW",
2578 -];
2579 -const DUIF_3 = [
2580 - "",
2581 - "",
2582 - "",
2583 - "",
2584 - "",
2585 - " WW",
2586 - " WWWWWWW WWWWWWWWW WWWWKW",
2587 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWOOOG",
2588 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWW GGGGG",
2589 - " WWWWWWWW WWWWWWWWWWWWW GGG",
2590 - " WWW WWWWWWWWW GG",
2591 - " WWWWWWW",
2592 - " WWWWW",
2593 - "",
2594 -];
2595 -const KERSTMAN_0 = [
2596 - "",
2597 - " WWWW",
2598 - " WWRRRRR",
2599 - " RRRRRRRR",
2600 - " RRRRRRRR KK KK KKKK KK KK KKKK",
2601 - " RRRRRRRR KKK K KK KKK K KK",
2602 - " RRRRRRRRR KKK KK KKK KKK KK KK",
2603 - " RRRRRRRRRR KK KK KKK KK KK KKK",
2604 - " WWWWWWWWWWWWW WWWW KKKKK K KK KK KKKKK K KK KK",
2605 - " WWWWWWWWWWWWW RRWWWW KK KK KKKK KK KK KKKK",
2606 - " RRGRR SSSSSSSSSSRRRRRRR KKK KK KKK KK",
2607 - " GGGG RRRRR WWWWWWWWWWRRRRRRR KK KK KK KK",
2608 - " GGGGKKRRRRR RWWWWWWWWRRRRRRR KK KK KK KK",
2609 - " KGGGGKKKKKKKK RRRRWWWWWWWRRRRRR BBKBKKB BBKBKKB",
2610 - " KKKKKKKKKKKKKKK RRRRRRRWWWWWWWWRR BBBBBKBBBB BBBBBKBBBB",
2611 - " KKKKKKKKKKKKKKK RRRRRRRRRRRRRRRRR BBBBBBBBBBBK BBBBBBBBBBBN",
2612 - " KKKKKKKKKKKKKKK RRRRRRRRGGGRRRRRRRR RR BBBBB BBB BBBBB BBB",
2613 - " KKKKKKKKKKK KKKKKKKGGGKKKKKKK RRRRRLLLL RRR BBBBB RRR BBBBB",
2614 - " KKKKK RRRRRRRRRRRRRRR RRRRRR LLLLLLLLLLL W BBBBBRRRBBBBBBBB W BBBBBRRRBBBBBBBB",
2615 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR LLLLL LLLLLLLLBBLLLLRRRBBBBBBB BBBBBBLLRRBBBBBBB",
2616 - " RRRGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGRRR LLLLLLLL LLLLLBBRRRBBBBBB BBLLBBRRRBBBBBB",
2617 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR DBBBBRRLLLLLBBLLLLLLLLLLL DBBBBRRRBBBBBB",
2618 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GGG DDDBBBB DDBBB DDDBBBB DDBBB",
2619 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GG DDD BBBB DD BBB DDD BBBB DD BBB",
2620 - " G GG GGG KK BBBBDDD BBBB KK BBBBDDD BBBB",
2621 - " GG G GGG BBB DD BBBB BBB DD BBBB",
2622 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG KK KK KK KK",
2623 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
2624 - "",
2625 - "",
2626 - "",
2627 - "",
2628 - "",
2629 - "",
2630 - "",
2631 - "",
2632 -];
2633 -const KERSTMAN_1 = [
2634 - "",
2635 - " WW",
2636 - " WWWWRRR",
2637 - " RRRRRRRR",
2638 - " RRRRRRRR KK KK KKKK KK KK KKKK",
2639 - " RRRRRRRR KKK K KK KKK K KK",
2640 - " RRRRRRRRR KKK KK KKK KKK KK KK",
2641 - " RRRRRRRRRR KK KK KKK KK KK KKK",
2642 - " WWWWWWWWWWWWW WW KKKKK K KK KK KKKKK K KK KK",
2643 - " WWWWWWWWWWWWW RWWWW KK KK KKKK KK KK KKKK",
2644 - " RRGRR SSSSSSSSSS RRRRWWR KKK KK KKK KK",
2645 - " GGGG RRRRR WWWWWWWWWWWRRRRRR KK KK KK KK",
2646 - " GGGGKKRRRRR RWWWWWWWWRRRRRRR KK KK KK KK",
2647 - " KGGGGKKKKKKKK RRRRWWWWWWWRRRRRR BBKBKKB BBKBKKB",
2648 - " KKKKKKKKKKKKKKK RRRRRRRWWWWWWWWRR BBBBBKBBBB BBBBBKBBBB",
2649 - " KKKKKKKKKKKKKKK RRRRRRRRRRRRRRRRR BBBBBBBBBBBK BBBBBBBBBBBN",
2650 - " KKKKKKKKKKKKKKK RRRRRRRRGGGRRRRRRRR RR BBBBB BBB BBBBB BBB",
2651 - " KKKKKKKKKKK KKKKKKKGGGKKKKKKK RRRRRLLLL RRR BBBBB RRR BBBBB",
2652 - " KKKKK RRRRRRRRRRRRRRR RRRRRR LLLLLLLLL W BBBBBRRRBBBBBBBB W BBBBBRRRBBBBBBBB",
2653 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR LLLL LLLLLLL BBBBLLLRRRBBBBBBB BBBBBBLLRRBBBBBBB",
2654 - " RRRGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGRRR LLLLLLLLLLLLLLBBBBBRRRBBBBBB BBBLBBRRRBBBBBB",
2655 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR DBBBLLLLBBBBBB LLLLLDBBBBRRRBBBBBB",
2656 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GGG DDDBBB DDDBLLLLLLLLLL DDDBBB DDDBBB",
2657 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GG DDDBBB DD BBB DDDBBB DD BBB",
2658 - " G GG GGG DDDD KK DD BBB DDDD KK DD BBB",
2659 - " GG G GGG DDD DD BB DDD DD BB",
2660 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG KK KK",
2661 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
2662 - "",
2663 - "",
2664 - "",
2665 - "",
2666 - "",
2667 - "",
2668 - "",
2669 - "",
2670 -];
2671 -const KERSTMAN_2 = [
2672 - "",
2673 - " WWWW",
2674 - " WWRRRRR",
2675 - " RRRRRRRR",
2676 - " RRRRRRRR KK KK KKKK KK KK KKKK",
2677 - " RRRRRRRR KKK K KK KKK K KK",
2678 - " RRRRRRRRR KKK KK KKK KKK KK KK",
2679 - " RRRRRRRRRR KK KK KKK KK KK KKK",
2680 - " WWWWWWWWWWWWW WWWW KKKKK K KK KK KKKKK K KK KK",
2681 - " WWWWWWWWWWWWW RRWWWW KK KK KKKK KK KK KKKK",
2682 - " RRGRR SSSSSSSSSSRRRRRRR KKK KK KKK KK",
2683 - " GGGG RRRRR WWWWWWWWWWRRRRRRR KK KK KK KK",
2684 - " GGGGKKRRRRR RWWWWWWWWRRRRRRR KK KK KK KK",
2685 - " KGGGGKKKKKKKK RRRRWWWWWWWRRRRRR BBKBKKB BBKBKKB",
2686 - " KKKKKKKKKKKKKKK RRRRRRRWWWWWWWWRR BBBBBKBBBB BBBBBKBBBB",
2687 - " KKKKKKKKKKKKKKK RRRRRRRRRRRRRRRRR BBBBBBBBBBBK BBBBBBBBBBBN",
2688 - " KKKKKKKKKKKKKKK RRRRRRRRGGGRRRRRRRR RR BBBBB BBB BBBBB BBB",
2689 - " KKKKKKKKKKK KKKKKKKGGGKKKKKKK RRRRRLLLL RRR BBBBB RRR BBBBB",
2690 - " KKKKK RRRRRRRRRRRRRRR RRRRRR LLLLLLLLLLL W BBBBBRRRBBBBBBBB W BBBBBRRRBBBBBBBB",
2691 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR LLLLL LLLLLLLLBBLLLLRRRBBBBBBB BBBBBBLLRRBBBBBBB",
2692 - " RRRGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGRRR LLLLLLLL LLLLLBBRRRBBBBBB BBLLBBRRRBBBBBB",
2693 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR DBBBBRRLLLLLBBLLLLLLLLLLL DBBBBRRRBBBBBB",
2694 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GGG BBB DBBB BBB DBBB",
2695 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GG BBBD DBB BBBD DBB",
2696 - " G GG GGG BBBBDD BBK BBBBDD BBK",
2697 - " GG G GGG BBB DD BB BBB DD BB",
2698 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
2699 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
2700 - "",
2701 - "",
2702 - "",
2703 - "",
2704 - "",
2705 - "",
2706 - "",
2707 - "",
2708 -];
2709 -const KERSTMAN_3 = [
2710 - " WW",
2711 - " WWWWR",
2712 - " RRRRR",
2713 - " RRRRRRR",
2714 - " RRRRRRR KK KK KKKK KK KK KKKK",
2715 - " RRRRRRRR KKK K KK KKK K KK",
2716 - " RRRRRRRRR KKK KK KKK KKK KK KK",
2717 - " RRRRRRRRRR KK KK KKK KK KK KKK",
2718 - " WWWWWWWWWWWWW WWWW KKKKK K KK KK KKKKK K KK KK",
2719 - " WWWWWWWWWWWWW RRWWWW KK KK KKKK KK KK KKKK",
2720 - " RRGRR SSSSSSSSSSRRRRRRR KKK KK KKK KK",
2721 - " GGGG RRRRR WWWWWWWWWWRRRRRR KK KK KK KK",
2722 - " GGGGKKRRRRR RWWWWWWWWRRRRRR KK KK KK KK",
2723 - " KGGGGKKKKKKKK RRRRWWWWWWWRRRRRR BBKBKKB BBKBKKB",
2724 - " KKKKKKKKKKKKKKK RRRRRRRWWWWWWWWRR BBBBBKBBBB BBBBBKBBBB",
2725 - " KKKKKKKKKKKKKKK RRRRRRRRRRRRRRRRR BBBBBBBBBBBK BBBBBBBBBBBN",
2726 - " KKKKKKKKKKKKKKK RRRRRRRRGGGRRRRRRRR RR BBBBB BBB BBBBB BBB",
2727 - " KKKKKKKKKKK KKKKKKKGGGKKKKKKK RRRRRLLLLL RRR BBBBB RRR BBBBB",
2728 - " KKKKK RRRRRRRRRRRRRRR RRRRRR LLLLLLLLLLLL W BBBBBRRRBBBBBBBB W BBBBBRRRBBBBBBBB",
2729 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR LLLLLLLLLLLLLLLLLLLBRRRBBBBBBB BBBBBBLLRRBBBBBBB",
2730 - " RRRGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGRRR BBLLLLLRRBBBBBB LLBLLBBBRRRBBBBBB",
2731 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR BBBBRRRBBBBLLLLLLLLLLL BBBBRRRBBBBBB",
2732 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GGG DDBBB DBB DDBBB DBB",
2733 - " RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR GG DBBB BBB DBBB BBB",
2734 - " G GG GGG BBDD KKDD BBDD KKDD",
2735 - " GG G GGG BBDDD DDD BBDDD DDD",
2736 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG KK KK",
2737 - " GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
2738 - "",
2739 - "",
2740 - "",
2741 - "",
2742 - "",
2743 - "",
2744 - "",
2745 - "",
2746 -];
2747 -const PAASHAAS_0 = [
2748 - "",
2749 - "",
2750 - "",
2751 - "",
2752 - "",
2753 - "",
2754 - "",
2755 - "",
2756 - "",
2757 - "",
2758 - "",
2759 - "",
2760 - "",
2761 - " FF HH",
2762 - " FFF HHHH",
2763 - " FFFFF HHHHH",
2764 - " FFFFF HHPPH",
2765 - " FFPPF HHPPH",
2766 - " FPPFF HHPPH",
2767 - " FPPPF HHPPH",
2768 - " FFPPF HHPPH",
2769 - " FPPF HHPPH",
2770 - " FPPF HPPH",
2771 - " FFPP HPPH",
2772 - " FPPFHPPH",
2773 - " FPPFHPPH",
2774 - " FFFFHHHH",
2775 - " HHHHHHHHH",
2776 - " HHHHHHHHHHH",
2777 - " H HHHHHHHHHHHHH",
2778 - " HHHHHHHHHHHHHHHHHHHHHHHHKKK",
2779 - " WWWHHHHHHHHHHHHHHHHHHHHHHHHH",
2780 - " WWWWWWHHHHHHHHHHHHHHHHHH GGG",
2781 - " WWWWWHHHHHHHHHHHHHHHHHHH GGGBGGG",
2782 - " HHHHHHHHHHHHHHHHHHHHH GGRGGGYGG",
2783 - " HHHHHHHHHHHHHHHHHHHHH GGGGGGGGG",
2784 - " HHHHHHHHHHHHHHHHHHHH GGGGGGG",
2785 - " HHHHHHHHHHHHHHHHH HHHHHHHH",
2786 - " HHHHHHHHHHHHH HHHHHHH",
2787 - " H",
2788 - "",
2789 - "",
2790 - "",
2791 - "",
2792 -];
2793 -const PAASHAAS_1 = [
2794 - "",
2795 - "",
2796 - "",
2797 - "",
2798 - "",
2799 - "",
2800 - "",
2801 - "",
2802 - " H",
2803 - " FF HH",
2804 - " FFFF HHHHH",
2805 - " FFFFF HHHHH",
2806 - " FFPPF HHPPH",
2807 - " FPPF HHPPH",
2808 - " FPPFF HHPPH",
2809 - " FFPPF HHPPH",
2810 - " FPPF HHPPH",
2811 - " FPPF HPPH",
2812 - " FPPF HPPH",
2813 - " FFPP HPPH",
2814 - " FPPFHPPH",
2815 - " FPPFHPPH",
2816 - " FFFFHHHH",
2817 - " HHHHHHHHHHH",
2818 - " HHHHHHHHKHHHH",
2819 - " HHHHHHHHHHH HHHHHHHHHHHP",
2820 - " HHHHHHHHHHHHHHHHHHHHHHHHHH",
2821 - " WWWWHHHHHHHHHHHHHHHHHHH H",
2822 - " WWWWWWWHHHHHHHHHHHHHHHHH HHHHGGGG",
2823 - " WWWHHHHHHHHHHHHHHHHHHHHHHHHGGHB GG",
2824 - " HHHHHHHHHHHHHHHHHHHHHHHHHGGGGGGGGG",
2825 - " HHHHHHHHHHHHHHHHHHH GGGGGGGGG",
2826 - " HHHHHHHHHHHHHHHHH GGGGG",
2827 - " HHHHHHH H",
2828 - " HHHHHHHH",
2829 - " HHHHHHH",
2830 - " HHH",
2831 - "",
2832 - "",
2833 - "",
2834 - "",
2835 - "",
2836 - "",
2837 - "",
2838 -];
2839 -const PAASHAAS_2 = [
2840 - "",
2841 - "",
2842 - "",
2843 - " FF HH",
2844 - " FFF HHHH",
2845 - " FFFFF HHHHH",
2846 - " FFFFF HHPPH",
2847 - " FFPPF HHPPH",
2848 - " FPPFF HHPPH",
2849 - " FPPPF HHPPH",
2850 - " FFPPF HHPPH",
2851 - " FPPF HHPPH",
2852 - " FPPF HPPH",
2853 - " FFPP HPPH",
2854 - " FPPFHPPH",
2855 - " FPPFHPPH",
2856 - " FFFFHHHH",
2857 - " HHHHHHHHH",
2858 - " HHHHHHHHHHH",
2859 - " H HHHHHHHHHHHHH",
2860 - " HHHHHHHHHHHHHHHHHHHHHHHHKKK",
2861 - " WWWHHHHHHHHHHHHHHHHHHHHHHHHH",
2862 - " WWWWWWHHHHHHHHHHHHHHHHHH GGG",
2863 - " WWWWWHHHHHHHHHHHHHHHHHHH GGGBGGG",
2864 - " HHHHHHHHHHHHHHHHHHHHH GGRGGGYGG",
2865 - " HHHHHHHHHHHHHHHHHHHHHHHHHGGGGGGGGG",
2866 - " HHHHHHHHHHHHHHHHHHHHHHHHHGGGGGGG",
2867 - " HHHHHHHHHHHHHHHHHHHH",
2868 - "HHHHHHHHHHH",
2869 - "HHHHH",
2870 - "",
2871 - "",
2872 - "",
2873 - "",
2874 - "",
2875 - "",
2876 - "",
2877 - "",
2878 - "",
2879 - "",
2880 - "",
2881 - "",
2882 - "",
2883 - "",
2884 -];
2885 -const PAASHAAS_3 = [
2886 - "",
2887 - "",
2888 - "",
2889 - "",
2890 - "",
2891 - "",
2892 - "",
2893 - "",
2894 - "",
2895 - " H",
2896 - " FF HH",
2897 - " FFFF HHHHH",
2898 - " FFFFF HHHHH",
2899 - " FFPPF HHPPH",
2900 - " FPPF HHPPH",
2901 - " FPPFF HHPPH",
2902 - " FFPPF HHPPH",
2903 - " FPPF HHPPH",
2904 - " FPPF HPPH",
2905 - " FPPF HPPH",
2906 - " FFPP HPPH",
2907 - " FPPFHPPH",
2908 - " FPPFHPPH",
2909 - " FFFFHHHH",
2910 - " HHHHHHHHHHH",
2911 - " HHHHHHHHKHHHH",
2912 - " HHHHHHHHHHH HHHHHHHHHHHP",
2913 - " HHHHHHHHHHHHHHHHHHHHHHHHHH",
2914 - " WWWWHHHHHHHHHHHHHHHHHHH H",
2915 - " WWWWWWWHHHHHHHHHHHHHHHHH GGGGG",
2916 - " WWWHHHHHHHHHHHHHHHHHHHH GG B GG",
2917 - " HHHHHHHHHHHHHHHHHHHHH GGGGGGGGG",
2918 - " HHHHHHHHHHHHHHHHHHH GGGGGGGGG",
2919 - " HHHHHHHHHHHHHHH GGGGG",
2920 - " HHHHHH H HHHHHHHHH",
2921 - " HHHH HHHHHHHHHHH",
2922 - " HHHH HHHHH",
2923 - " HHHHHH",
2924 - " HHH",
2925 - "",
2926 - "",
2927 - "",
2928 - "",
2929 - "",
2930 -];
2931 -const BOOTJE_0 = [
2932 - "",
2933 - " OOOOOOOOOOOK",
2934 - " RRRRRRRRRRRRK",
2935 - " RRRRRRRRRRRRKWW",
2936 - " WWWWWWWWWWWWKWWWWW",
2937 - " WWWWWWWWWWWWKWWWWWWW",
2938 - " BBBBBBBBBBBBKWWWWWWWWWW",
2939 - " KWWWWWWWWWWWWW",
2940 - " KWWWWWWWWWWWWWWW",
2941 - " KWWWWWWWWWWWWWWW",
2942 - " KWWWWWWWWWWWWWWWW",
2943 - " KWWWWWWWWWWWWWWWW",
2944 - " KWWWWWWWWWWWWWWWW",
2945 - " K",
2946 - " OOOOOOOOOOOOOOOOOOOOOOOOOOKOOOOOOOOOOOOOOOOOOOOOOOO",
2947 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO",
2948 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWO",
2949 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO",
2950 - " OOOOOWWWOOOOOWWWOOOOOWWWOOOOOWWWOOOOOOOOO",
2951 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO GG",
2952 - "G GGGGGGGG GGGGGGGG GGGGGGGG GGGGGGG",
2953 - "GGGGGGGG GGGGGGGG GGGGGGGG GGGGGGGG",
2954 - " GG GG GG GG",
2955 - "",
2956 -];
2957 -const BOOTJE_1 = [
2958 - "",
2959 - " OOOOOOOOOOOOOK",
2960 - " K",
2961 - " RRRRRRRRRRRRKWW",
2962 - " RRRRRRRRRRRRKWWWWW",
2963 - " WWWWWWWWWWWWKWWWWWWW",
2964 - " BBBBBBBBBBBBKWWWWWWWWWW",
2965 - " BBBBBBBBBBBBKWWWWWWWWWWWWW",
2966 - " KWWWWWWWWWWWWWWW",
2967 - " KWWWWWWWWWWWWWWW",
2968 - " KWWWWWWWWWWWWWWWW",
2969 - " KWWWWWWWWWWWWWWWW",
2970 - " KWWWWWWWWWWWWWWWW",
2971 - " K",
2972 - " OOOOOOOOOOOOOOOOOOOOOOOOOOKOOOOOOOOOOOOOOOOOOOOOOOO",
2973 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO",
2974 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWO",
2975 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO",
2976 - " OOOOOWWWOOOOOWWWOOOOOWWWOOOOOWWWOOOOOOOOO",
2977 - " GGOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO GG",
2978 - " GGGGGGGG GGGGGGGG GGGGGGGG GGGGGGGG",
2979 - "GGGGG GGGGGGGG GGGGGGGG GGGGGGGG GGG",
2980 - "GG GG GG GG",
2981 - "",
2982 -];
2983 -const KERSTBOOM = [
2984 - " Y",
2985 - " G",
2986 - " GGG",
2987 - " GGGGG",
2988 - " GGRGGGG",
2989 - " GGGGGGGGG",
2990 - " GGGGGGGRGGG",
2991 - " GGGGGGGGGGG",
2992 - " GGGGGGGGGGGGG",
2993 - " GGGGGGGGGGGGGGG",
2994 - " BBB",
2995 - " BBB",
2996 - " BBB",
2997 -];
2998 -const STER = [
2999 - "",
3000 - " Y",
3001 - " YYY",
3002 - " YYYYYYY",
3003 - " YYYYYYYYYYYYYYY",
3004 - " YYYYYYYYYYY",
3005 - " YYYYYYYYY",
3006 - " YYYY YYYY",
3007 - " YY YY",
3008 - "",
3009 -];
3010 -const EI = [
3011 - " EEE",
3012 - " EEEEEEE",
3013 - " EEEEEEEEE",
3014 - " DDDDDDDDD",
3015 - " EEEEEEEEE",
3016 - " EDEEDEEDE",
3017 - " EEEEE",
3018 -];
3019 -const KROON = [
3020 - " Y",
3021 - " Y",
3022 - " YY YYY Y",
3023 - " YYY YYYYY YY",
3024 - " YYYYYYYYYYYY",
3025 - " YYYRYYBYYYRY",
3026 - " OOOOOOOOOOOOO",
3027 - " OOOOOOOOOOOOO",
3028 -];
3029 -const LAMPION = [
3030 - " K",
3031 - " K",
3032 - " OOOOO",
3033 - " YOOOOOOOY",
3034 - " OOOOOOOOO",
3035 - " OOOOOOOOO",
3036 - " OOOOOOOOO",
3037 - " OOOOOOO",
3038 - " OOO",
3039 - " KKK",
3040 -];
3041 -const VUUR = [
3042 - " R",
3043 - " RRR",
3044 - " RRRRR",
3045 - " RRRORRR",
3046 - " RRROOORRR",
3047 - " RROOOORRR",
3048 - " RROOYOORR",
3049 - " RROYYYOR",
3050 - " ROYYYOR",
3051 - "",
3052 -];
3053 -const TULP = [
3054 - "",
3055 - " R R",
3056 - " R R",
3057 - " RRRRRR",
3058 - " RRRRRR",
3059 - " GGGGGGG",
3060 - " G GG GG",
3061 - "GG G GG",
3062 - " G",
3063 -];
3064 -const PAKJE = [
3065 - " GGGG GGGG",
3066 - " GGGGGGGGGGG",
3067 - " RRRGGGGGRRR",
3068 - " RRRRRGRRRRR",
3069 - " GGGGGGGGGGG",
3070 - " RRRRRGRRRRR",
3071 - " RRRRRGRRRRR",
3072 - " RRRRRGRRRRR",
3073 -];
3074 -const MIJTER = [
3075 - " R",
3076 - " RRR",
3077 - " RRGRR",
3078 - " RRGGGGGR",
3079 - " RRRRGRRR",
3080 - " RRRRGRRR",
3081 - "GRRRRRRRRGG",
3082 - "GGGGGGGGGGG",
3083 -];
3084 -
3085 -const GROTE_KROON = [
3086 - " YYY YYY YYY",
3087 - " YYY Y YYY",
3088 - " Y YYY Y",
3089 - " YYY YYY YYY YYY YYY",
3090 - " YYY YYYYY YYYYY YYYY YYY",
3091 - " YYY YYYYYY YYYYY YYYYYY YY",
3092 - " YYYYY YYYYYYYYYYYYYYYYYYYYY YYYY",
3093 - " YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY",
3094 - " YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY",
3095 - " YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY",
3096 - " OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO",
3097 - " OOORRROOORRROOOOBBBOOOORRROOORRROOO",
3098 - " OOORRROOORRROOOOBBBOOOORRROOORRROOO",
3099 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
3100 - " WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
3101 -];
3102 -
3103 -/* Een kristallen glas, rechtop en in drie standen gekanteld (naar rechts); het andere glas is het
3104 - gespiegelde. */
3105 -const GLAS_0 = [
3106 - "",
3107 - "",
3108 - "",
3109 - " CCCCCCCCCCCCCCCCCCCCCCCCC",
3110 - " CC L CC",
3111 - " CC L CC",
3112 - " CCYLYYYYYYYYYYYYYYYYYYYCC",
3113 - " CCYLYYYYYYYYYYYYYYYYYYYCC",
3114 - " CCLLYYYYYYYYYYBYYYYYYYYCC",
3115 - " CCLYYYYYYYYYYYYYYYYYYCC",
3116 - " CCLLYYYYBYYYYYYYYYYYYCC",
3117 - " CCCLYYYYYYYYYYYYYYYYCC",
3118 - " CCCYYYYYYYYYYYYYCCC",
3119 - " CCCYYYYYYYYYYYCCC",
3120 - " CCCCCCCCCCCCCC",
3121 - " CCC",
3122 - " CCC",
3123 - " CCC",
3124 - " CCC",
3125 - " CCC",
3126 - " CCC",
3127 - " CCC",
3128 - " CCC",
3129 - " CCC",
3130 - " CCC",
3131 - " CCC",
3132 - " CCC",
3133 - " CCCCCCCCCCCCCCCCCC",
3134 - " CCCCCCCCCCCCCCCCCCC",
3135 - "",
3136 -];
3137 -const GLAS_1 = [
3138 - "",
3139 - "",
3140 - " C",
3141 - " CCCCCCCCCCCCCCCC",
3142 - " CCLL CCCCCCCC",
3143 - " CCCL CC",
3144 - " CCLL CC",
3145 - " CCLLYYYYYYYYYYYYYYYYYYYCC",
3146 - " CCLYYYYYYYYYYYYYYYYYYYYCC",
3147 - " CCLLYYYYYYYYYYYYYYYYYYCC",
3148 - " CCYLYYYYBYYYYYYYYYYYYCC",
3149 - " CCYYYYYYYYYYYYYYYYYYCC",
3150 - " CCYYYYYYYYYYYBYYYCCC",
3151 - " CCCYYYYYYYYYYYCCC",
3152 - " CCCCCYYCCCCCC",
3153 - " CCCCC",
3154 - " CC",
3155 - " CCC",
3156 - " CC",
3157 - " CC",
3158 - " CC",
3159 - " CC",
3160 - " CC",
3161 - " CC",
3162 - " CCC",
3163 - " CC",
3164 - " CC",
3165 - " CCCCCCCCCC",
3166 - " CCCCCCCCCCCCCCCCCCCC",
3167 - " CCCCCC",
3168 -];
3169 -const GLAS_2 = [
3170 - "",
3171 - "",
3172 - " C",
3173 - " CCCCCCCCCC",
3174 - " CCCLL CCCCCCC",
3175 - " CCLL CCCCCCC",
3176 - " CC L CC",
3177 - " CCLLYYYYYYYYYYYYYYYYYYYCC",
3178 - " CCLYYYYYYYYYYYYYYYYYYYYCC",
3179 - " CCLYYYYYYYYYYBYYYYYYYYYCC",
3180 - " CCLYYYYBYYYYYYYYYYYYYYCC",
3181 - " CCCYYYYYYYYYYYYYYYYYYCC",
3182 - " CCYYYYYYYYYYYYYYYYYCC",
3183 - " CCCYYYBYYYYYYYYCCCC",
3184 - " CCCCYYYYYYYYCCCC",
3185 - " CCCCCCCCCC",
3186 - " CC",
3187 - " CC",
3188 - " CC",
3189 - " CC",
3190 - " CCC",
3191 - " CC",
3192 - " CCC",
3193 - " CC",
3194 - " CCC",
3195 - " CC",
3196 - " C CCC",
3197 - " CCCCCCCCCCC",
3198 - " CCCCCCCCCCCCC",
3199 - " CCCCCCCC",
3200 -];
3201 -const GLAS_3 = [
3202 - "",
3203 - "",
3204 - "",
3205 - " CCCCCC",
3206 - " CCCL CCCCCC",
3207 - " CCCLL CCCCCC",
3208 - " CCLL CCCCCC",
3209 - " CCLL CCC",
3210 - " CCLYYYYYYYYYYYYYYYYYYYYYCC",
3211 - " CCCLYYYYYYYYYYYYYYYYYYYYCC",
3212 - " CCLLYYYYYYYYYYBYYYYYYYYCC",
3213 - " CCCYYYYYYYYYYYYYYYYYYYCCC",
3214 - " CCYYYYYYYYYYYYYYYYYYCC",
3215 - " CCCYYYYYYYYYYYYYYYYCC",
3216 - " CCCCYYYYYYYYYYYCCCC",
3217 - " CCCCYYYYYYCCCC",
3218 - " CCCCCCCCC",
3219 - " CC",
3220 - " CCC",
3221 - " CCC",
3222 - " CCC",
3223 - " CC",
3224 - " CC",
3225 - " CCC",
3226 - " CCC",
3227 - " CCC",
3228 - " CCCCC CC",
3229 - " CCCCCCCCCC",
3230 - " CCCCCCCCCC",
3231 - " CCCCCCCC",
3232 -];
3233 -
3234 -const PASTEL = ["#f4a6c0", "#9fd3f2", "#f6d86b", "#a8dba8", "#c9a7eb"];
3235 -
3236 -/* De kalender. Volgorde telt: het eerste thema waarvan de periode klopt, geldt. Daarom staan de korte,
3237 - bijzondere dagen (4 en 5 mei, Koningsdag) vooraan. */
3238 -const SEASONS = Object.freeze([
3239 - {
3240 - id: "herdenking",
3241 - label: "Dodenherdenking",
3242 - klaar: true,
3243 - rule: { from: "05-04", to: "05-04" },
3244 - // Op 4 mei alleen het rustige veld en de vlag, halfstok; verder niets.
3245 - quiet: true,
3246 - decor: { kind: "vlag", halfstok: true },
3247 - },
3248 - {
3249 - id: "bevrijdingsdag",
3250 - label: "Bevrijdingsdag",
3251 - klaar: true,
3252 - rule: { from: "05-05", to: "05-05" },
3253 - accent: NL_RED,
3254 - decor: { kind: "vlag", halfstok: false, wimpel: true },
3255 - traverser: {
3256 - palette: { W: "text:0.75", O: "#e8920c", G: "#3f7d3a", K: "text:0.95" },
3257 - // Vleugelslag: op, half, neer, half.
3258 - frames: [DUIF_0, DUIF_1, DUIF_3, DUIF_2],
3259 - frameMs: 120,
3260 - wave: 22,
3261 - waveSeconds: 1.8,
3262 - crossSeconds: 9,
3263 - everySeconds: [20, 40],
3264 - firstSeconds: [2, 5],
3265 - },
3266 - // Een vlammetje van het bevrijdingsvuur bij een klik.
3267 - clickArt: { palette: { R: NL_RED, O: ORANGE, Y: YELLOW }, shapes: [VUUR], cellScale: 2 },
3268 - },
3269 - {
3270 - id: "koningsdag",
3271 - label: "Koningsdag",
3272 - klaar: true,
3273 - rule: { kingsDay: true },
3274 - accent: ORANGE,
3275 - // Een grote gouden kroon ergens op een vrije plek, die zacht glinstert.
3276 - decor: {
3277 - kind: "figuur",
3278 - art: GROTE_KROON,
3279 - palette: { Y: "#d4a017", O: ORANGE, R: NL_RED, B: NL_BLUE, W: "text:0.35", K: "text:0.8" },
3280 - glitter: true,
3281 - sparkle: { color: "#f6d86b", symbols: ["\u2726", "\u2727", "*"] },
3282 - },
3283 - traverser: {
3284 - palette: { O: ORANGE, W: "text:0.45", K: "text:0.8", R: NL_RED, B: NL_BLUE, G: "#3b82c4" },
3285 - frames: [BOOTJE_0, BOOTJE_1],
3286 - ground: true,
3287 - frameMs: 400,
3288 - bob: 3,
3289 - crossSeconds: 14,
3290 - everySeconds: [25, 45],
3291 - firstSeconds: [2, 5],
3292 - },
3293 - clickArt: {
3294 - palette: { Y: YELLOW, O: ORANGE, R: NL_RED, B: NL_BLUE, G: GREEN },
3295 - shapes: [KROON, TULP],
3296 - cellScale: 2,
3297 - },
3298 - },
3299 - {
3300 - id: "sintmaarten",
3301 - label: "Sint-Maarten",
3302 - klaar: true,
3303 - rule: { from: "11-11", to: "11-11" },
3304 - accent: ORANGE,
3305 - // Lampionnetjes die zachtjes omhoog zweven en een beetje gloeien.
3306 - decor: { kind: "lampions", count: 7, art: LAMPION, palette: { O: ORANGE, Y: YELLOW, K: "text:0.6" } },
3307 - clickArt: { palette: { O: ORANGE, Y: YELLOW, K: "text:0.6" }, shapes: [LAMPION], cellScale: 2 },
3308 - },
3309 - {
3310 - id: "sinterklaas",
3311 - label: "Sinterklaas",
3312 - klaar: true,
3313 - rule: { from: "11-24", to: "12-05" },
3314 - accent: RED,
3315 - traverser: {
3316 - palette: { R: RED, G: GOLD, S: "#e0a47a", W: "text:0.85", H: "text:0.55", F: "text:0.3", M: "text:0.82", K: "text:0.95", L: "text:0.3" },
3317 - frames: [SINT_0, SINT_1, SINT_2, SINT_3],
3318 - ground: true,
3319 - // Galop in vier standen; het geheel deint mee.
3320 - frameMs: 130,
3321 - bob: 5,
3322 - crossSeconds: 12,
3323 - everySeconds: [25, 45],
3324 - firstSeconds: [2, 5],
3325 - },
3326 - // Pepernotenbuien: af en toe spat links of rechts een handvol pepernoten uit elkaar.
3327 - decor: { kind: "buien", colors: ["#8b5a2b", "#6b4423", "#a06a32"], count: 60, everySeconds: [2, 4.5], firstSeconds: [1, 2] },
3328 - clickArt: { palette: { R: RED, G: GOLD }, shapes: [PAKJE, MIJTER], cellScale: 2 },
3329 - },
3330 - {
3331 - id: "kerst",
3332 - label: "Kerst",
3333 - klaar: true,
3334 - rule: { from: "12-06", to: "12-26" },
3335 - accent: RED,
3336 - traverser: {
3337 - palette: {
3338 - R: RED,
3339 - W: "text:0.85",
3340 - S: "#e0a47a",
3341 - K: "text:0.9",
3342 - G: GOLD,
3343 - B: BROWN,
3344 - N: "#ff2a2a",
3345 - D: "#b08455",
3346 - L: "text:0.3",
3347 - },
3348 - frames: [KERSTMAN_0, KERSTMAN_1, KERSTMAN_2, KERSTMAN_3],
3349 - // Een golvend spoor van sterren achter de slee.
3350 - trail: {
3351 - symbols: ["\u2605", "\u2726", "\u2727", "\u2736", "*", "\u00b7"],
3352 - colors: ["#e0b000", "#f6d86b", "text:0.7"],
3353 - perSecond: 40,
3354 - life: 2.4,
3355 - waveAmp: 30,
3356 - waveMs: 420,
3357 - spread: 16,
3358 - at: [0.03, 0.45],
3359 - },
3360 - frameMs: 150,
3361 - wave: 26,
3362 - waveSeconds: 2.6,
3363 - crossSeconds: 10,
3364 - everySeconds: [25, 45],
3365 - firstSeconds: [2, 5],
3366 - },
3367 - // Half echte sneeuwvlokjes, half letters uit het veld.
3368 - decor: { kind: "sneeuw", count: 90, symbolShare: 0.5 },
3369 - clickArt: { palette: { G: GREEN, B: BROWN, Y: YELLOW, R: RED }, shapes: [KERSTBOOM, STER], cellScale: 2 },
3370 - },
3371 - {
3372 - id: "nieuwjaar",
3373 - label: "Nieuwjaar",
3374 - klaar: true,
3375 - rule: { from: "12-27", to: "01-14" },
3376 - accent: GOLD,
3377 - // Af en toe vuurpijlen die opstijgen en openspatten; een klik geeft vuurwerk.
3378 - // Vaker vuurwerk, en af en toe een pijl die horizontaal over het scherm schiet en alsnog ontploft.
3379 - traverser: { kind: "vuurwerk", everySeconds: [2.5, 5], firstSeconds: [1, 2], sideways: 0.25 },
3380 - // Vijf snelle klikken: een vuurwerkshow en twee kristallen glazen die klinken.
3381 - fiveClicks: "proost",
3382 - proostArt: {
3383 - frames: [GLAS_0, GLAS_1, GLAS_2, GLAS_3],
3384 - palette: { C: "#6f9fc4", L: "#cfe6f5", Y: "#e0b000", B: "#fff3b0" },
3385 - },
3386 - clickArt: { burst: true },
3387 - },
3388 - {
3389 - id: "pasen",
3390 - label: "Pasen",
3391 - klaar: true,
3392 - rule: { easter: [-7, 8] },
3393 - accent: "#e879a6",
3394 - traverser: {
3395 - palette: { H: "text:0.6", F: "text:0.4", W: "text:0.3", P: "#f4a6c0", K: "text:0.95", G: "#b7884a", R: PASTEL[0], B: PASTEL[1], Y: PASTEL[2] },
3396 - // Huppelen in vier standen: zitten, afzetten, zweven, landen; de sprong zit in de tekening.
3397 - frames: [PAASHAAS_0, PAASHAAS_1, PAASHAAS_2, PAASHAAS_3],
3398 - ground: true,
3399 - frameMs: 150,
3400 - crossSeconds: 7,
3401 - everySeconds: [25, 45],
3402 - firstSeconds: [2, 5],
3403 - },
3404 - // Eieren verstopt in het veld; een klik op een ei laat het openspringen.
3405 - decor: { kind: "eieren", count: 6, art: EI, colors: PASTEL },
3406 - clickArt: { palette: { E: PASTEL[3], D: PASTEL[4] }, shapes: [EI], cellScale: 2 },
3407 - },
3408 -]);
3409 -
3410 -/* Het seizoen dat op een datum geldt, of null. override komt uit ?tekenveld=: een id toont dat thema
3411 - meteen (ook als het niet klaar is), "geen" zet de thema's uit; een onbekende id wordt genegeerd. */
3412 -function seasonFor(date, { override = null } = {}) {
3413 - if (override === "geen") return null;
3414 - if (override) {
3415 - const forced = SEASONS.find((season) => season.id === override);
3416 - if (forced) return forced;
3417 - }
3418 - return SEASONS.find((season) => season.klaar && inPeriod(date, season.rule)) || null;
3419 -}
3420 -
3421 -// Opstart van het tekenveld op de startpagina en de inlogpagina van de Kennisbank (<Code>.Code.Tekenveld).
3422 -// maak-tekenveld.js zet dit achter de engine en de seizoenskalender van Ember; samen vormen ze de
3423 -// JavaScriptExtension. De engine (createGlyphField, glyphFieldSettings) en de kalender (seasonFor)
3424 -// komen ongewijzigd uit Ember.
3425 -
3426 -// Waar een klik iets doet of waar inhoud staat: geen achtergrond, dus geen smiley.
3427 -const KB_NIET_ACHTERGROND = [
3428 - "a",
3429 - "button",
3430 - "input",
3431 - "select",
3432 - "textarea",
3433 - "label",
3434 - "form:not(#loginForm)",
3435 - "#loginForm .panel",
3436 - "summary",
3437 - "[role='button']",
3438 - "[role='dialog']",
3439 - ".navbar",
3440 - "#leftPanels",
3441 - "#rightPanels",
3442 - ".document-header",
3443 - ".wb-zoek > *",
3444 - ".wb-tegels li",
3445 - ".wb-home-onder",
3446 -].join(",");
3447 -
3448 -// De blokken van de startpagina; de hartslaglijn, de figuren, de vlag en de kroon kiezen bij voorkeur een
3449 -// plek waar die niet staan. De knoppen voor redacteuren (Wijzigen, Toevoegen) horen erbij, op de inlogpagina het inlogvak.
3450 -const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar, .document-header .document-menu, .document-header .btn, #loginForm .panel";
3451 -
3452 -// Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en
3453 -// beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer.
3454 -const KB_STILSTAND_MS = 60000;
3455 -
3456 -// De explosie van de engine: vijf klikken op de lege achtergrond binnen 1,6 seconde, met een
3457 -// nasleep van een minuut. Dezelfde getallen als de standaard van de engine; hier alleen om
3458 -// tijdens de explosie het vervagen naar het midden weg te halen en het veld wakker te houden.
3459 -const KB_BOEM_KLIKKEN = 5;
3460 -const KB_BOEM_VENSTER_MS = 1600;
3461 -const KB_BOEM_DUUR_MS = 62000;
3462 -
3463 -function kbTekenveld() {
3464 - // Alleen op de startpagina (twee laadregels) en de inlogpagina (de lader TekenveldInloggen); nooit twee keer.
3465 - if (!document.querySelector(".wb-home, #loginForm") || document.querySelector("canvas.kb-tekenveld")) return;
3466 -
3467 - // De Kennisbank heeft alleen een lichte stand; de engine leest die van <html>.
3468 - document.documentElement.setAttribute("data-appearance", "light");
3469 -
3470 - const canvas = document.createElement("canvas");
3471 - canvas.className = "kb-tekenveld";
3472 - canvas.setAttribute("aria-hidden", "true");
3473 - document.body.prepend(canvas);
3474 -
3475 - const isAchtergrond = (event) => event.target instanceof Element && !event.target.closest(KB_NIET_ACHTERGROND);
3476 - const opties = {
3477 - ...glyphFieldSettings({ intensity: "lijst" }),
3478 - isBackground: isAchtergrond,
3479 - obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()),
3480 - };
3481 -
3482 - /* Het seizoen van vandaag uit de kalender van Ember (Sinterklaas, Kerst, Nieuwjaar, Pasen, Koningsdag,
3483 - 4 en 5 mei, Sint-Maarten); met ?tekenveld=<id> is een thema meteen te zien. Het decor begint onder
3484 - de kopbalk van de Kennisbank. */
3485 - function seizoenNu() {
3486 - const override = new URLSearchParams(window.location.search).get("tekenveld");
3487 - return seasonFor(new Date(), { override });
3488 - }
3489 - function kopbalkOnder() {
3490 - const balk = document.querySelector(".navbar");
3491 - return balk ? Math.round(balk.getBoundingClientRect().bottom) : 0;
3492 - }
3493 -
3494 - let veld = null;
3495 - let rustTimer = 0;
3496 - let klikken = [];
3497 - let boemTot = 0;
3498 -
3499 - function slaap() {
3500 - // Tijdens een explosie niet: de nasleep duurt een minuut, ook zonder muis.
3501 - if (Date.now() < boemTot) {
3502 - rustTimer = window.setTimeout(slaap, boemTot - Date.now());
3503 - return;
3504 - }
3505 - // Het laatste beeld blijft staan; alleen de animatie stopt.
3506 - veld.destroy();
3507 - veld = null;
3508 - }
3509 -
3510 - // De klik die een slapend veld wekt, ziet de engine zelf niet (hij luistert pas daarna);
3511 - // die telt hier dus ook niet mee, anders lopen de twee tellers uiteen.
3512 - let wekklik = null;
3513 -
3514 - function wakker(event) {
3515 - if (!veld) {
3516 - veld = createGlyphField(canvas, opties);
3517 - veld.configure({ season: seizoenNu(), decorTop: kopbalkOnder() });
3518 - wekklik = event || null;
3519 - }
3520 - window.clearTimeout(rustTimer);
3521 - rustTimer = window.setTimeout(slaap, KB_STILSTAND_MS);
3522 - }
3523 -
3524 - function telKlik(event) {
3525 - const nu = Date.now();
3526 - if (event === wekklik || event.button !== 0 || nu < boemTot || !isAchtergrond(event)) return;
3527 - // Tijdens een seizoen is er geen explosie (vijf klikken doen dan iets van het seizoen zelf, of niets).
3528 - if (seizoenNu()) return;
3529 - klikken = klikken.filter((tijd) => nu - tijd < KB_BOEM_VENSTER_MS);
3530 - klikken.push(nu);
3531 - if (klikken.length < KB_BOEM_KLIKKEN) return;
3532 - klikken = [];
3533 - boemTot = nu + KB_BOEM_DUUR_MS;
3534 - // Tijdens de explosie het hele scherm, niet alleen de randen.
3535 - canvas.classList.add("kb-tekenveld--boem");
3536 - window.setTimeout(() => canvas.classList.remove("kb-tekenveld--boem"), KB_BOEM_DUUR_MS);
3537 - }
3538 -
3539 - window.addEventListener("pointermove", wakker, { passive: true });
3540 - // Eerst wakker maken, zodat de engine de klik ook krijgt als het veld sliep.
3541 - window.addEventListener("pointerdown", wakker, { passive: true });
3542 - window.addEventListener("pointerdown", telKlik, { passive: true });
3543 - wakker();
3544 -}
3545 -
3546 -if (document.readyState === "loading") {
3547 - document.addEventListener("DOMContentLoaded", kbTekenveld);
3548 -} else {
3549 - kbTekenveld();
3550 -}
3551 -
3552 -})();
3553 -
name
... ... @@ -1,1 +1,0 @@
1 -Wardenburg tekenveld
parse
... ... @@ -1,1 +1,0 @@
1 -Nee
use
... ... @@ -1,1 +1,0 @@
1 -onDemand
XWiki.StyleSheetExtension[0]
Caching policy
... ... @@ -1,1 +1,0 @@
1 -long
Code
... ... @@ -1,54 +1,0 @@
1 -/* Tekenveld op de startpagina en de inlogpagina van de Kennisbank (<Code>.Code.Tekenveld); alleen daar
2 - geladen. 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 - de hartslaglijn in het rood van het thema (brand-danger). */
6 -html {
7 - --text: #202124;
8 - --brand-red: #e8920c;
9 - --glyph-line: #d0021b;
10 - background: #ffffff;
11 -}
12 -
13 -body,
14 -#body,
15 -#contentcontainer,
16 -#contentcolumn,
17 -#mainContentArea,
18 -.main,
19 -#xwikicontent,
20 -.wb-home {
21 - background: transparent !important;
22 -}
23 -
24 -canvas.kb-tekenveld {
25 - position: fixed;
26 - inset: 0;
27 - z-index: -1;
28 - pointer-events: none;
29 - /* Naar het midden, waar het zoekveld en de tegels staan, vervaagt het veld tot 40%: het zwakke
30 - grijze veld is daar vrijwel weg, de rode hartslaglijn komt er nog doorheen. */
31 - -webkit-mask-image: radial-gradient(ellipse 48% 58% at 50% 55%, rgba(0, 0, 0, .4) 45%, #000 100%);
32 - mask-image: radial-gradient(ellipse 48% 58% at 50% 55%, rgba(0, 0, 0, .4) 45%, #000 100%);
33 -}
34 -
35 -/* Tijdens de explosie (vijf snelle klikken) het hele scherm. */
36 -canvas.kb-tekenveld.kb-tekenveld--boem {
37 - -webkit-mask-image: none;
38 - mask-image: none;
39 -}
40 -
41 -/* Leesbaarheid: een zachte witte gloed achter de kop, het zoekveld en elke tegel. */
42 -.wb-zoek > h1,
43 -.wb-zoek > p,
44 -.wb-zoek > form,
45 -.wb-tegels > li > a {
46 - background: radial-gradient(closest-side, rgba(255, 255, 255, .97) 62%, rgba(255, 255, 255, 0) 100%);
47 -}
48 -
49 -.wb-home-onder {
50 - background: #ffffff;
51 - border-radius: 16px;
52 - box-shadow: 0 0 36px 28px #ffffff;
53 -}
54 -
Content Type
... ... @@ -1,1 +1,0 @@
1 -CSS
Naam
... ... @@ -1,1 +1,0 @@
1 -Wardenburg tekenveld
Inhoud verwerken
... ... @@ -1,1 +1,0 @@
1 -Nee
Gebruik deze uitbreiding
... ... @@ -1,1 +1,0 @@
1 -onDemand
XWiki.XWikiRights[0]
allow
... ... @@ -1,1 +1,0 @@
1 -Sta toe
levels
... ... @@ -1,1 +1,0 @@
1 -view
users
... ... @@ -1,1 +1,0 @@
1 -XWiki.XWikiGuest
XWiki.XWikiRights[1]
allow
... ... @@ -1,1 +1,0 @@
1 -Sta toe
groups
... ... @@ -1,1 +1,0 @@
1 -XWiki.XWikiAllGroup
levels
... ... @@ -1,1 +1,0 @@
1 -view
XWiki.XWikiRights[2]
allow
... ... @@ -1,1 +1,0 @@
1 -Sta toe
groups
... ... @@ -1,1 +1,0 @@
1 -XWiki.XWikiAdminGroup
levels
... ... @@ -1,1 +1,0 @@
1 -view,edit,comment,script