Wijzigen voor document Tekenveld

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

Van versie 10.1
gewijzigd door Jeeves de Butler
op 20-10-2026 10:41
Opmerking bij wijziging: Er is geen opmerking bij deze versie
Naar versie 11.1
gewijzigd door Jeeves de Butler
op 20-10-2026 10:41
Opmerking bij wijziging: Er is geen opmerking bij deze versie

Samenvatting

Details

XWiki.JavaScriptExtension[0]
code
... ... @@ -1,5 +1,6 @@
1 1  /* Tekenveld voor de Kennisbank. Gemaakt met maak-tekenveld.js uit de skill xwiki-wardenburg-admin.
2 - Engine: Ember src/lib/glyphField.js, commit 83ea536. Niet hier wijzigen: pas de bron aan en maak opnieuw. */
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. */
3 3  (function () {
4 4  "use strict";
5 5  // /src/lib/glyphField.js
... ... @@ -83,6 +83,10 @@
83 83   // Bepaalt of een klik op de achtergrond viel (en dus een smiley geeft); zonder deze
84 84   // functie telt elke klik.
85 85   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,
86 86   // Geeft de rechthoeken (getBoundingClientRect) van de inhoud; de hartslaglijn kiest bij
87 87   // voorkeur een strook waar die niet staan.
88 88   obstacles: null,
... ... @@ -111,7 +111,9 @@
111 111   streakAccent: { light: 1, dark: 1 },
112 112   // Smileys: een klik op de achtergrond laat er een opspringen; spammen mag.
113 113   smileyRadius: 145,
114 - smileySeconds: 1.5,
119 + // Een smiley of klikvorm zweeft smileyRise px per seconde omhoog en blijft smileySeconds staan.
120 + smileySeconds: 3,
121 + smileyRise: 55,
115 115   smileyMax: 40,
116 116   // Variatie in de hartslaglijn; een op de zes lijnen is wild.
117 117   ecgWildChance: 0.17,
... ... @@ -199,10 +199,19 @@
199 199   return y > 0.12 && Math.abs(Math.hypot(x, y + 0.05) - 0.56) < 0.09;
200 200  }
201 201  
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 +
202 202  function inHeartEye(x, y, cx, cy) {
203 - const dx = (x - cx) / 0.2;
204 - const dy = (y - cy) / 0.2;
205 - return Math.hypot(dx, dy) < heartRadius(dx, dy);
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;
206 206  }
207 207  
208 208  const SMILEYS = [
... ... @@ -224,7 +224,7 @@
224 224   (Math.abs(y - 0.28) < 0.08 && Math.abs(x) < 0.42) ||
225 225   (y > 0.28 && inDot(x, y, 0.14, 0.46, 0.21)),
226 226   // verliefd
227 - (x, y) => inHeartEye(x, y, -0.33, -0.26) || inHeartEye(x, y, 0.33, -0.26) || inSmile(x, y),
243 + (x, y) => inHeartEye(x, y, -0.36, -0.16) || inHeartEye(x, y, 0.36, -0.16) || (y > 0.24 && inSmile(x, y)),
228 228   // verbaasd
229 229   (x, y) =>
230 230   inDot(x, y, -0.33, -0.3, 0.13) ||
... ... @@ -232,6 +232,8 @@
232 232   Math.abs(Math.hypot(x, y - 0.4) - 0.19) < 0.08,
233 233  ];
234 234  
251 +const SMILEY_VERLIEFD = 4;
252 +
235 235  function smileyAt(kind, x, y) {
236 236   const r = Math.hypot(x, y);
237 237   // De rand is minstens ƩƩn rasterregel dik, anders valt hij onderaan tussen de regels door.
... ... @@ -253,6 +253,16 @@
253 253   return value < 0 ? 0 : value > 1 ? 1 : value;
254 254  }
255 255  
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 +
256 256  function readTheme() {
257 257   const root = document.documentElement;
258 258   const styles = getComputedStyle(root);
... ... @@ -290,6 +290,20 @@
290 290   const options = { ...DEFAULTS, ...overrides };
291 291   let glyphSet = checkGlyphs(options.glyphs);
292 292   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 + }
293 293   const ctx = canvas.getContext("2d");
294 294   const layer = document.createElement("canvas");
295 295   const layerCtx = layer.getContext("2d");
... ... @@ -334,8 +334,15 @@
334 334   let smileys = [];
335 335   let calm = 1;
336 336   let nuke = null;
379 + // Proosten op Nieuwjaar (vijf snelle klikken): vuurwerkshow en twee klinkende glazen.
380 + let proost = null;
337 337   let clickTimes = [];
338 338   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();
339 339   let nextEcgAt = performance.now() + randomBetween(options.ecgFirstSeconds) * 1000;
340 340   let frameId = 0;
341 341   let lastFrame = 0;
... ... @@ -343,9 +343,10 @@
343 343   let running = false;
344 344   let destroyed = false;
345 345  
346 - function buildAtlas(color) {
395 + // set: de tekens in de atlas; standaard die van het veld, voor symbolen (sneeuwvlokken) een eigen lijst.
396 + function buildAtlas(color, set = glyphSet) {
347 347   const atlas = document.createElement("canvas");
348 - atlas.width = tileW * glyphSet.length;
398 + atlas.width = tileW * set.length;
349 349   atlas.height = tileH;
350 350   const a = atlas.getContext("2d");
351 351   a.font = `${options.fontSize * dpr}px ${options.fontFamily}`;
... ... @@ -352,8 +352,8 @@
352 352   a.textBaseline = "middle";
353 353   a.textAlign = "center";
354 354   a.fillStyle = color;
355 - for (let i = 0; i < glyphSet.length; i += 1) {
356 - a.fillText(glyphSet[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5);
405 + for (let i = 0; i < set.length; i += 1) {
406 + a.fillText(set[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5);
357 357   }
358 358   return atlas;
359 359   }
... ... @@ -360,8 +360,13 @@
360 360  
361 361   function buildAtlases() {
362 362   neutralAtlas = buildAtlas(theme.text);
363 - accentAtlas = buildAtlas(theme.accent);
364 - lineAtlas = theme.line === theme.accent ? accentAtlas : buildAtlas(theme.line);
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();
365 365   // Vuurkleuren voor de explosie.
366 366   yellowAtlas = buildAtlas(theme.mode === "light" ? "#eab308" : "#facc15");
367 367   orangeAtlas = buildAtlas("#f97316");
... ... @@ -399,6 +399,8 @@
399 399   maxC = maxR = 0;
400 400  
401 401   buildAtlases();
457 + // Sneeuw, lampions en eieren opnieuw verdelen over de nieuwe breedte.
458 + resetSeasonFx();
402 402   lastAmbient = -Infinity;
403 403   render(performance.now(), 0);
404 404   }
... ... @@ -567,15 +567,17 @@
567 567   /* Hoogte voor de hartslaglijn. Een lichte voorkeur voor stroken waar weinig inhoud staat:
568 568   uit vijf willekeurige hoogtes wint meestal de vrijste, maar een op de vier keer gewoon
569 569   een willekeurige, zodat de lijn overal kan verschijnen. */
570 - function pickLineY(height) {
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) {
571 571   const candidates = [];
572 - for (let i = 0; i < 5; i += 1) candidates.push(height * (0.15 + Math.random() * 0.75));
631 + for (let i = 0; i < 9; i += 1) candidates.push(height * (0.15 + Math.random() * 0.8));
573 573   const rects = options.obstacles ? options.obstacles() : [];
574 - if (!rects.length || Math.random() < 0.25) return candidates[0];
633 + if (!rects.length || Math.random() < randomShare) return candidates[0];
575 575  
576 576   const width = window.innerWidth;
577 - const top = options.ecgAmplitude;
578 - const bottom = options.ecgAmplitude * 0.4;
636 + const top = above;
637 + const bottom = below;
579 579   let best = candidates[0];
580 580   let bestCover = Infinity;
581 581   for (const y of candidates) {
... ... @@ -602,6 +602,17 @@
602 602  
603 603   function heartLine(now) {
604 604   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 + }
605 605   if (!ecg) {
606 606   if (now < nextEcgAt) return;
607 607   const height = window.innerHeight;
... ... @@ -646,10 +646,24 @@
646 646   }
647 647  
648 648   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 + }
649 649   let kind = (Math.random() * SMILEYS.length) | 0;
650 650   // Niet twee keer achter elkaar dezelfde.
651 651   if (smileys.length && smileys[smileys.length - 1].kind === kind) kind = (kind + 1) % SMILEYS.length;
652 - smileys.push({ x, y, kind, start: performance.now(), tilt: (Math.random() - 0.5) * 0.5 });
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 });
653 653   if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax);
654 654   }
655 655  
... ... @@ -668,9 +668,13 @@
668 668   const pop = Math.min(age / 0.22, 1);
669 669   const scale = pop < 1 ? 0.3 + 0.9 * Math.sin((pop * Math.PI) / 2) + 0.1 * Math.sin(pop * Math.PI) : 1;
670 670   const fade = Math.min(1, (options.smileySeconds - age) / 0.45);
671 - const radius = options.smileyRadius * scale;
672 672   const cx = smiley.x;
673 - const cy = smiley.y - age * 40;
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);
674 674   const cos = Math.cos(smiley.tilt);
675 675   const sin = Math.sin(smiley.tilt);
676 676   // Stille cirkel: binnen 1,1 keer de straal helemaal stil, tot 1,5 keer zacht aflopend.
... ... @@ -694,7 +694,12 @@
694 694   const still = fade * (d2 < 1.21 ? 1 : (2.25 - d2) / 1.04);
695 695   if (still > quiet[index]) quiet[index] = still;
696 696   }
697 - if (!smileyAt(smiley.kind, x, y)) continue;
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 + }
698 698   if (fade > streak[index]) streak[index] = fade;
699 699   }
700 700   }
... ... @@ -706,6 +706,14 @@
706 706   function onPointerDown(event) {
707 707   if (reducedMotion.matches || event.button !== 0) return;
708 708   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 + }
709 709   if (nuke) {
710 710   // In de nasleep geen nieuwe explosie; na nukeSmileyAfter mogen de smileys wel weer.
711 711   if (nukeAge(performance.now()) >= options.nukeSmileyAfter) spawnSmiley(event.clientX, event.clientY);
... ... @@ -715,8 +715,17 @@
715 715   const now = performance.now();
716 716   clickTimes = clickTimes.filter((time) => now - time < options.nukeClickWindow * 1000);
717 717   clickTimes.push(now);
718 - if (clickTimes.length >= options.nukeClicks) {
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) {
719 719   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 = [];
720 720   startNuke(now, event.clientX, event.clientY);
721 721   return;
722 722   }
... ... @@ -742,6 +742,9 @@
742 742  
743 743   function heartbeat(now) {
744 744   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;
745 745   if (heartHome) {
746 746   const sinceBeat = now - lastBeatAt;
747 747   const moved = lastBeatSpot
... ... @@ -948,6 +948,897 @@
948 948   nuke.puffs.length = keep;
949 949   }
950 950  
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 + }
951 951   // 1 als het veld er gewoon staat; 0 tijdens de nasleep van een explosie.
952 952   function fieldPresence(now) {
953 953   const age = nukeAge(now);
... ... @@ -1138,6 +1138,7 @@
1138 1138   ctx.drawImage(layer, 0, 0);
1139 1139   ctx.globalAlpha = 1;
1140 1140   }
2145 + drawDecor(now);
1141 1141   if (dt > 0) {
1142 1142   heartbeat(now);
1143 1143   heartLine(now);
... ... @@ -1144,6 +1144,11 @@
1144 1144   drawSmileys(now);
1145 1145   }
1146 1146   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);
1147 1147   if (nuke) drawNuke(now, dt);
1148 1148   }
1149 1149  
... ... @@ -1150,6 +1150,9 @@
1150 1150   function loop(now) {
1151 1151   if (!running) return;
1152 1152   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;
1153 1153   if (pointer && nukeAge(now) >= options.nukeHeartAfter) paintPointer();
1154 1154   else if (nukeAge(now) < options.nukeHeartAfter) pointer = null;
1155 1155   // Dertig beelden per seconde is genoeg voor wisselende tekens; dat halveert het werk
... ... @@ -1241,11 +1241,21 @@
1241 1241   function configure(next = {}) {
1242 1242   if (destroyed) return;
1243 1243   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 + }
1244 1244   if (next.glyphs) {
1245 1245   const nextSet = checkGlyphs(next.glyphs);
1246 1246   const changed = nextSet.length !== glyphSet.length || nextSet.some((glyph, index) => glyph !== glyphSet[index]);
1247 1247   if (changed) {
1248 1248   glyphSet = nextSet;
2271 + refreshDense();
1249 1249   for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph();
1250 1250   buildAtlases();
1251 1251   }
... ... @@ -1274,9 +1274,1131 @@
1274 1274   }
1275 1275  }
1276 1276  
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 +
1277 1277  // Opstart van het tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld).
1278 -// maak-tekenveld.js zet dit achter de engine van Ember; samen vormen ze de JavaScriptExtension.
1279 -// De engine zelf (createGlyphField, glyphFieldSettings) komt ongewijzigd uit Ember.
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.
1280 1280  
1281 1281  // Waar een klik iets doet of waar inhoud staat: geen achtergrond, dus geen smiley.
1282 1282  const KB_NIET_ACHTERGROND = [
... ... @@ -1299,8 +1299,9 @@
1299 1299   ".wb-home-onder",
1300 1300  ].join(",");
1301 1301  
1302 -// De blokken van de startpagina; de hartslaglijn loopt bij voorkeur waar die niet staan.
1303 -const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar";
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";
1304 1304  
1305 1305  // Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en
1306 1306  // beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer.
... ... @@ -1332,6 +1332,18 @@
1332 1332   obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()),
1333 1333   };
1334 1334  
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 +
1335 1335   let veld = null;
1336 1336   let rustTimer = 0;
1337 1337   let klikken = [];
... ... @@ -1355,6 +1355,7 @@
1355 1355   function wakker(event) {
1356 1356   if (!veld) {
1357 1357   veld = createGlyphField(canvas, opties);
3516 + veld.configure({ season: seizoenNu(), decorTop: kopbalkOnder() });
1358 1358   wekklik = event || null;
1359 1359   }
1360 1360   window.clearTimeout(rustTimer);
... ... @@ -1364,6 +1364,8 @@
1364 1364   function telKlik(event) {
1365 1365   const nu = Date.now();
1366 1366   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;
1367 1367   klikken = klikken.filter((tijd) => nu - tijd < KB_BOEM_VENSTER_MS);
1368 1368   klikken.push(nu);
1369 1369   if (klikken.length < KB_BOEM_KLIKKEN) return;