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 2.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
Samenvatting
-
Objecten (0 gewijzigd, 1 toegevoegd, 0 verwijderd)
Details
- XWiki.JavaScriptExtension[0]
-
- cache
-
... ... @@ -1,0 +1,1 @@ 1 +long - code
-
... ... @@ -1,0 +1,1335 @@ 1 +/* Tekenveld voor de Kennisbank. Gemaakt met maak-tekenveld.js uit de skill xwiki-wardenburg-admin. 2 + Engine: Ember src/lib/glyphField.js, commit 2af047d. Niet hier wijzigen: pas de bron aan en maak opnieuw. */ 3 +(function () { 4 +"use strict"; 5 +// /src/lib/glyphField.js 6 + 7 +/* Het tekenveld (Home, het Dashboard, de lijstpagina's, inloggen en de foutpagina's; welke pagina's 8 + het krijgen staat bij de route, zie src/components/RouteBackground.jsx): een raster van willekeurige letters en cijfers in een monospace 9 + letter, bijna onzichtbaar, met langzaam drijvende dichtere wolken. Waar de muis langs 10 + gaat, worden de tekens donkerder, kleuren ze naar het merkrood en wisselen ze snel; dat 11 + spoor dooft in een paar seconden weer uit. Daaronder klopt een rustige hartslag: twee 12 + hartvormige ringen die vanuit de muis uitdijen en uitdoven. Af en toe schiet er een rode 13 + hartmonitorlijn over het scherm, en een klik op de achtergrond laat een smiley opspringen. 14 + 15 + Bewust zonder React: de engine tekent op een canvas en leest het thema uit de CSS-tokens 16 + (--text en --brand-red), zodat hij licht en donker vanzelf volgt. 17 + 18 + Zuinig met CPU en GPU, want het veld staat open zolang de pagina openstaat: 19 + - de tekens staan eenmalig in een atlas en worden per cel gekopieerd (drawImage) in 20 + plaats van steeds opnieuw gezet (fillText); 21 + - het rustende veld staat op een eigen laag die maar vier keer per seconde opnieuw wordt 22 + getekend; per frame gaat die laag in één keer over het canvas; 23 + - per frame worden alleen de cellen onder het spoor en de hartslag bijgewerkt, binnen de 24 + kleinste rechthoek waarin nog iets ligt; 25 + - op een verborgen tabblad tekent het veld niet, en bij prefers-reduced-motion staat er 26 + één stilstaand beeld zonder spoor. */ 27 + 28 +const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 29 + 30 +/* Het thema bepaalt welke tekens het veld gebruikt. Nu alleen de standaardset; een seizoensset 31 + (sneeuwvlokken rond kerst, paaseitjes rond Pasen) komt hier later bij als eigen thema, zonder dat 32 + een pagina iets hoeft te weten. Elk teken krijgt één cel; hooguit 256 tekens (Uint8Array). */ 33 +const GLYPH_THEMES = Object.freeze({ 34 + standaard: Object.freeze({ label: "Standaard", glyphs: Object.freeze(Array.from(GLYPHS)) }), 35 +}); 36 + 37 +const DEFAULT_GLYPH_THEME = "standaard"; 38 + 39 +/* Intensiteit per pagina. "vol" is het veld van Home zoals het is; "lijst" heeft in rust minder 40 + tekens (de wolken liggen hoger, zodat er minder cellen boven de drempel komen), maar elk teken is 41 + even duidelijk en het muisspoor, de hartslag, de hartslaglijn, de smileys en de explosie blijven 42 + precies gelijk. Gemeten: in rust staan er op "lijst" ongeveer 70% van de tekens van "vol". */ 43 +const GLYPH_INTENSITIES = Object.freeze({ 44 + vol: Object.freeze({}), 45 + lijst: Object.freeze({ ambientLift: -0.05 }), 46 +}); 47 + 48 +const DEFAULT_GLYPH_INTENSITY = "vol"; 49 + 50 +/* De opties voor createGlyphField bij een intensiteit en een thema. Een onbekende naam is een 51 + programmeerfout; die wordt gemeld en valt terug op de standaard, zodat de pagina niet stukgaat. */ 52 +function glyphFieldSettings({ intensity = DEFAULT_GLYPH_INTENSITY, theme = DEFAULT_GLYPH_THEME } = {}) { 53 + let intensityOptions = GLYPH_INTENSITIES[intensity]; 54 + if (!intensityOptions) { 55 + console.error(`tekenveld: onbekende intensiteit "${intensity}"; ${DEFAULT_GLYPH_INTENSITY} gebruikt`); 56 + intensityOptions = GLYPH_INTENSITIES[DEFAULT_GLYPH_INTENSITY]; 57 + } 58 + let themeOptions = GLYPH_THEMES[theme]; 59 + if (!themeOptions) { 60 + console.error(`tekenveld: onbekend thema "${theme}"; ${DEFAULT_GLYPH_THEME} gebruikt`); 61 + themeOptions = GLYPH_THEMES[DEFAULT_GLYPH_THEME]; 62 + } 63 + return { ...intensityOptions, glyphs: themeOptions.glyphs }; 64 +} 65 + 66 +const DEFAULTS = { 67 + // De tekens van het veld; zie GLYPH_THEMES. 68 + glyphs: GLYPH_THEMES.standaard.glyphs, 69 + fontSize: 12, 70 + lineHeight: 15, 71 + fontFamily: 'ui-monospace, "Cascadia Mono", "SF Mono", Consolas, "Liberation Mono", monospace', 72 + // Zichtbaarheid van de wolken in het rustende veld; donker oogt sneller sterker. 73 + ambientMax: { light: 0.1, dark: 0.075 }, 74 + // Hoe hoog de wolken liggen: lager betekent minder tekens in rust (zie GLYPH_INTENSITIES). 75 + ambientLift: 0.05, 76 + // Zichtbaarheid onder het spoor van de muis. 77 + trailNeutral: { light: 0.38, dark: 0.3 }, 78 + trailAccent: { light: 0.4, dark: 0.45 }, 79 + brushRadius: 70, 80 + trailSeconds: 2.2, 81 + ambientIntervalMs: 250, 82 + maxFps: 30, 83 + // Bepaalt of een klik op de achtergrond viel (en dus een smiley geeft); zonder deze 84 + // functie telt elke klik. 85 + isBackground: null, 86 + // Geeft de rechthoeken (getBoundingClientRect) van de inhoud; de hartslaglijn kiest bij 87 + // voorkeur een strook waar die niet staan. 88 + obstacles: null, 89 + // Hartslag: elke slag twee hartvormige ringen vlak na elkaar (lub-dub) die vanaf de muis 90 + // uitdijen en onderweg uitdoven. In rust een slag per heartPeriod; tijdens bewegen een 91 + // slag per heartSpacing pixels, maar niet vaker dan per heartMinGap seconden. 92 + heartPeriod: 4, 93 + heartDubDelay: 0.2, 94 + // De tweede slag (dub) is een zachte naklank van de eerste, geen tweede volle ring. 95 + heartDubWeight: 0.3, 96 + heartSpacing: 420, 97 + heartMinGap: 1.4, 98 + heartMaxRings: 12, 99 + // Hartslaglijn: af en toe trekt er op een willekeurige hoogte een rode lijn in de vorm van 100 + // een hartmonitor van links naar rechts over het scherm; het spoor dooft vanzelf uit. 101 + ecgEverySeconds: [10, 24], 102 + ecgFirstSeconds: [3, 7], 103 + ecgCrossSeconds: 0.7, 104 + ecgBeatWidth: 165, 105 + ecgAmplitude: 105, 106 + ecgRadius: 10, 107 + // De hartslaglijn en de smileys hebben een eigen, kort spoor; ze moeten flitsen, niet 108 + // blijven hangen zoals het spoor van de muis. 109 + streakSeconds: 0.3, 110 + streakNeutral: { light: 0.1, dark: 0.08 }, 111 + streakAccent: { light: 1, dark: 1 }, 112 + // Smileys: een klik op de achtergrond laat er een opspringen; spammen mag. 113 + smileyRadius: 145, 114 + smileySeconds: 1.5, 115 + smileyMax: 40, 116 + // Variatie in de hartslaglijn; een op de zes lijnen is wild. 117 + ecgWildChance: 0.17, 118 + // Vijf snelle klikken: een atoomexplosie. De letters vallen van het scherm; na 15 s mogen 119 + // de smileys weer, na 30 s komt de hartslaglijn terug, na 45 s de hartslag en het 120 + // muisspoor, na 60 s het hele veld. 121 + nukeClicks: 5, 122 + nukeClickWindow: 1.6, 123 + nukeFallAfter: 2.4, 124 + nukeSmileyAfter: 15, 125 + nukeEcgAfter: 30, 126 + nukeHeartAfter: 45, 127 + nukeNormalAfter: 60, 128 + ecgStrength: 1, 129 + heartDuration: 1.9, 130 + heartReach: 0.42, 131 + heartThickness: 40, 132 + heartStrength: 0.6, 133 + heartSeconds: 0.15, 134 + // Hoe rood een verse ring is ten opzichte van het muisspoor. 135 + heartRed: 1.3, 136 +}; 137 + 138 +const MIN_ALPHA = 0.01; 139 + 140 +/* Straal van een hart per hoek, vanuit het midden van het hart en met de punt naar 141 + beneden; de grootste straal is 1. Gemaakt uit de bekende parametrische hartkromme. */ 142 +const HEART_BINS = 360; 143 +const HEART_RADIUS = (() => { 144 + const points = []; 145 + for (let i = 0; i < 2000; i += 1) { 146 + const t = (i / 2000) * Math.PI * 2; 147 + const x = 16 * Math.sin(t) ** 3; 148 + const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); 149 + points.push([x, y]); 150 + } 151 + const ys = points.map((p) => p[1]); 152 + const midY = (Math.min(...ys) + Math.max(...ys)) / 2; 153 + const table = new Float32Array(HEART_BINS); 154 + for (const [x, y] of points) { 155 + const angle = Math.atan2(y - midY, x); 156 + const bin = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS; 157 + table[bin] = Math.max(table[bin], Math.hypot(x, y - midY)); 158 + } 159 + for (let i = 0; i < HEART_BINS; i += 1) { 160 + if (!table[i]) table[i] = table[(i + HEART_BINS - 1) % HEART_BINS]; 161 + } 162 + const max = Math.max(...table); 163 + for (let i = 0; i < HEART_BINS; i += 1) table[i] /= max; 164 + return table; 165 +})(); 166 +const HEART_MIN = Math.min(...HEART_RADIUS); 167 + 168 +function randomBetween([min, max]) { 169 + return min + Math.random() * (max - min); 170 +} 171 + 172 +function bump(u, center, width, height) { 173 + const d = (u - center) / width; 174 + return height * Math.exp(-d * d); 175 +} 176 + 177 +/* Een slag van een hartmonitor over een fase u van 0 tot 1: ronde P-golf, de scherpe QRS-piek 178 + met een diepe S, een licht oplopend ST-stuk, een brede T-golf en een kleine U-golf. Omhoog 179 + is positief. */ 180 +function ecgShape(u) { 181 + return ( 182 + bump(u, 0.18, 0.05, 0.16) + 183 + bump(u, 0.36, 0.012, -0.14) + 184 + bump(u, 0.4, 0.013, 1) + 185 + bump(u, 0.44, 0.014, -0.34) + 186 + bump(u, 0.5, 0.05, 0.04) + 187 + bump(u, 0.66, 0.075, 0.32) + 188 + bump(u, 0.82, 0.035, 0.05) 189 + ); 190 +} 191 + 192 +/* Smileys in genormaliseerde coördinaten: het midden is (0, 0), de rand van het gezicht 193 + ligt op straal 1 en y loopt naar beneden. Elke vorm geeft per punt 1 (teken) of 0. */ 194 +function inDot(x, y, cx, cy, radius) { 195 + return Math.hypot(x - cx, y - cy) < radius; 196 +} 197 + 198 +function inSmile(x, y) { 199 + return y > 0.12 && Math.abs(Math.hypot(x, y + 0.05) - 0.56) < 0.09; 200 +} 201 + 202 +function inHeartEye(x, y, cx, cy) { 203 + const dx = (x - cx) / 0.2; 204 + const dy = (y - cy) / 0.2; 205 + return Math.hypot(dx, dy) < heartRadius(dx, dy); 206 +} 207 + 208 +const SMILEYS = [ 209 + // blij 210 + (x, y) => inDot(x, y, -0.33, -0.28, 0.13) || inDot(x, y, 0.33, -0.28, 0.13) || inSmile(x, y), 211 + // grote lach 212 + (x, y) => 213 + inDot(x, y, -0.33, -0.3, 0.13) || 214 + inDot(x, y, 0.33, -0.3, 0.13) || 215 + // open mond: de rand van een D, met een gat erin 216 + (y > 0.06 && Math.hypot(x, y - 0.05) < 0.62 && !(y > 0.2 && Math.hypot(x, y - 0.05) < 0.44)), 217 + // knipoog 218 + (x, y) => 219 + (Math.abs(y + 0.28) < 0.09 && Math.abs(x + 0.33) < 0.17) || inDot(x, y, 0.33, -0.28, 0.13) || inSmile(x, y), 220 + // tong uit 221 + (x, y) => 222 + inDot(x, y, -0.33, -0.28, 0.13) || 223 + inDot(x, y, 0.33, -0.28, 0.13) || 224 + (Math.abs(y - 0.28) < 0.08 && Math.abs(x) < 0.42) || 225 + (y > 0.28 && inDot(x, y, 0.14, 0.46, 0.21)), 226 + // verliefd 227 + (x, y) => inHeartEye(x, y, -0.33, -0.26) || inHeartEye(x, y, 0.33, -0.26) || inSmile(x, y), 228 + // verbaasd 229 + (x, y) => 230 + inDot(x, y, -0.33, -0.3, 0.13) || 231 + inDot(x, y, 0.33, -0.3, 0.13) || 232 + Math.abs(Math.hypot(x, y - 0.4) - 0.19) < 0.08, 233 +]; 234 + 235 +function smileyAt(kind, x, y) { 236 + const r = Math.hypot(x, y); 237 + // De rand is minstens één rasterregel dik, anders valt hij onderaan tussen de regels door. 238 + if (Math.abs(r - 0.9) < 0.12) return true; 239 + if (r > 0.78) return false; 240 + return SMILEYS[kind](x, y); 241 +} 242 +function hash(value) { 243 + const x = Math.sin(value * 12.9898) * 43758.5453; 244 + return x - Math.floor(x); 245 +} 246 + 247 +function heartRadius(dx, dy) { 248 + const bin = Math.floor(((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * HEART_BINS) % HEART_BINS; 249 + return HEART_RADIUS[bin]; 250 +} 251 + 252 +function clamp01(value) { 253 + return value < 0 ? 0 : value > 1 ? 1 : value; 254 +} 255 + 256 +function readTheme() { 257 + const root = document.documentElement; 258 + const styles = getComputedStyle(root); 259 + const text = styles.getPropertyValue("--text").trim() || "#111827"; 260 + const accent = styles.getPropertyValue("--brand-red").trim() || "#e62b27"; 261 + // :root zonder attribuut is in Ember donker; zie layout.css. 262 + const mode = root.getAttribute("data-appearance") === "light" ? "light" : "dark"; 263 + return { text, accent, mode }; 264 +} 265 + 266 +/* De tekens van een thema: een niet-lege lijst van hooguit 256 tekens (de index staat in een Uint8Array). */ 267 +function checkGlyphs(glyphs) { 268 + const list = Array.from(glyphs || []); 269 + if (!list.length || list.length > 256) throw new Error("tekenveld: een thema heeft 1 tot 256 tekens"); 270 + return list; 271 +} 272 + 273 +/* Wat geen achtergrond is, staat met data-glyph-block op het element: een blok, een tegel, een kop. 274 + Een klik daarop geeft geen smiley, en de hartslaglijn loopt bij voorkeur waar die niet staan. De 275 + pagina markeert zelf; het veld kent geen vaste selectors van een pagina. */ 276 +const GLYPH_BLOCK_SELECTOR = "[data-glyph-block]"; 277 + 278 +/* De rechthoeken van alle gemarkeerde blokken die zichtbaar zijn; voor de optie obstacles. */ 279 +function glyphBlockRects(root = document) { 280 + return Array.from(root.querySelectorAll(GLYPH_BLOCK_SELECTOR), (element) => element.getBoundingClientRect()) 281 + .filter((rect) => rect.width > 0 && rect.height > 0); 282 +} 283 + 284 +/* Start het veld op een canvas. Geeft { destroy, configure } terug: configure past de intensiteit of 285 + het thema aan terwijl het veld doorloopt (een andere pagina met hetzelfde veld), zonder opnieuw te 286 + beginnen. */ 287 +function createGlyphField(canvas, overrides = {}) { 288 + const options = { ...DEFAULTS, ...overrides }; 289 + let glyphSet = checkGlyphs(options.glyphs); 290 + const randomGlyph = () => (Math.random() * glyphSet.length) | 0; 291 + const ctx = canvas.getContext("2d"); 292 + const layer = document.createElement("canvas"); 293 + const layerCtx = layer.getContext("2d"); 294 + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); 295 + 296 + let dpr = 1; 297 + let cellW = 7; 298 + let cellH = options.lineHeight; 299 + let tileW = 7; 300 + let tileH = 15; 301 + let cols = 0; 302 + let rows = 0; 303 + let glyphs = new Uint8Array(0); 304 + let ambient = new Float32Array(0); 305 + let trail = new Float32Array(0); 306 + let pulse = new Float32Array(0); 307 + let streak = new Float32Array(0); 308 + let quiet = new Float32Array(0); 309 + let neutralAtlas = null; 310 + let accentAtlas = null; 311 + let yellowAtlas = null; 312 + let orangeAtlas = null; 313 + let smokeAtlas = null; 314 + let theme = readTheme(); 315 + 316 + // Rechthoek (in cellen) waarbinnen nog spoor ligt; leeg als minC > maxC. 317 + let minC = 1; 318 + let maxC = 0; 319 + let minR = 1; 320 + let maxR = 0; 321 + 322 + let pointer = null; 323 + let lastPointer = null; 324 + let heartHome = null; 325 + let rings = []; 326 + let lastBeatAt = -Infinity; 327 + let lastBeatSpot = null; 328 + let ecg = null; 329 + let smileys = []; 330 + let calm = 1; 331 + let nuke = null; 332 + let clickTimes = []; 333 + let fallers = null; 334 + let nextEcgAt = performance.now() + randomBetween(options.ecgFirstSeconds) * 1000; 335 + let frameId = 0; 336 + let lastFrame = 0; 337 + let lastAmbient = -Infinity; 338 + let running = false; 339 + let destroyed = false; 340 + 341 + function buildAtlas(color) { 342 + const atlas = document.createElement("canvas"); 343 + atlas.width = tileW * glyphSet.length; 344 + atlas.height = tileH; 345 + const a = atlas.getContext("2d"); 346 + a.font = `${options.fontSize * dpr}px ${options.fontFamily}`; 347 + a.textBaseline = "middle"; 348 + a.textAlign = "center"; 349 + a.fillStyle = color; 350 + for (let i = 0; i < glyphSet.length; i += 1) { 351 + a.fillText(glyphSet[i], i * tileW + tileW / 2, tileH / 2 + dpr * 0.5); 352 + } 353 + return atlas; 354 + } 355 + 356 + function buildAtlases() { 357 + neutralAtlas = buildAtlas(theme.text); 358 + accentAtlas = buildAtlas(theme.accent); 359 + // Vuurkleuren voor de explosie. 360 + yellowAtlas = buildAtlas(theme.mode === "light" ? "#eab308" : "#facc15"); 361 + orangeAtlas = buildAtlas("#f97316"); 362 + // Rook: zwart op licht; op donker donkergrijs, anders zie je hem niet. 363 + smokeAtlas = buildAtlas(theme.mode === "light" ? "#0b0b0f" : "#52525b"); 364 + } 365 + 366 + function resize() { 367 + dpr = Math.min(window.devicePixelRatio || 1, 2); 368 + const width = window.innerWidth; 369 + const height = window.innerHeight; 370 + 371 + canvas.width = layer.width = Math.round(width * dpr); 372 + canvas.height = layer.height = Math.round(height * dpr); 373 + canvas.style.width = `${width}px`; 374 + canvas.style.height = `${height}px`; 375 + 376 + ctx.font = `${options.fontSize}px ${options.fontFamily}`; 377 + cellW = Math.max(6, Math.round(ctx.measureText("M").width * 10) / 10); 378 + cellH = options.lineHeight; 379 + tileW = Math.ceil(cellW * dpr); 380 + tileH = Math.ceil(cellH * dpr); 381 + 382 + cols = Math.ceil(width / cellW) + 1; 383 + rows = Math.ceil(height / cellH) + 1; 384 + glyphs = new Uint8Array(cols * rows); 385 + for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph(); 386 + ambient = new Float32Array(cols * rows); 387 + trail = new Float32Array(cols * rows); 388 + pulse = new Float32Array(cols * rows); 389 + streak = new Float32Array(cols * rows); 390 + quiet = new Float32Array(cols * rows); 391 + minC = minR = 1; 392 + maxC = maxR = 0; 393 + 394 + buildAtlases(); 395 + lastAmbient = -Infinity; 396 + render(performance.now(), 0); 397 + } 398 + 399 + function applyTheme() { 400 + const next = readTheme(); 401 + if (next.text === theme.text && next.accent === theme.accent && next.mode === theme.mode) return; 402 + theme = next; 403 + buildAtlases(); 404 + lastAmbient = -Infinity; 405 + render(performance.now(), 0); 406 + } 407 + 408 + function cellX(c) { 409 + return Math.round(c * cellW * dpr); 410 + } 411 + 412 + function cellY(r) { 413 + return Math.round(r * cellH * dpr); 414 + } 415 + 416 + // Het rustende veld: twee trage golven per as en een diagonale band. Alleen de toppen 417 + // worden zichtbaar, zodat er losse wolken ontstaan en geen egaal behang. 418 + function drawAmbient(time, elapsed) { 419 + const t = time / 1000; 420 + const max = options.ambientMax[theme.mode]; 421 + const colWave = new Float32Array(cols); 422 + const rowWave = new Float32Array(rows); 423 + for (let c = 0; c < cols; c += 1) { 424 + colWave[c] = Math.sin(c * 0.045 + t * 0.21) + 0.6 * Math.sin(c * 0.013 - t * 0.09); 425 + } 426 + for (let r = 0; r < rows; r += 1) { 427 + rowWave[r] = Math.sin(r * 0.11 - t * 0.17) + 0.6 * Math.sin(r * 0.031 + t * 0.07); 428 + } 429 + 430 + // Tekens wisselen ook in rust, heel af en toe. 431 + const rerollChance = elapsed > 0 ? Math.min(0.05 * elapsed, 0.2) : 0; 432 + 433 + layerCtx.setTransform(1, 0, 0, 1, 0, 0); 434 + layerCtx.clearRect(0, 0, layer.width, layer.height); 435 + let currentAlpha = -1; 436 + 437 + for (let r = 0; r < rows; r += 1) { 438 + const y = cellY(r); 439 + const rw = rowWave[r]; 440 + for (let c = 0; c < cols; c += 1) { 441 + const index = r * cols + c; 442 + const wave = (colWave[c] * rw + Math.sin((c + r * 2.2) * 0.035 + t * 0.12)) / 3.2; 443 + const cloud = clamp01((wave + options.ambientLift) * 2.2); 444 + const alpha = Math.round(max * cloud * cloud * 128) / 128; 445 + ambient[index] = alpha; 446 + if (rerollChance && Math.random() < rerollChance) glyphs[index] = randomGlyph(); 447 + if (alpha < MIN_ALPHA) continue; 448 + if (alpha !== currentAlpha) { 449 + layerCtx.globalAlpha = alpha; 450 + currentAlpha = alpha; 451 + } 452 + layerCtx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH); 453 + } 454 + } 455 + layerCtx.globalAlpha = 1; 456 + } 457 + 458 + // Een zachte kwast op de plek van de muis; het spoor neemt het hoogste van oud en nieuw. 459 + // flat: een harde kern zonder zachte rand, voor een scherpe lijn in plaats van een vlek. 460 + function stamp(px, py, radius = options.brushRadius, strength = 1, flat = false, target = trail) { 461 + const c0 = Math.max(0, Math.floor((px - radius) / cellW)); 462 + const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW)); 463 + const r0 = Math.max(0, Math.floor((py - radius) / cellH)); 464 + const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH)); 465 + if (c0 > c1 || r0 > r1) return; 466 + const inv = 1 / (radius * radius); 467 + 468 + for (let r = r0; r <= r1; r += 1) { 469 + const dy = r * cellH + cellH / 2 - py; 470 + for (let c = c0; c <= c1; c += 1) { 471 + const dx = c * cellW + cellW / 2 - px; 472 + const d = (dx * dx + dy * dy) * inv; 473 + if (d >= 1) continue; 474 + // Rafelige rand: niet elke cel aan de buitenkant doet even hard mee. 475 + const edge = flat ? (d < 0.6 ? 1 : (1 - d) / 0.4) : (1 - d) * (1 - d) * (0.75 + Math.random() * 0.25); 476 + const value = edge * strength; 477 + const index = r * cols + c; 478 + if (value > target[index]) target[index] = value; 479 + } 480 + } 481 + 482 + extendBox(c0, c1, r0, r1); 483 + } 484 + 485 + function extendBox(c0, c1, r0, r1) { 486 + if (minC > maxC) { 487 + minC = c0; 488 + maxC = c1; 489 + minR = r0; 490 + maxR = r1; 491 + } else { 492 + minC = Math.min(minC, c0); 493 + maxC = Math.max(maxC, c1); 494 + minR = Math.min(minR, r0); 495 + maxR = Math.max(maxR, r1); 496 + } 497 + } 498 + 499 + // Een hartvormige ring met de grootste straal `size` rond (cx, cy). Per rij worden alleen 500 + // de kolommen bekeken die tussen de kleinste en grootste straal van de ring vallen. 501 + function stampRing(cx, cy, size, strength) { 502 + const half = options.heartThickness / 2; 503 + const outer = size + half; 504 + const inner = Math.max(0, size * HEART_MIN - half); 505 + const r0 = Math.max(0, Math.floor((cy - outer) / cellH)); 506 + const r1 = Math.min(rows - 1, Math.ceil((cy + outer) / cellH)); 507 + let touched = false; 508 + 509 + for (let r = r0; r <= r1; r += 1) { 510 + const dy = r * cellH + cellH / 2 - cy; 511 + const outerSpan = outer * outer - dy * dy; 512 + if (outerSpan <= 0) continue; 513 + const reach = Math.sqrt(outerSpan); 514 + const innerSpan = inner * inner - dy * dy; 515 + const gap = innerSpan > 0 ? Math.sqrt(innerSpan) : 0; 516 + const c0 = Math.max(0, Math.floor((cx - reach) / cellW)); 517 + const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW)); 518 + 519 + for (let c = c0; c <= c1; c += 1) { 520 + const dx = c * cellW + cellW / 2 - cx; 521 + if (dx > -gap && dx < gap) { 522 + c = Math.max(c, Math.floor((cx + gap) / cellW) - 1); 523 + continue; 524 + } 525 + const distance = Math.sqrt(dx * dx + dy * dy); 526 + const offset = Math.abs(distance - size * heartRadius(dx, dy)) / half; 527 + if (offset >= 1) continue; 528 + const value = (1 - offset * offset) * strength; 529 + const index = r * cols + c; 530 + if (value > pulse[index]) pulse[index] = value; 531 + touched = true; 532 + } 533 + } 534 + 535 + if (touched) { 536 + extendBox( 537 + Math.max(0, Math.floor((cx - outer) / cellW)), 538 + Math.min(cols - 1, Math.ceil((cx + outer) / cellW)), 539 + r0, 540 + r1 541 + ); 542 + } 543 + } 544 + 545 + // Een slag: twee ringen vanaf dezelfde plek, de tweede iets later en veel zwakker (lub-dub). 546 + function spawnBeat(now, x, y) { 547 + rings.push({ x, y, start: now, weight: 1 }); 548 + rings.push({ x, y, start: now + options.heartDubDelay * 1000, weight: options.heartDubWeight }); 549 + if (rings.length > options.heartMaxRings) rings.splice(0, rings.length - options.heartMaxRings); 550 + lastBeatAt = now; 551 + lastBeatSpot = { x, y }; 552 + } 553 + 554 + /* De hartslag komt uit de muis. Staat de muis stil, dan klopt hij in een rustig ritme op 555 + die plek; beweegt hij, dan komt er een slag bij zodra hij een stuk verder is, zodat er 556 + hartjes langs het pad liggen. Buiten het venster klopt er niets. */ 557 + /* De hartslaglijn loopt los van de muis. De baan wordt per 2 px gevolgd, anders valt de 558 + smalle piek tussen twee frames door; een stempel volgt zodra de lijn een stukje verder 559 + is. */ 560 + /* Hoogte voor de hartslaglijn. Een lichte voorkeur voor stroken waar weinig inhoud staat: 561 + uit vijf willekeurige hoogtes wint meestal de vrijste, maar een op de vier keer gewoon 562 + een willekeurige, zodat de lijn overal kan verschijnen. */ 563 + function pickLineY(height) { 564 + const candidates = []; 565 + for (let i = 0; i < 5; i += 1) candidates.push(height * (0.15 + Math.random() * 0.75)); 566 + const rects = options.obstacles ? options.obstacles() : []; 567 + if (!rects.length || Math.random() < 0.25) return candidates[0]; 568 + 569 + const width = window.innerWidth; 570 + const top = options.ecgAmplitude; 571 + const bottom = options.ecgAmplitude * 0.4; 572 + let best = candidates[0]; 573 + let bestCover = Infinity; 574 + for (const y of candidates) { 575 + let cover = 0; 576 + for (const rect of rects) { 577 + if (rect.bottom < y - top || rect.top > y + bottom) continue; 578 + cover += Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0)); 579 + } 580 + if (cover < bestCover) { 581 + bestCover = cover; 582 + best = y; 583 + } 584 + } 585 + return best; 586 + } 587 + 588 + // Elke slag een eigen grootte, vast per slag zodat hij niet trilt tussen frames. 589 + function beatScale(line, beat) { 590 + const roll = hash(line.seed + beat * 7.31); 591 + let scale = 0.82 + roll * 0.36; 592 + if (line.wild && hash(line.seed - beat * 3.17) > 0.62) scale *= 1.6 + roll * 0.5; 593 + return scale; 594 + } 595 + 596 + function heartLine(now) { 597 + if (nuke && now - nuke.start < options.nukeEcgAfter * 1000) return; 598 + if (!ecg) { 599 + if (now < nextEcgAt) return; 600 + const height = window.innerHeight; 601 + ecg = { 602 + start: now, 603 + baseY: pickLineY(height), 604 + lastX: -options.ecgRadius, 605 + stampX: -Infinity, 606 + stampY: 0, 607 + offset: Math.random(), 608 + seed: Math.random() * 1000, 609 + // Elke lijn een eigen hoogte en tempo; zo nu en dan een wilde met uitschieters. 610 + amplitude: options.ecgAmplitude * (0.8 + Math.random() * 0.4), 611 + beatWidth: options.ecgBeatWidth * (0.8 + Math.random() * 0.45), 612 + wild: Math.random() < options.ecgWildChance, 613 + }; 614 + } 615 + 616 + const width = window.innerWidth; 617 + const travel = width + options.ecgRadius * 2; 618 + const progress = (now - ecg.start) / (options.ecgCrossSeconds * 1000); 619 + const targetX = -options.ecgRadius + travel * Math.min(progress, 1); 620 + 621 + for (let x = ecg.lastX; x <= targetX; x += 2) { 622 + // Een echte lijn ligt nooit stil: de basislijn deint zacht mee (ademhaling). 623 + const sway = ecg.wild ? 2 : 1; 624 + const wander = (Math.sin(x * 0.006 + ecg.offset * 6) * 0.1 + Math.sin(x * 0.017 + ecg.offset * 3) * 0.04) * sway; 625 + const phase = x / ecg.beatWidth + ecg.offset; 626 + const y = ecg.baseY - (ecgShape(phase % 1) * beatScale(ecg, Math.floor(phase)) + wander) * ecg.amplitude; 627 + if (Math.hypot(x - ecg.stampX, y - ecg.stampY) >= options.ecgRadius * 0.3) { 628 + stamp(x, y, options.ecgRadius, options.ecgStrength, true, streak); 629 + ecg.stampX = x; 630 + ecg.stampY = y; 631 + } 632 + ecg.lastX = x + 2; 633 + } 634 + 635 + if (progress >= 1) { 636 + ecg = null; 637 + nextEcgAt = now + randomBetween(options.ecgEverySeconds) * 1000; 638 + } 639 + } 640 + 641 + function spawnSmiley(x, y) { 642 + let kind = (Math.random() * SMILEYS.length) | 0; 643 + // Niet twee keer achter elkaar dezelfde. 644 + if (smileys.length && smileys[smileys.length - 1].kind === kind) kind = (kind + 1) % SMILEYS.length; 645 + smileys.push({ x, y, kind, start: performance.now(), tilt: (Math.random() - 0.5) * 0.5 }); 646 + if (smileys.length > options.smileyMax) smileys.splice(0, smileys.length - options.smileyMax); 647 + } 648 + 649 + 650 + /* Een smiley springt op (even groter dan zijn eindmaat), zweeft een stukje omhoog en 651 + dooft uit. Hij wordt elk frame opnieuw in het korte spoor gezet; als hij weg is, flitst 652 + hij in een paar tienden van een seconde uit. */ 653 + function drawSmileys(now) { 654 + let keep = 0; 655 + for (const smiley of smileys) { 656 + const age = (now - smiley.start) / 1000; 657 + if (age >= options.smileySeconds) continue; 658 + smileys[keep] = smiley; 659 + keep += 1; 660 + 661 + const pop = Math.min(age / 0.22, 1); 662 + const scale = pop < 1 ? 0.3 + 0.9 * Math.sin((pop * Math.PI) / 2) + 0.1 * Math.sin(pop * Math.PI) : 1; 663 + const fade = Math.min(1, (options.smileySeconds - age) / 0.45); 664 + const radius = options.smileyRadius * scale; 665 + const cx = smiley.x; 666 + const cy = smiley.y - age * 40; 667 + const cos = Math.cos(smiley.tilt); 668 + const sin = Math.sin(smiley.tilt); 669 + // Stille cirkel: binnen 1,1 keer de straal helemaal stil, tot 1,5 keer zacht aflopend. 670 + const reach = radius * 1.5; 671 + 672 + const c0 = Math.max(0, Math.floor((cx - reach) / cellW)); 673 + const c1 = Math.min(cols - 1, Math.ceil((cx + reach) / cellW)); 674 + const r0 = Math.max(0, Math.floor((cy - reach) / cellH)); 675 + const r1 = Math.min(rows - 1, Math.ceil((cy + reach) / cellH)); 676 + if (c0 > c1 || r0 > r1) continue; 677 + 678 + for (let r = r0; r <= r1; r += 1) { 679 + const py = (r * cellH + cellH / 2 - cy) / radius; 680 + for (let c = c0; c <= c1; c += 1) { 681 + const px = (c * cellW + cellW / 2 - cx) / radius; 682 + const x = px * cos + py * sin; 683 + const y = -px * sin + py * cos; 684 + const index = r * cols + c; 685 + const d2 = x * x + y * y; 686 + if (d2 < 2.25) { 687 + const still = fade * (d2 < 1.21 ? 1 : (2.25 - d2) / 1.04); 688 + if (still > quiet[index]) quiet[index] = still; 689 + } 690 + if (!smileyAt(smiley.kind, x, y)) continue; 691 + if (fade > streak[index]) streak[index] = fade; 692 + } 693 + } 694 + extendBox(c0, c1, r0, r1); 695 + } 696 + smileys.length = keep; 697 + } 698 + 699 + function onPointerDown(event) { 700 + if (reducedMotion.matches || event.button !== 0) return; 701 + if (options.isBackground && !options.isBackground(event)) return; 702 + if (nuke) { 703 + // In de nasleep geen nieuwe explosie; na nukeSmileyAfter mogen de smileys wel weer. 704 + if (nukeAge(performance.now()) >= options.nukeSmileyAfter) spawnSmiley(event.clientX, event.clientY); 705 + return; 706 + } 707 + 708 + const now = performance.now(); 709 + clickTimes = clickTimes.filter((time) => now - time < options.nukeClickWindow * 1000); 710 + clickTimes.push(now); 711 + if (clickTimes.length >= options.nukeClicks) { 712 + clickTimes = []; 713 + startNuke(now, event.clientX, event.clientY); 714 + return; 715 + } 716 + spawnSmiley(event.clientX, event.clientY); 717 + } 718 + 719 + function startNuke(now, x, y) { 720 + nuke = { start: now, x, y, puffs: [], puffClock: 0 }; 721 + smileys = []; 722 + rings = []; 723 + ecg = null; 724 + pointer = null; 725 + lastPointer = null; 726 + trail.fill(0); 727 + pulse.fill(0); 728 + nextEcgAt = now + options.nukeEcgAfter * 1000; 729 + } 730 + 731 + // Hoe ver de nasleep is, in seconden; Infinity als er geen explosie is geweest. 732 + function nukeAge(now) { 733 + return nuke ? (now - nuke.start) / 1000 : Infinity; 734 + } 735 + 736 + function heartbeat(now) { 737 + if (nukeAge(now) < options.nukeHeartAfter) return; 738 + if (heartHome) { 739 + const sinceBeat = now - lastBeatAt; 740 + const moved = lastBeatSpot 741 + ? Math.hypot(heartHome.x - lastBeatSpot.x, heartHome.y - lastBeatSpot.y) 742 + : Infinity; 743 + if ( 744 + sinceBeat >= options.heartPeriod * 1000 || 745 + (moved >= options.heartSpacing && sinceBeat >= options.heartMinGap * 1000) 746 + ) { 747 + spawnBeat(now, heartHome.x, heartHome.y); 748 + } 749 + } 750 + 751 + const reach = Math.max(window.innerWidth, window.innerHeight) * options.heartReach; 752 + let keep = 0; 753 + for (const ring of rings) { 754 + const age = (now - ring.start) / 1000; 755 + if (age >= options.heartDuration) continue; 756 + rings[keep] = ring; 757 + keep += 1; 758 + if (age < 0) continue; 759 + const progress = age / options.heartDuration; 760 + const size = reach * (1 - (1 - progress) * (1 - progress)); 761 + const strength = options.heartStrength * ring.weight * (1 - progress) ** 1.3; 762 + // Een bijna uitgedoofde ring is groot en kost veel cellen voor weinig beeld. 763 + if (size > 4 && strength > 0.06) stampRing(ring.x, ring.y, size, strength); 764 + } 765 + rings.length = keep; 766 + } 767 + 768 + function paintPointer() { 769 + const from = lastPointer || pointer; 770 + const dx = pointer.x - from.x; 771 + const dy = pointer.y - from.y; 772 + const step = options.brushRadius * 0.35; 773 + const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step)); 774 + for (let s = 1; s <= steps; s += 1) { 775 + stamp(from.x + (dx * s) / steps, from.y + (dy * s) / steps); 776 + } 777 + lastPointer = pointer; 778 + pointer = null; 779 + } 780 + 781 + // Het spoor: alleen de cellen binnen de rechthoek met spoor. De cel wordt eerst leeg 782 + // gemaakt, zodat het teken uit de rustlaag er niet dubbel onder staat. 783 + function drawTrail(dt) { 784 + if (minC > maxC) return; 785 + const decay = dt > 0 ? Math.exp(-dt / options.trailSeconds) : 1; 786 + const pulseDecay = dt > 0 ? Math.exp(-dt / options.heartSeconds) : 1; 787 + const streakDecay = dt > 0 ? Math.exp(-dt / options.streakSeconds) : 1; 788 + const neutralMax = options.trailNeutral[theme.mode]; 789 + const accentMax = options.trailAccent[theme.mode]; 790 + const streakNeutral = options.streakNeutral[theme.mode]; 791 + const streakAccent = options.streakAccent[theme.mode]; 792 + let nextMinC = cols; 793 + let nextMaxC = -1; 794 + let nextMinR = rows; 795 + let nextMaxR = -1; 796 + 797 + for (let r = minR; r <= maxR; r += 1) { 798 + const y = cellY(r); 799 + for (let c = minC; c <= maxC; c += 1) { 800 + const index = r * cols + c; 801 + let heat = trail[index]; 802 + let beat = pulse[index]; 803 + let flash = streak[index]; 804 + let hush = quiet[index]; 805 + if (heat <= 0 && beat <= 0 && flash <= 0 && hush <= 0) continue; 806 + 807 + if (heat > 0) { 808 + heat *= decay; 809 + if (heat < 0.01) heat = 0; 810 + trail[index] = heat; 811 + } 812 + if (beat > 0) { 813 + beat *= pulseDecay; 814 + if (beat < 0.04) beat = 0; 815 + pulse[index] = beat; 816 + } 817 + if (flash > 0) { 818 + flash *= streakDecay; 819 + if (flash < 0.03) flash = 0; 820 + streak[index] = flash; 821 + } 822 + if (hush > 0) { 823 + hush *= streakDecay; 824 + if (hush < 0.03) hush = 0; 825 + quiet[index] = hush; 826 + } 827 + if (heat === 0 && beat === 0 && flash === 0 && hush === 0) continue; 828 + 829 + if (c < nextMinC) nextMinC = c; 830 + if (c > nextMaxC) nextMaxC = c; 831 + if (r < nextMinR) nextMinR = r; 832 + if (r > nextMaxR) nextMaxR = r; 833 + 834 + // Onder het spoor en de hartslag wisselen de tekens snel. 835 + if (dt > 0 && Math.random() < (heat * 9 + beat * 5 + flash * 6) * dt) glyphs[index] = randomGlyph(); 836 + 837 + // Een verse ring is roder dan het spoor; verder weg is hij al zwakker en daarmee 838 + // vanzelf grijzer. 839 + // In de stille cirkel van een smiley (hush) en tijdens de nasleep van een explosie 840 + // (calm) wijken het spoor en de ringen; de smiley en de hartslaglijn blijven. 841 + heat *= calm * (1 - hush); 842 + beat *= calm * (1 - hush); 843 + const lift = heat > beat ? heat : beat; 844 + const ring = Math.min(1, beat * options.heartRed); 845 + const glow = heat > ring ? heat : ring; 846 + const x = cellX(c); 847 + const sx = glyphs[index] * tileW; 848 + // Achter een smiley wordt het rustende veld stilgezet (hush), zodat het gezicht 849 + // niet in de wolken verdwijnt. De lijn en de smileys (flash) zijn vooral rood. 850 + const neutral = ambient[index] * calm * (1 - lift) * (1 - hush) + neutralMax * lift + streakNeutral * flash; 851 + const red = Math.max(glow * glow * accentMax, flash * streakAccent); 852 + ctx.clearRect(x, y, tileW, tileH); 853 + if (neutral >= MIN_ALPHA) { 854 + ctx.globalAlpha = neutral; 855 + ctx.drawImage(neutralAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH); 856 + } 857 + if (red > 0.01) { 858 + ctx.globalAlpha = Math.min(1, red); 859 + ctx.drawImage(accentAtlas, sx, 0, tileW, tileH, x, y, tileW, tileH); 860 + } 861 + } 862 + } 863 + 864 + ctx.globalAlpha = 1; 865 + minC = nextMinC; 866 + maxC = nextMaxC; 867 + minR = nextMinR; 868 + maxR = nextMaxR; 869 + } 870 + 871 + /* Zwarte rook die uit de kop van de paddenstoel spuwt en naar de bovenkant van het scherm 872 + klimt. Losse wolkjes die opstijgen, opzwellen en onderweg dunner worden. */ 873 + function drawSmoke(t, dt) { 874 + const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650)); 875 + const capY = nuke.y - 300 * size * easeOut(t / 2.2); 876 + const capRx = (50 + 190 * easeOut(t / 1.4)) * size; 877 + 878 + // Spuwen tussen 0,5 en 3,2 seconden, zo'n vijfentwintig wolkjes per seconde. Alle rook is 879 + // weg rond 5,5 seconden, als de letters gevallen zijn. 880 + if (dt > 0 && t > 0.5 && t < 3.2) { 881 + nuke.puffClock += dt * 25; 882 + while (nuke.puffClock >= 1) { 883 + nuke.puffClock -= 1; 884 + const startY = capY - Math.random() * 40 * size; 885 + nuke.puffs.push({ 886 + x: nuke.x + (Math.random() - 0.5) * capRx * 0.9, 887 + y: startY, 888 + // Snel genoeg om in een paar seconden de bovenkant te halen. 889 + vy: -(startY + 120) / (1.1 + Math.random() * 0.8), 890 + vx: (Math.random() - 0.5) * 50 * size, 891 + radius: (14 + Math.random() * 18) * size, 892 + born: t, 893 + seed: Math.random() * 10, 894 + }); 895 + } 896 + } 897 + 898 + const life = 2.4; 899 + const allFade = t < 4.2 ? 1 : Math.max(0, 1 - (t - 4.2) / 1.3); 900 + let keep = 0; 901 + for (const puff of nuke.puffs) { 902 + const age = t - puff.born; 903 + if (age >= life) continue; 904 + nuke.puffs[keep] = puff; 905 + keep += 1; 906 + 907 + const px = puff.x + puff.vx * age; 908 + const py = puff.y + puff.vy * age * (1 - age / (life * 2.4)); 909 + const radius = puff.radius * (1 + age * 0.85); 910 + const strength = 0.75 * (1 - age / life) * Math.min(1, age / 0.15) * allFade; 911 + if (py + radius < 0 || strength <= 0.01) continue; 912 + 913 + const c0 = Math.max(0, Math.floor((px - radius) / cellW)); 914 + const c1 = Math.min(cols - 1, Math.ceil((px + radius) / cellW)); 915 + const r0 = Math.max(0, Math.floor((py - radius) / cellH)); 916 + const r1 = Math.min(rows - 1, Math.ceil((py + radius) / cellH)); 917 + for (let r = r0; r <= r1; r += 1) { 918 + const dy = r * cellH + cellH / 2 - py; 919 + for (let c = c0; c <= c1; c += 1) { 920 + const dx = c * cellW + cellW / 2 - px; 921 + const angle = Math.atan2(dy, dx); 922 + const edge = 1 + 0.18 * Math.sin(angle * 5 + puff.seed + t * 2); 923 + const d = Math.sqrt(dx * dx + dy * dy) / (radius * edge); 924 + if (d >= 1) continue; 925 + ctx.globalAlpha = strength * (1 - d * d); 926 + ctx.drawImage(smokeAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH); 927 + } 928 + } 929 + } 930 + nuke.puffs.length = keep; 931 + } 932 + 933 + // 1 als het veld er gewoon staat; 0 tijdens de nasleep van een explosie. 934 + function fieldPresence(now) { 935 + const age = nukeAge(now); 936 + if (age < options.nukeFallAfter) return 1; 937 + if (age < options.nukeNormalAfter) return 0; 938 + return Math.min(1, (age - options.nukeNormalAfter) / 2); 939 + } 940 + 941 + function easeOut(value) { 942 + const v = Math.min(1, Math.max(0, value)); 943 + return 1 - (1 - v) ** 3; 944 + } 945 + 946 + /* De explosie, rechtstreeks op het canvas: een rode flits over het hele veld, een 947 + schokgolf die over het scherm rolt, een paddenstoelwolk van kokende letters, het veld 948 + dat schudt, en daarna vallen alle letters van het scherm. */ 949 + function drawNuke(now, dt) { 950 + const t = nukeAge(now); 951 + const accentAlpha = theme.mode === "light" ? 0.85 : 0.95; 952 + 953 + // Schudden: alleen het canvas, de inhoud van de pagina blijft staan. 954 + if (t < 1.3) { 955 + const shake = 12 * (1 - t / 1.3) ** 2; 956 + canvas.style.transform = `translate(${(Math.random() - 0.5) * 2 * shake}px, ${(Math.random() - 0.5) * 2 * shake}px)`; 957 + } else if (canvas.style.transform) { 958 + canvas.style.transform = ""; 959 + } 960 + 961 + // Flits: het hele veld licht even rood op. 962 + if (t < 0.7) { 963 + const flashAtlas = t < 0.2 ? yellowAtlas : t < 0.42 ? orangeAtlas : accentAtlas; 964 + ctx.globalAlpha = 0.6 * (1 - t / 0.7) ** 2; 965 + for (let r = 0; r < rows; r += 1) { 966 + const y = cellY(r); 967 + for (let c = 0; c < cols; c += 1) { 968 + ctx.drawImage(flashAtlas, glyphs[r * cols + c] * tileW, 0, tileW, tileH, cellX(c), y, tileW, tileH); 969 + } 970 + } 971 + } 972 + 973 + // Schokgolf: een ring die in anderhalve seconde voorbij de randen van het scherm rolt. 974 + if (t < 1.8) { 975 + const progress = t / 1.8; 976 + const reach = Math.hypot(window.innerWidth, window.innerHeight); 977 + const radius = reach * easeOut(progress); 978 + const band = 30 + 90 * (1 - progress); 979 + const strength = 0.95 * (1 - progress); 980 + const r0 = Math.max(0, Math.floor((nuke.y - radius - band) / cellH)); 981 + const r1 = Math.min(rows - 1, Math.ceil((nuke.y + radius + band) / cellH)); 982 + for (let r = r0; r <= r1; r += 1) { 983 + const dy = r * cellH + cellH / 2 - nuke.y; 984 + const outer = (radius + band) ** 2 - dy * dy; 985 + if (outer <= 0) continue; 986 + const reachX = Math.sqrt(outer); 987 + const c0 = Math.max(0, Math.floor((nuke.x - reachX) / cellW)); 988 + const c1 = Math.min(cols - 1, Math.ceil((nuke.x + reachX) / cellW)); 989 + for (let c = c0; c <= c1; c += 1) { 990 + const dx = c * cellW + cellW / 2 - nuke.x; 991 + const off = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius) / band; 992 + if (off >= 1) continue; 993 + const front = Math.sqrt(dx * dx + dy * dy) - radius; 994 + const waveAtlas = front > band * 0.3 ? yellowAtlas : front > -band * 0.3 ? orangeAtlas : accentAtlas; 995 + ctx.globalAlpha = strength * (1 - off); 996 + ctx.drawImage(waveAtlas, randomGlyph() * tileW, 0, tileW, tileH, cellX(c), cellY(r), tileW, tileH); 997 + } 998 + } 999 + } 1000 + 1001 + // Paddenstoelwolk: een kokende kop die opstijgt, een steel en een wolk over de grond. 1002 + if (t < 4.2) { 1003 + const grow = easeOut(t / 1.4); 1004 + const fade = t < 2.8 ? 1 : Math.max(0, 1 - (t - 2.8) / 1.4); 1005 + // Groot op een groot scherm, passend op een klein scherm. 1006 + const size = Math.min(1.7, Math.max(0.7, Math.min(window.innerWidth, window.innerHeight) / 650)); 1007 + const rise = 300 * size * easeOut(t / 2.2); 1008 + const capY = nuke.y - rise; 1009 + const capRx = (50 + 190 * grow) * size; 1010 + const capRy = (35 + 85 * grow) * size; 1011 + const baseRx = (70 + 230 * grow) * size; 1012 + const baseRy = (22 + 34 * grow) * size; 1013 + const spanX = Math.max(capRx * 1.25, baseRx); 1014 + const c0 = Math.max(0, Math.floor((nuke.x - spanX) / cellW)); 1015 + const c1 = Math.min(cols - 1, Math.ceil((nuke.x + spanX) / cellW)); 1016 + const r0 = Math.max(0, Math.floor((capY - capRy * 1.25) / cellH)); 1017 + const r1 = Math.min(rows - 1, Math.ceil((nuke.y + baseRy) / cellH)); 1018 + 1019 + for (let r = r0; r <= r1; r += 1) { 1020 + const py = r * cellH + cellH / 2; 1021 + for (let c = c0; c <= c1; c += 1) { 1022 + const dx = c * cellW + cellW / 2 - nuke.x; 1023 + let core = 0; 1024 + 1025 + const capDy = py - capY; 1026 + const angle = Math.atan2(capDy, dx); 1027 + const edge = 1 + 0.14 * Math.sin(angle * 7 + t * 2.5) + 0.08 * Math.sin(angle * 13 - t * 4); 1028 + const capD = Math.sqrt((dx / capRx) ** 2 + (capDy / capRy) ** 2) / edge; 1029 + if (capD < 1) core = Math.max(core, 1 - capD * 0.6); 1030 + 1031 + if (py > capY && py < nuke.y) { 1032 + const along = (py - capY) / Math.max(rise, 1); 1033 + const half = (18 + 14 * grow + 30 * grow * along * along) * size; 1034 + if (Math.abs(dx) < half) core = Math.max(core, 0.75); 1035 + } 1036 + 1037 + const baseD = Math.sqrt((dx / baseRx) ** 2 + ((py - nuke.y) / baseRy) ** 2); 1038 + if (baseD < 1 + 0.12 * Math.sin(dx * 0.05 + t * 3)) core = Math.max(core, 0.85 - baseD * 0.4); 1039 + 1040 + if (core <= 0) continue; 1041 + const x = cellX(c); 1042 + const y = cellY(r); 1043 + const glyph = randomGlyph() * tileW; 1044 + // Heet in de kern; naarmate de wolk afkoelt schuift alles richting rood. 1045 + const heatLevel = core - Math.max(0, t - 1.2) * 0.12 + (Math.random() - 0.5) * 0.12; 1046 + const fireAtlas = heatLevel > 0.8 ? yellowAtlas : heatLevel > 0.58 ? orangeAtlas : accentAtlas; 1047 + ctx.globalAlpha = Math.min(1, accentAlpha * core * fade); 1048 + ctx.drawImage(fireAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH); 1049 + ctx.globalAlpha = 0.35 * core * core * fade; 1050 + ctx.drawImage(neutralAtlas, glyph, 0, tileW, tileH, x, y, tileW, tileH); 1051 + } 1052 + } 1053 + } 1054 + 1055 + drawSmoke(t, dt); 1056 + 1057 + // De letters vallen: elk teken uit het rustende veld krijgt een eigen moment en valt 1058 + // dan met zwaartekracht van het scherm. 1059 + if (t >= options.nukeFallAfter && !fallers) { 1060 + const list = []; 1061 + for (let i = 0; i < ambient.length; i += 1) if (ambient[i] >= 0.012) list.push(i); 1062 + fallers = { 1063 + index: Int32Array.from(list), 1064 + y: new Float32Array(list.length), 1065 + vy: new Float32Array(list.length), 1066 + delay: Float32Array.from(list, () => Math.random() * 1.8), 1067 + }; 1068 + } 1069 + if (fallers && t < options.nukeNormalAfter) { 1070 + const since = t - options.nukeFallAfter; 1071 + const floor = canvas.height; 1072 + for (let i = 0; i < fallers.index.length; i += 1) { 1073 + const index = fallers.index[i]; 1074 + if (since > fallers.delay[i] && dt > 0) { 1075 + fallers.vy[i] += 1600 * dpr * dt; 1076 + fallers.y[i] += fallers.vy[i] * dt; 1077 + } 1078 + const r = (index / cols) | 0; 1079 + const y = cellY(r) + fallers.y[i]; 1080 + if (y > floor) continue; 1081 + // Tijdens het vallen iets duidelijker, anders zie je ze bijna niet gaan. 1082 + const boost = since > fallers.delay[i] ? 3.5 : 1; 1083 + ctx.globalAlpha = Math.min(0.5, ambient[index] * boost); 1084 + ctx.drawImage(neutralAtlas, glyphs[index] * tileW, 0, tileW, tileH, cellX(index % cols), y, tileW, tileH); 1085 + } 1086 + } 1087 + 1088 + ctx.globalAlpha = 1; 1089 + if (t >= options.nukeNormalAfter + 2) { 1090 + nuke = null; 1091 + fallers = null; 1092 + canvas.style.transform = ""; 1093 + } 1094 + } 1095 + 1096 + function render(now, dt) { 1097 + // Een venster zonder afmeting (geminimaliseerd of verborgen bij het laden): niets tekenen. 1098 + // drawImage van een leeg canvas gooit een fout, en die fout brak hier createGlyphField af. 1099 + // Bij de eerste resize gaat het vanzelf verder. 1100 + if (!neutralAtlas || !cols || !canvas.width || !canvas.height) return; 1101 + const hadTrail = minC <= maxC; 1102 + const ambientDue = now - lastAmbient >= options.ambientIntervalMs; 1103 + 1104 + // Los aangeroepen (thema, formaat) terwijl er niets veranderd is: niets tekenen. 1105 + if (!hadTrail && !ambientDue && dt === 0) return; 1106 + 1107 + if (ambientDue) { 1108 + const elapsed = Number.isFinite(lastAmbient) ? (now - lastAmbient) / 1000 : 0; 1109 + drawAmbient(now, elapsed); 1110 + lastAmbient = now; 1111 + } 1112 + 1113 + ctx.setTransform(1, 0, 0, 1, 0, 0); 1114 + ctx.clearRect(0, 0, canvas.width, canvas.height); 1115 + // Na een explosie ligt het veld stil tot nukeNormalAfter en komt het daarna terug. Een 1116 + // smiley maakt alleen een cirkel om zichzelf stil (quiet), niet het hele veld. 1117 + calm = fieldPresence(now); 1118 + if (calm > 0.01) { 1119 + ctx.globalAlpha = calm; 1120 + ctx.drawImage(layer, 0, 0); 1121 + ctx.globalAlpha = 1; 1122 + } 1123 + if (dt > 0) { 1124 + heartbeat(now); 1125 + heartLine(now); 1126 + drawSmileys(now); 1127 + } 1128 + drawTrail(dt); 1129 + if (nuke) drawNuke(now, dt); 1130 + } 1131 + 1132 + function loop(now) { 1133 + if (!running) return; 1134 + frameId = requestAnimationFrame(loop); 1135 + if (pointer && nukeAge(now) >= options.nukeHeartAfter) paintPointer(); 1136 + else if (nukeAge(now) < options.nukeHeartAfter) pointer = null; 1137 + // Dertig beelden per seconde is genoeg voor wisselende tekens; dat halveert het werk 1138 + // op een 60 Hz-scherm en meer op een sneller scherm. 1139 + if (lastFrame && now - lastFrame < 1000 / options.maxFps - 2) return; 1140 + const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.25) : 0; 1141 + lastFrame = now; 1142 + render(now, dt); 1143 + } 1144 + 1145 + function start() { 1146 + if (running || destroyed || reducedMotion.matches || document.hidden) return; 1147 + running = true; 1148 + lastFrame = 0; 1149 + frameId = requestAnimationFrame(loop); 1150 + } 1151 + 1152 + function stop() { 1153 + running = false; 1154 + cancelAnimationFrame(frameId); 1155 + } 1156 + 1157 + function onPointerMove(event) { 1158 + if (reducedMotion.matches) return; 1159 + const next = { x: event.clientX, y: event.clientY }; 1160 + // Na het verlaten van het venster geen streep trekken vanaf de oude plek. 1161 + if (!lastPointer) lastPointer = next; 1162 + pointer = next; 1163 + heartHome = next; 1164 + } 1165 + 1166 + function onPointerLeave() { 1167 + lastPointer = null; 1168 + pointer = null; 1169 + heartHome = null; 1170 + lastBeatSpot = null; 1171 + } 1172 + 1173 + function onVisibility() { 1174 + if (document.hidden) stop(); 1175 + else start(); 1176 + } 1177 + 1178 + function onMotionChange() { 1179 + if (reducedMotion.matches) { 1180 + stop(); 1181 + trail.fill(0); 1182 + pulse.fill(0); 1183 + rings = []; 1184 + smileys = []; 1185 + nuke = null; 1186 + fallers = null; 1187 + canvas.style.transform = ""; 1188 + streak.fill(0); 1189 + quiet.fill(0); 1190 + minC = minR = 1; 1191 + maxC = maxR = 0; 1192 + lastAmbient = -Infinity; 1193 + render(performance.now(), 0); 1194 + } else { 1195 + start(); 1196 + } 1197 + } 1198 + 1199 + let resizeTimer = 0; 1200 + function onResize() { 1201 + window.clearTimeout(resizeTimer); 1202 + resizeTimer = window.setTimeout(resize, 120); 1203 + } 1204 + 1205 + // Het thema wisselt via data-appearance op <html>; zie layout.css. 1206 + const themeObserver = new MutationObserver(applyTheme); 1207 + themeObserver.observe(document.documentElement, { 1208 + attributes: true, 1209 + attributeFilter: ["data-appearance", "class", "style"], 1210 + }); 1211 + 1212 + window.addEventListener("pointermove", onPointerMove, { passive: true }); 1213 + window.addEventListener("pointerdown", onPointerDown, { passive: true }); 1214 + document.documentElement.addEventListener("pointerleave", onPointerLeave); 1215 + window.addEventListener("blur", onPointerLeave); 1216 + window.addEventListener("resize", onResize); 1217 + document.addEventListener("visibilitychange", onVisibility); 1218 + reducedMotion.addEventListener("change", onMotionChange); 1219 + 1220 + /* Andere intensiteit of ander thema zonder het veld opnieuw te starten. Een nieuw thema zet nieuwe 1221 + tekens in elke cel; een andere intensiteit tekent alleen de rustlaag opnieuw. */ 1222 + function configure(next = {}) { 1223 + if (destroyed) return; 1224 + Object.assign(options, next); 1225 + if (next.glyphs) { 1226 + const nextSet = checkGlyphs(next.glyphs); 1227 + const changed = nextSet.length !== glyphSet.length || nextSet.some((glyph, index) => glyph !== glyphSet[index]); 1228 + if (changed) { 1229 + glyphSet = nextSet; 1230 + for (let i = 0; i < glyphs.length; i += 1) glyphs[i] = randomGlyph(); 1231 + buildAtlases(); 1232 + } 1233 + } 1234 + lastAmbient = -Infinity; 1235 + render(performance.now(), 0); 1236 + } 1237 + 1238 + resize(); 1239 + start(); 1240 + 1241 + return { destroy, configure }; 1242 + 1243 + function destroy() { 1244 + destroyed = true; 1245 + stop(); 1246 + window.clearTimeout(resizeTimer); 1247 + themeObserver.disconnect(); 1248 + window.removeEventListener("pointermove", onPointerMove); 1249 + window.removeEventListener("pointerdown", onPointerDown); 1250 + document.documentElement.removeEventListener("pointerleave", onPointerLeave); 1251 + window.removeEventListener("blur", onPointerLeave); 1252 + window.removeEventListener("resize", onResize); 1253 + document.removeEventListener("visibilitychange", onVisibility); 1254 + reducedMotion.removeEventListener("change", onMotionChange); 1255 + } 1256 +} 1257 + 1258 +// Opstart van het tekenveld op de startpagina van de Kennisbank (Wardenburg.Code.Tekenveld). 1259 +// maak-tekenveld.js zet dit achter de engine van Ember; samen vormen ze de JavaScriptExtension. 1260 +// De engine zelf (createGlyphField, glyphFieldSettings) komt ongewijzigd uit Ember. 1261 + 1262 +// Waar een klik iets doet of waar inhoud staat: geen achtergrond, dus geen smiley. 1263 +const KB_NIET_ACHTERGROND = [ 1264 + "a", 1265 + "button", 1266 + "input", 1267 + "select", 1268 + "textarea", 1269 + "label", 1270 + "form", 1271 + "summary", 1272 + "[role='button']", 1273 + "[role='dialog']", 1274 + ".navbar", 1275 + "#leftPanels", 1276 + "#rightPanels", 1277 + ".document-header", 1278 + ".wb-zoek > *", 1279 + ".wb-tegels li", 1280 + ".wb-home-onder", 1281 +].join(","); 1282 + 1283 +// De blokken van de startpagina; de hartslaglijn loopt bij voorkeur waar die niet staan. 1284 +const KB_BLOKKEN = ".wb-zoek > *, .wb-tegels li, .wb-home-onder, .navbar"; 1285 + 1286 +// Na zoveel tijd zonder muis of aanraking ligt het veld stil (scheelt rekenwerk en 1287 +// beeldverkeer op de terminalserver); bij de eerste beweging loopt het weer. 1288 +const KB_STILSTAND_MS = 60000; 1289 + 1290 +function kbTekenveld() { 1291 + // Alleen op de startpagina; de extensie wordt ook alleen daar geladen. 1292 + if (!document.querySelector(".wb-home")) return; 1293 + 1294 + // De Kennisbank heeft alleen een lichte stand; de engine leest die van <html>. 1295 + document.documentElement.setAttribute("data-appearance", "light"); 1296 + 1297 + const canvas = document.createElement("canvas"); 1298 + canvas.className = "kb-tekenveld"; 1299 + canvas.setAttribute("aria-hidden", "true"); 1300 + document.body.prepend(canvas); 1301 + 1302 + const opties = { 1303 + ...glyphFieldSettings({ intensity: "lijst" }), 1304 + // Geen atoomexplosie op de Kennisbank. 1305 + nukeClicks: Infinity, 1306 + isBackground: (event) => event.target instanceof Element && !event.target.closest(KB_NIET_ACHTERGROND), 1307 + obstacles: () => Array.from(document.querySelectorAll(KB_BLOKKEN), (element) => element.getBoundingClientRect()), 1308 + }; 1309 + 1310 + let veld = null; 1311 + let rustTimer = 0; 1312 + 1313 + function wakker() { 1314 + if (!veld) veld = createGlyphField(canvas, opties); 1315 + window.clearTimeout(rustTimer); 1316 + rustTimer = window.setTimeout(() => { 1317 + // Het laatste beeld blijft staan; alleen de animatie stopt. 1318 + veld.destroy(); 1319 + veld = null; 1320 + }, KB_STILSTAND_MS); 1321 + } 1322 + 1323 + window.addEventListener("pointermove", wakker, { passive: true }); 1324 + window.addEventListener("pointerdown", wakker, { passive: true }); 1325 + wakker(); 1326 +} 1327 + 1328 +if (document.readyState === "loading") { 1329 + document.addEventListener("DOMContentLoaded", kbTekenveld); 1330 +} else { 1331 + kbTekenveld(); 1332 +} 1333 + 1334 +})(); 1335 + - name
-
... ... @@ -1,0 +1,1 @@ 1 +Wardenburg tekenveld - parse
-
... ... @@ -1,0 +1,1 @@ 1 +Nee - use
-
... ... @@ -1,0 +1,1 @@ 1 +onDemand