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