Wijzigen voor document Tekenveld
Laatst gewijzigd door Jeeves de Butler op 23-10-2026 02:42
Van 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
Naar 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
Samenvatting
-
Objecten (0 gewijzigd, 0 toegevoegd, 1 verwijderd)
Details
- XWiki.JavaScriptExtension[0]
-
- cache
-
... ... @@ -1,1 +1,0 @@ 1 -long - code
-
... ... @@ -1,1335 +1,0 @@ 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,1 +1,0 @@ 1 -Wardenburg tekenveld - parse
-
... ... @@ -1,1 +1,0 @@ 1 -Nee - use
-
... ... @@ -1,1 +1,0 @@ 1 -onDemand