diff --git a/docs/overrides/javascripts/rapyer-background.js b/docs/overrides/javascripts/rapyer-background.js new file mode 100644 index 00000000..ce62233d --- /dev/null +++ b/docs/overrides/javascripts/rapyer-background.js @@ -0,0 +1,401 @@ +/* + * Rapyer animated documentation background. + * + * An ASCII plasma terminal: a grid of monospace glyphs whose brightness rides + * a drifting plasma field, with phosphor glow, CRT scanlines and idle flicker. + * Every few seconds a write pulses out from a random cell and briefly lights + * the field it passes through. + * + * Renders into a single fixed canvas behind the page. Fully decorative: + * without JavaScript, or with `prefers-reduced-motion`, the docs keep their + * plain background. + */ +(function () { + "use strict"; + + // mkdocs-material's instant navigation re-evaluates this script on every + // page change, so make initialization idempotent. + if (window.__rapyerBackground) { + return; + } + + var CONFIG = { + ramp: " .:-=+*#@", // dark to bright + cell: 15, // glyph height in px; the grid steps at 0.6x this horizontally + levels: 9, // glyph ramp depth + scale: 1, // plasma zoom; higher is busier + speed: 0.5, // plasma tempo + contrast: 1.12, + glow: 0.7, // phosphor bloom on the brightest glyphs + flicker: 0.1, // idle terminal liveliness + scanlines: 0.18, // CRT line darkening + vignette: 0.45, + contentScrim: 0.55, // how far the field is muted behind the text column + fps: 24, // terminals do not need 60 + maxCells: 14000, + }; + + var PALETTES = { + slate: { + base: "#1a1a1a", + baseRgb: "26, 26, 26", + // glyph ramp stops, dark to bright + stops: [ + [90, 26, 22], + [178, 44, 36], + [220, 56, 45], + [255, 107, 95], + [255, 214, 205], + ], + minAlpha: 0.1, + maxAlpha: 0.55, + scanline: "0, 0, 0", + intensity: 1, + }, + default: { + base: "#ffffff", + baseRgb: "255, 255, 255", + stops: [ + [255, 200, 192], + [255, 150, 138], + [226, 78, 66], + [186, 44, 34], + [130, 24, 18], + ], + minAlpha: 0.06, + maxAlpha: 0.34, + scanline: "120, 40, 34", + intensity: 0.75, + }, + }; + + var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + + var canvas = document.createElement("canvas"); + canvas.className = "rapyer-bg"; + canvas.setAttribute("aria-hidden", "true"); + + var ctx = canvas.getContext("2d", { alpha: false }); + if (!ctx) { + return; + } + + window.__rapyerBackground = true; + document.body.appendChild(canvas); + document.documentElement.classList.add("rapyer-bg-on"); + + var width = 0; + var height = 0; + var cellW = 0; + var cellH = 0; + var cols = 0; + var rows = 0; + var levels = new Int8Array(0); + var centreX = 0; + var centreY = 0; + var palette = PALETTES.slate; + var colours = []; // one rgba string per ramp level + var glyphs = CONFIG.ramp.split(""); + var scrim = null; + var vignette = null; + var writes = []; // expanding wavefronts from an atomic write + var nextWrite = 0; + var frame = null; + var lastDraw = 0; + var startedAt = 0; + + function rand(min, max) { + return min + Math.random() * (max - min); + } + + function mix(a, b, t) { + return a + (b - a) * t; + } + + /* Build the per-level colour ramp once per palette change. */ + function buildColours() { + colours = []; + var stops = palette.stops; + var count = Math.max(2, CONFIG.levels); + for (var i = 0; i < count; i++) { + var t = i / (count - 1); + var pos = t * (stops.length - 1); + var lo = Math.floor(pos); + var hi = Math.min(stops.length - 1, lo + 1); + var f = pos - lo; + var r = Math.round(mix(stops[lo][0], stops[hi][0], f)); + var g = Math.round(mix(stops[lo][1], stops[hi][1], f)); + var b = Math.round(mix(stops[lo][2], stops[hi][2], f)); + var alpha = mix(palette.minAlpha, palette.maxAlpha, Math.pow(t, 1.4)); + colours.push("rgba(" + r + ", " + g + ", " + b + ", " + alpha + ")"); + } + } + + /* The text column runs down the middle, so mute the field there and let it + breathe in the margins. */ + function buildOverlays() { + if (!width || !height) { + return; + } + var rgb = palette.baseRgb; + var strength = CONFIG.contentScrim; + + scrim = ctx.createLinearGradient(0, 0, width, 0); + scrim.addColorStop(0, "rgba(" + rgb + ", 0)"); + scrim.addColorStop(0.22, "rgba(" + rgb + ", " + strength * 0.55 + ")"); + scrim.addColorStop(0.5, "rgba(" + rgb + ", " + strength + ")"); + scrim.addColorStop(0.78, "rgba(" + rgb + ", " + strength * 0.55 + ")"); + scrim.addColorStop(1, "rgba(" + rgb + ", 0)"); + + vignette = ctx.createRadialGradient( + width / 2, + height / 2, + Math.min(width, height) * 0.25, + width / 2, + height / 2, + Math.max(width, height) * 0.78 + ); + vignette.addColorStop(0, "rgba(" + rgb + ", 0)"); + vignette.addColorStop(1, "rgba(" + rgb + ", " + CONFIG.vignette + ")"); + } + + function readPalette() { + var scheme = document.body.getAttribute("data-md-color-scheme"); + palette = PALETTES[scheme] || PALETTES.slate; + document.documentElement.style.setProperty("--rapyer-bg-base", palette.base); + buildColours(); + buildOverlays(); + } + + function resize() { + width = window.innerWidth; + height = window.innerHeight; + var dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Grow the cell until the glyph count is affordable. + var size = CONFIG.cell; + for (;;) { + cellH = size; + cellW = Math.round(size * 0.6) || 1; + cols = Math.ceil(width / cellW) + 1; + rows = Math.ceil(height / cellH) + 1; + // The upper bound only exists so a pathological maxCells cannot spin + // here; it is far past the size any real viewport settles on. + if (cols * rows <= CONFIG.maxCells || size >= 96) { + break; + } + size += 2; + } + + centreX = (cols * (cellW / cellH)) / 2; + centreY = rows / 2; + levels = new Int8Array(cols * rows); + ctx.font = cellH + 'px "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace'; + ctx.textBaseline = "top"; + ctx.textAlign = "left"; + buildOverlays(); + } + + function spawnWrite() { + writes.push({ + x: Math.random() * width, + y: Math.random() * height, + radius: 0, + speed: rand(260, 420), // px per second + life: 1, + }); + } + + /* Plasma value at a cell, -1..1. Coordinates arrive in glyph-height units so + the field stays isotropic despite cells being taller than they are wide. */ + function field(nx, ny, t) { + var s = 0.13 * CONFIG.scale; + var dx = nx - centreX; + var dy = ny - centreY; + return ( + Math.sin(nx * s * 1.1 + t) + + Math.sin(ny * s * 1.3 - t * 0.85) + + Math.sin((nx + ny) * s * 0.8 + t * 1.25) + + Math.sin(Math.sqrt(dx * dx + dy * dy) * s * 1.4 - t * 1.1) + ) / 4; + } + + function computeLevels(t) { + var top = CONFIG.levels - 1; + var i = 0; + var aspect = cellW / cellH; + for (var row = 0; row < rows; row++) { + var py = row * cellH; + for (var col = 0; col < cols; col++) { + var px = col * cellW; + var v = field(col * aspect, row, t); + + // Atomic writes brighten the wavefront they are passing through. + for (var w = 0; w < writes.length; w++) { + var wr = writes[w]; + var dx = px - wr.x; + var dy = py - wr.y; + var d = Math.sqrt(dx * dx + dy * dy) - wr.radius; + if (d > -70 && d < 70) { + v += Math.cos((d / 70) * Math.PI * 0.5) * wr.life * 0.85; + } + } + + v = (v * 0.5 + 0.5) * CONFIG.contrast - (CONFIG.contrast - 1) * 0.5; + var level = Math.round(v * top); + levels[i++] = level < 0 ? 0 : level > top ? top : level; + } + } + } + + function draw(t, dt) { + ctx.fillStyle = palette.base; + ctx.fillRect(0, 0, width, height); + + computeLevels(t); + + ctx.globalAlpha = 1 - Math.random() * CONFIG.flicker * palette.intensity; + + // One pass per ramp level: fillStyle is the costly call, so set it once. + var top = CONFIG.levels - 1; + for (var level = 1; level < CONFIG.levels; level++) { + var glyph = glyphs[Math.round((level / top) * (glyphs.length - 1))]; + if (glyph === " ") { + continue; + } + ctx.fillStyle = colours[level]; + if (CONFIG.glow > 0 && level >= top - 1) { + ctx.shadowColor = colours[top]; + ctx.shadowBlur = CONFIG.glow * cellH * 0.55; + } else { + ctx.shadowBlur = 0; + } + var i = 0; + for (var row = 0; row < rows; row++) { + var y = row * cellH; + for (var col = 0; col < cols; col++) { + if (levels[i++] === level) { + ctx.fillText(glyph, col * cellW, y); + } + } + } + } + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + + if (CONFIG.scanlines > 0) { + ctx.fillStyle = + "rgba(" + palette.scanline + ", " + CONFIG.scanlines * palette.intensity + ")"; + for (var sy = 0; sy < height; sy += 3) { + ctx.fillRect(0, sy, width, 1); + } + } + + if (vignette) { + ctx.fillStyle = vignette; + ctx.fillRect(0, 0, width, height); + } + if (scrim) { + ctx.fillStyle = scrim; + ctx.fillRect(0, 0, width, height); + } + + // Advance the write wavefronts. + for (var w = writes.length - 1; w >= 0; w--) { + var wr = writes[w]; + wr.radius += wr.speed * dt; + wr.life -= dt * 0.42; + if (wr.life <= 0) { + writes.splice(w, 1); + } + } + } + + function loop(now) { + frame = window.requestAnimationFrame(loop); + var interval = 1000 / CONFIG.fps; + if (now - lastDraw < interval) { + return; + } + var dt = lastDraw ? Math.min((now - lastDraw) / 1000, 0.25) : interval / 1000; + lastDraw = now; + if (!startedAt) { + startedAt = now; + } + + if (now >= nextWrite) { + spawnWrite(); + nextWrite = now + rand(2600, 6500); + } + draw(((now - startedAt) / 1000) * CONFIG.speed, dt); + } + + function start() { + if (frame !== null || reduceMotion.matches) { + return; + } + lastDraw = 0; + frame = window.requestAnimationFrame(loop); + } + + function stop() { + if (frame !== null) { + window.cancelAnimationFrame(frame); + frame = null; + } + } + + function renderStill() { + writes.length = 0; + draw(0, 0); + } + + function applyMotionPreference() { + if (reduceMotion.matches) { + stop(); + renderStill(); + } else { + start(); + } + } + + resize(); + readPalette(); + + window.addEventListener("resize", function () { + resize(); + if (reduceMotion.matches) { + renderStill(); + } + }); + + document.addEventListener("visibilitychange", function () { + if (document.hidden) { + stop(); + } else { + applyMotionPreference(); + } + }); + + // Follow the palette toggle without a reload. + new MutationObserver(function () { + readPalette(); + if (reduceMotion.matches) { + renderStill(); + } + }).observe(document.body, { + attributes: true, + attributeFilter: ["data-md-color-scheme"], + }); + + if (typeof reduceMotion.addEventListener === "function") { + reduceMotion.addEventListener("change", applyMotionPreference); + } else if (typeof reduceMotion.addListener === "function") { + reduceMotion.addListener(applyMotionPreference); + } + + applyMotionPreference(); +})(); diff --git a/docs/overrides/stylesheets/extra.css b/docs/overrides/stylesheets/extra.css index 862c59b3..ac54f98d 100644 --- a/docs/overrides/stylesheets/extra.css +++ b/docs/overrides/stylesheets/extra.css @@ -108,4 +108,36 @@ ::-webkit-scrollbar-thumb:hover { background: var(--md-primary-fg-color--light); +} + +/* Animated background + ------------------------------------------------------------------ + Driven by javascripts/rapyer-background.js. The rules below only take + effect once that script has attached its canvas and flagged the root + element, so visitors without JavaScript keep the plain background. */ + +html.rapyer-bg-on { + background-color: var(--rapyer-bg-base, #1a1a1a); +} + +/* Let the canvas, which sits at z-index -1, show through the page. */ +html.rapyer-bg-on body { + background-color: transparent; +} + +.rapyer-bg { + position: fixed; + inset: 0; + display: block; + width: 100%; + height: 100%; + z-index: -1; + pointer-events: none; +} + +/* Sidebars and the content column ride on top of the canvas, so keep the + surfaces that carry body text readable. */ +html.rapyer-bg-on .md-footer { + background-color: rgba(45, 45, 45, 0.92); + backdrop-filter: blur(6px); } \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 371204f7..00b833a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,4 +98,7 @@ extra: link: https://github.com/imaginary-cherry/rapyer extra_css: - - stylesheets/extra.css \ No newline at end of file + - stylesheets/extra.css + +extra_javascript: + - javascripts/rapyer-background.js \ No newline at end of file