Wijzigen voor document Tekenveld

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

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

Samenvatting

Details

Paginaeigenschappen
Inhoud
... ... @@ -1,6 +1,6 @@
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.
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.
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]].
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]].
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: de twee regels met //Wardenburg.Code.Tekenveld// van de startpagina weghalen. 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: 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]].
6 6  
XWiki.JavaScriptExtension[0]
cache
... ... @@ -1,0 +1,1 @@
1 +long
code
... ... @@ -1,0 +1,3552 @@
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 c42b6fd.
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 van de Kennisbank (Wardenburg.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",
3435 + "summary",
3436 + "[role='button']",
3437 + "[role='dialog']",
3438 + ".navbar",
3439 + "#leftPanels",
3440 + "#rightPanels",
3441 + ".document-header",
3442 + ".wb-zoek > *",
3443 + ".wb-tegels li",
3444 + ".wb-home-onder",
3445 +].join(",");
3446 +
3447 +// De blokken van de startpagina; de hartslaglijn, de figuren, de vlag en de kroon kiezen bij voorkeur een
3448 +// plek waar die niet staan. De knoppen voor redacteuren (Wijzigen, Toevoegen) horen erbij.
3449 +const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar, .document-header .document-menu, .document-header .btn";
3450 +
3451 +// Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en
3452 +// beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer.
3453 +const KB_STILSTAND_MS = 60000;
3454 +
3455 +// De explosie van de engine: vijf klikken op de lege achtergrond binnen 1,6 seconde, met een
3456 +// nasleep van een minuut. Dezelfde getallen als de standaard van de engine; hier alleen om
3457 +// tijdens de explosie het vervagen naar het midden weg te halen en het veld wakker te houden.
3458 +const KB_BOEM_KLIKKEN = 5;
3459 +const KB_BOEM_VENSTER_MS = 1600;
3460 +const KB_BOEM_DUUR_MS = 62000;
3461 +
3462 +function kbTekenveld() {
3463 + // Alleen op de startpagina; de extensie wordt ook alleen daar geladen.
3464 + if (!document.querySelector(".wb-home")) return;
3465 +
3466 + // De Kennisbank heeft alleen een lichte stand; de engine leest die van <html>.
3467 + document.documentElement.setAttribute("data-appearance", "light");
3468 +
3469 + const canvas = document.createElement("canvas");
3470 + canvas.className = "kb-tekenveld";
3471 + canvas.setAttribute("aria-hidden", "true");
3472 + document.body.prepend(canvas);
3473 +
3474 + const isAchtergrond = (event) => event.target instanceof Element && !event.target.closest(KB_NIET_ACHTERGROND);
3475 + const opties = {
3476 + ...glyphFieldSettings({ intensity: "lijst" }),
3477 + isBackground: isAchtergrond,
3478 + obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()),
3479 + };
3480 +
3481 + /* Het seizoen van vandaag uit de kalender van Ember (Sinterklaas, Kerst, Nieuwjaar, Pasen, Koningsdag,
3482 + 4 en 5 mei, Sint-Maarten); met ?tekenveld=<id> is een thema meteen te zien. Het decor begint onder
3483 + de kopbalk van de Kennisbank. */
3484 + function seizoenNu() {
3485 + const override = new URLSearchParams(window.location.search).get("tekenveld");
3486 + return seasonFor(new Date(), { override });
3487 + }
3488 + function kopbalkOnder() {
3489 + const balk = document.querySelector(".navbar");
3490 + return balk ? Math.round(balk.getBoundingClientRect().bottom) : 0;
3491 + }
3492 +
3493 + let veld = null;
3494 + let rustTimer = 0;
3495 + let klikken = [];
3496 + let boemTot = 0;
3497 +
3498 + function slaap() {
3499 + // Tijdens een explosie niet: de nasleep duurt een minuut, ook zonder muis.
3500 + if (Date.now() < boemTot) {
3501 + rustTimer = window.setTimeout(slaap, boemTot - Date.now());
3502 + return;
3503 + }
3504 + // Het laatste beeld blijft staan; alleen de animatie stopt.
3505 + veld.destroy();
3506 + veld = null;
3507 + }
3508 +
3509 + // De klik die een slapend veld wekt, ziet de engine zelf niet (hij luistert pas daarna);
3510 + // die telt hier dus ook niet mee, anders lopen de twee tellers uiteen.
3511 + let wekklik = null;
3512 +
3513 + function wakker(event) {
3514 + if (!veld) {
3515 + veld = createGlyphField(canvas, opties);
3516 + veld.configure({ season: seizoenNu(), decorTop: kopbalkOnder() });
3517 + wekklik = event || null;
3518 + }
3519 + window.clearTimeout(rustTimer);
3520 + rustTimer = window.setTimeout(slaap, KB_STILSTAND_MS);
3521 + }
3522 +
3523 + function telKlik(event) {
3524 + const nu = Date.now();
3525 + if (event === wekklik || event.button !== 0 || nu < boemTot || !isAchtergrond(event)) return;
3526 + // Tijdens een seizoen is er geen explosie (vijf klikken doen dan iets van het seizoen zelf, of niets).
3527 + if (seizoenNu()) return;
3528 + klikken = klikken.filter((tijd) => nu - tijd < KB_BOEM_VENSTER_MS);
3529 + klikken.push(nu);
3530 + if (klikken.length < KB_BOEM_KLIKKEN) return;
3531 + klikken = [];
3532 + boemTot = nu + KB_BOEM_DUUR_MS;
3533 + // Tijdens de explosie het hele scherm, niet alleen de randen.
3534 + canvas.classList.add("kb-tekenveld--boem");
3535 + window.setTimeout(() => canvas.classList.remove("kb-tekenveld--boem"), KB_BOEM_DUUR_MS);
3536 + }
3537 +
3538 + window.addEventListener("pointermove", wakker, { passive: true });
3539 + // Eerst wakker maken, zodat de engine de klik ook krijgt als het veld sliep.
3540 + window.addEventListener("pointerdown", wakker, { passive: true });
3541 + window.addEventListener("pointerdown", telKlik, { passive: true });
3542 + wakker();
3543 +}
3544 +
3545 +if (document.readyState === "loading") {
3546 + document.addEventListener("DOMContentLoaded", kbTekenveld);
3547 +} else {
3548 + kbTekenveld();
3549 +}
3550 +
3551 +})();
3552 +
name
... ... @@ -1,0 +1,1 @@
1 +Wardenburg tekenveld
parse
... ... @@ -1,0 +1,1 @@
1 +Nee
use
... ... @@ -1,0 +1,1 @@
1 +onDemand
XWiki.StyleSheetExtension[0]
Caching policy
... ... @@ -1,0 +1,1 @@
1 +long
Code
... ... @@ -1,0 +1,54 @@
1 +/* Tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld); alleen geladen op
2 + de startpagina. Het canvas ligt achter de inhoud; de vlakken eromheen worden doorzichtig. */
3 +
4 +/* Kleuren voor de engine: de tekstkleur en het donkere oranje (brand-warning) uit het kleurthema;
5 + 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,0 +1,1 @@
1 +CSS
Naam
... ... @@ -1,0 +1,1 @@
1 +Wardenburg tekenveld
Inhoud verwerken
... ... @@ -1,0 +1,1 @@
1 +Nee
Gebruik deze uitbreiding
... ... @@ -1,0 +1,1 @@
1 +onDemand
XWiki.XWikiRights[0]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
levels
... ... @@ -1,0 +1,1 @@
1 +view
users
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiGuest
XWiki.XWikiRights[1]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
groups
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiAllGroup
levels
... ... @@ -1,0 +1,1 @@
1 +view
XWiki.XWikiRights[2]
allow
... ... @@ -1,0 +1,1 @@
1 +Sta toe
groups
... ... @@ -1,0 +1,1 @@
1 +XWiki.XWikiAdminGroup
levels
... ... @@ -1,0 +1,1 @@
1 +view,edit,comment,script