(function() { const canvas = document.getElementById("ascii-bg"); if (!canvas || !canvas.getContext) { return; } const context = canvas.getContext("2d", { alpha: true }); const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const CHARS = "ยท.:-=+*#"; const CELL_WIDTH = 14; const CELL_HEIGHT = 19; const FRAME_MS = 1000 / 18; const BASE_DENSITY = 0.09; const WAVE_DENSITY = 0.24; let columns = 0; let rows = 0; let dither = new Float32Array(0); let baseColor = "rgba(255, 255, 255, 1)"; let accentColor = "rgba(235, 82, 52, 1)"; let time = 0; let lastFrame = 0; let rafId = 0; // Interfering waves, centred on the viewport, give the field slow bright and // dark swells. Which cells are lit comes from a fixed per-cell dither value, // so the texture stays even everywhere instead of pooling into blobs, and // cells brighten as a swell passes rather than flickering at random. const waveAt = (x, y, t) => { const radius = Math.sqrt( ((x - columns / 2) * 0.55) ** 2 + (y - rows / 2) ** 2 ); const a = Math.sin(x * 0.068 + t * 0.55); const b = Math.sin(y * 0.1 - t * 0.42); const c = Math.sin((x * 0.4 + y * 0.6) * 0.07 + t * 0.33); const d = Math.sin(radius * 0.1 - t * 0.6); return (a + b + c + d) / 8 + 0.5; }; const buildDither = () => { dither = new Float32Array(columns * rows); for (let row = 0; row < rows; row += 1) { for (let column = 0; column < columns; column += 1) { const noise = Math.sin(column * 127.1 + row * 311.7) * 43758.5453; dither[row * columns + column] = noise - Math.floor(noise); } } }; const readThemeColors = () => { const styles = getComputedStyle(document.body); baseColor = styles.getPropertyValue("--ascii-base").trim() || baseColor; accentColor = styles.getPropertyValue("--ascii-accent").trim() || accentColor; }; const resize = () => { const ratio = Math.min(window.devicePixelRatio || 1, 2); const width = window.innerWidth; const height = window.innerHeight; canvas.width = Math.floor(width * ratio); canvas.height = Math.floor(height * ratio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; context.setTransform(ratio, 0, 0, ratio, 0, 0); context.font = `12px "JetBrains Mono", ui-monospace, monospace`; context.textBaseline = "top"; columns = Math.ceil(width / CELL_WIDTH); rows = Math.ceil(height / CELL_HEIGHT); buildDither(); }; const draw = () => { const width = canvas.width; const height = canvas.height; context.save(); context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, width, height); context.restore(); let currentColor = null; for (let row = 0; row < rows; row += 1) { for (let column = 0; column < columns; column += 1) { const level = waveAt(column, row, time); if (dither[row * columns + column] > BASE_DENSITY + level * WAVE_DENSITY) { continue; } const glyph = CHARS[Math.min(CHARS.length - 1, Math.floor(level * CHARS.length))]; const color = level > 0.9 ? accentColor : baseColor; if (color !== currentColor) { context.fillStyle = color; currentColor = color; } context.globalAlpha = 0.06 + level * 0.5; context.fillText(glyph, column * CELL_WIDTH, row * CELL_HEIGHT); } } context.globalAlpha = 1; }; const loop = (timestamp) => { rafId = window.requestAnimationFrame(loop); if (timestamp - lastFrame < FRAME_MS) { return; } lastFrame = timestamp; time += 0.021; draw(); }; const stop = () => { if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; } }; const start = () => { if (rafId || prefersReducedMotion.matches) { return; } lastFrame = 0; rafId = window.requestAnimationFrame(loop); }; const reset = () => { readThemeColors(); resize(); draw(); }; reset(); start(); let resizeTimer = 0; window.addEventListener("resize", () => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(reset, 150); }); document.addEventListener("visibilitychange", () => { if (document.hidden) { stop(); return; } start(); }); prefersReducedMotion.addEventListener("change", () => { stop(); start(); draw(); }); })();