diff --git a/sass/_base.scss b/sass/_base.scss index 215cdc3..e3537ec 100644 --- a/sass/_base.scss +++ b/sass/_base.scss @@ -8,13 +8,14 @@ html { font-size: $font-size; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background: $bg; } body { font-family: $font-sans; line-height: $line-height; color: $text; - background: $bg; + background: transparent; min-height: 100vh; } diff --git a/static/background.js b/static/background.js new file mode 100644 index 0000000..e0d7046 --- /dev/null +++ b/static/background.js @@ -0,0 +1,29 @@ +// Background dispatcher — picks a random renderer on each page load +(function () { + 'use strict'; + + var backgrounds = [ + 'mandelbrot.js', + 'bg-julia.js', + 'bg-reaction-diffusion.js', + 'bg-flowfield.js', + 'bg-lorenz.js', + 'bg-waves.js', + 'bg-contours.js', + 'bg-life.js', + 'bg-lissajous.js', + 'bg-sierpinski.js', + 'bg-domain.js', + ]; + + var pick = backgrounds[Math.floor(Math.random() * backgrounds.length)]; + + // Resolve path relative to this script's location + var scripts = document.getElementsByTagName('script'); + var thisScript = scripts[scripts.length - 1]; + var basePath = thisScript.src.substring(0, thisScript.src.lastIndexOf('/') + 1); + + var script = document.createElement('script'); + script.src = basePath + pick; + document.body.appendChild(script); +})(); diff --git a/static/bg-contours.js b/static/bg-contours.js new file mode 100644 index 0000000..51e2316 --- /dev/null +++ b/static/bg-contours.js @@ -0,0 +1,119 @@ +// Perlin noise contour lines — slowly morphing topographic map +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + + // Permutation table + const PERM = new Uint8Array(512); + for (let i = 0; i < 256; i++) PERM[i] = i; + for (let i = 255; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = PERM[i]; PERM[i] = PERM[j]; PERM[j] = tmp; + } + for (let i = 0; i < 256; i++) PERM[256 + i] = PERM[i]; + + function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + function lerp(a, b, t) { return a + (b - a) * t; } + + function grad(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); + } + + function noise2d(x, y) { + const xi = Math.floor(x) & 255; + const yi = Math.floor(y) & 255; + const xf = x - Math.floor(x); + const yf = y - Math.floor(y); + const u = fade(xf); + const v = fade(yf); + const aa = PERM[PERM[xi] + yi]; + const ab = PERM[PERM[xi] + yi + 1]; + const ba = PERM[PERM[xi + 1] + yi]; + const bb = PERM[PERM[xi + 1] + yi + 1]; + return lerp( + lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u), + lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), + v + ); + } + + function fbm(x, y, octaves) { + let val = 0, amp = 1, freq = 1, total = 0; + for (let i = 0; i < octaves; i++) { + val += noise2d(x * freq, y * freq) * amp; + total += amp; + amp *= 0.5; + freq *= 2; + } + return val / total; + } + + let time = 0; + let animId; + let lastFrame = 0; + const frameInterval = 80; // Slower — contours don't need fast updates + const numContours = 12; + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + const scale = 0.012; + + for (let py = 0; py < H; py++) { + for (let px = 0; px < W; px++) { + const n = fbm(px * scale + time, py * scale, 4); + // Normalize to 0-1 + const v = (n + 1) * 0.5; + + // Create contour lines: sharp brightness at specific iso-values + const contourVal = v * numContours; + const frac = contourVal - Math.floor(contourVal); + // Thin contour line when frac is near 0 or 1 + const edge = Math.min(frac, 1 - frac); + const line = edge < 0.06 ? 1.0 - edge / 0.06 : 0; + + // Base fill between contours + const fill = v * 0.15; + const brightness = fill + line * 0.7; + + const idx = (py * W + px) * 4; + buf[idx] = (10 + brightness * 98) | 0; + buf[idx + 1] = (13 + brightness * 127) | 0; + buf[idx + 2] = (20 + brightness * 235) | 0; + buf[idx + 3] = 255; + } + } + + ctx.putImageData(imgData, 0, 0); + time += 0.008; + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-domain.js b/static/bg-domain.js new file mode 100644 index 0000000..72abfb0 --- /dev/null +++ b/static/bg-domain.js @@ -0,0 +1,101 @@ +// Domain coloring — complex function visualization with slowly morphing parameters +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + + let time = 0; + let animId; + let lastFrame = 0; + const frameInterval = 50; + + // Complex arithmetic helpers + function cmul(ar, ai, br, bi) { return [ar * br - ai * bi, ar * bi + ai * br]; } + function cabs(r, i) { return Math.sqrt(r * r + i * i); } + function carg(r, i) { return Math.atan2(i, r); } + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + const scale = 4; + const aspect = W / H; + + // Morphing parameter for the complex function + const pr = Math.sin(time * 0.3) * 0.8; + const pi = Math.cos(time * 0.2) * 0.8; + + for (let py = 0; py < H; py++) { + for (let px = 0; px < W; px++) { + // Map pixel to complex plane + let zr = (px / W - 0.5) * scale * aspect; + let zi = (py / H - 0.5) * scale; + + // f(z) = z^3 + p*z + 1 (morphing cubic) + const z2 = cmul(zr, zi, zr, zi); + const z3 = cmul(z2[0], z2[1], zr, zi); + const pz = cmul(pr, pi, zr, zi); + const wr = z3[0] + pz[0] + 1; + const wi = z3[1] + pz[1]; + + const mag = cabs(wr, wi); + const arg = carg(wr, wi); + + // Map argument (angle) to hue, magnitude to brightness + // Use site palette colors based on angle + const t = (arg / (Math.PI * 2) + 1) % 1; + const brightness = 1 - 1 / (1 + mag * 0.3); + + // Contour lines on magnitude + const logMag = Math.log(mag + 1); + const contour = Math.abs(logMag - Math.round(logMag)) < 0.08 ? 0.3 : 0; + + let r, g, b; + if (t < 0.33) { + const s = t / 0.33; + r = 10 + s * 98; g = 13 + s * 127; b = 20 + s * 235; + } else if (t < 0.66) { + const s = (t - 0.33) / 0.33; + r = 108 - s * 74; g = 140 + s * 71; b = 255 - s * 17; + } else { + const s = (t - 0.66) / 0.34; + r = 34 + s * 158; g = 211 - s * 79; b = 238 + s * 14; + } + + const v = brightness + contour; + const idx = (py * W + px) * 4; + buf[idx] = Math.min(255, (r * v) | 0); + buf[idx + 1] = Math.min(255, (g * v) | 0); + buf[idx + 2] = Math.min(255, (b * v) | 0); + buf[idx + 3] = 255; + } + } + + ctx.putImageData(imgData, 0, 0); + time += 0.008; + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-flowfield.js b/static/bg-flowfield.js new file mode 100644 index 0000000..9ff1731 --- /dev/null +++ b/static/bg-flowfield.js @@ -0,0 +1,141 @@ +// Flow field background — particles tracing through curl noise +// Produces river-like streams that slowly shift +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + // Simple value noise (no dependencies) + const PERM = new Uint8Array(512); + for (let i = 0; i < 256; i++) PERM[i] = i; + for (let i = 255; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = PERM[i]; PERM[i] = PERM[j]; PERM[j] = tmp; + } + for (let i = 0; i < 256; i++) PERM[256 + i] = PERM[i]; + + function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + + function grad(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); + } + + function noise2d(x, y) { + const xi = x | 0; + const yi = y | 0; + const xf = x - xi; + const yf = y - yi; + const u = fade(xf); + const v = fade(yf); + const X = xi & 255; + const Y = yi & 255; + const aa = PERM[PERM[X] + Y]; + const ab = PERM[PERM[X] + Y + 1]; + const ba = PERM[PERM[X + 1] + Y]; + const bb = PERM[PERM[X + 1] + Y + 1]; + const x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u); + const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u); + return lerp(x1, x2, v); + } + + function lerp(a, b, t) { return a + (b - a) * t; } + + // Particles + const NUM = 800; + const particles = []; + for (let i = 0; i < NUM; i++) { + particles.push({ + x: Math.random() * W, + y: Math.random() * H, + age: Math.floor(Math.random() * 200), + maxAge: 150 + Math.floor(Math.random() * 150), + }); + } + + // Trail canvas — accumulates fading particle paths + const trailCanvas = document.createElement('canvas'); + trailCanvas.width = W; + trailCanvas.height = H; + const tCtx = trailCanvas.getContext('2d'); + tCtx.fillStyle = 'rgb(10, 13, 20)'; + tCtx.fillRect(0, 0, W, H); + + let time = Math.random() * 100; + let animId; + let lastFrame = 0; + const frameInterval = 33; // ~30fps for smooth trails + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + // Fade trails slightly + tCtx.fillStyle = 'rgba(10, 13, 20, 0.02)'; + tCtx.fillRect(0, 0, W, H); + + const scale = 0.008; + + for (let i = 0; i < NUM; i++) { + const p = particles[i]; + + // Get flow angle from noise + const angle = noise2d(p.x * scale, p.y * scale + time * 0.1) * Math.PI * 4; + + const prevX = p.x; + const prevY = p.y; + + p.x += Math.cos(angle) * 0.8; + p.y += Math.sin(angle) * 0.8; + p.age++; + + // Fade in/out based on age + const life = p.age / p.maxAge; + const alpha = life < 0.1 ? life / 0.1 : life > 0.9 ? (1 - life) / 0.1 : 1; + + // Draw trail segment + tCtx.strokeStyle = 'rgba(108, 140, 255, ' + (alpha * 0.6) + ')'; + tCtx.lineWidth = 1; + tCtx.beginPath(); + tCtx.moveTo(prevX, prevY); + tCtx.lineTo(p.x, p.y); + tCtx.stroke(); + + // Reset if out of bounds or too old + if (p.x < 0 || p.x >= W || p.y < 0 || p.y >= H || p.age >= p.maxAge) { + p.x = Math.random() * W; + p.y = Math.random() * H; + p.age = 0; + p.maxAge = 150 + Math.floor(Math.random() * 150); + } + } + + time += 0.003; + + // Copy trail canvas to main canvas + ctx.drawImage(trailCanvas, 0, 0); + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 300; i++) render(i * frameInterval); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-julia.js b/static/bg-julia.js new file mode 100644 index 0000000..566157b --- /dev/null +++ b/static/bg-julia.js @@ -0,0 +1,136 @@ +// Julia set fractal background — animated parameter drift +// Same rendering approach as Mandelbrot: low-res canvas, smooth coloring +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + // Palette — same scheme as Mandelbrot for visual consistency + const palette = new Array(256); + for (let i = 0; i < 256; i++) { + const t = i / 255; + let r, g, b; + if (t < 0.25) { + const s = t / 0.25; + r = lerp(8, 108, s); g = lerp(12, 140, s); b = lerp(28, 255, s); + } else if (t < 0.5) { + const s = (t - 0.25) / 0.25; + r = lerp(108, 34, s); g = lerp(140, 211, s); b = lerp(255, 238, s); + } else if (t < 0.75) { + const s = (t - 0.5) / 0.25; + r = lerp(34, 192, s); g = lerp(211, 132, s); b = lerp(238, 252, s); + } else { + const s = (t - 0.75) / 0.25; + r = lerp(192, 8, s); g = lerp(132, 12, s); b = lerp(252, 28, s); + } + palette[i] = [r | 0, g | 0, b | 0]; + } + + function lerp(a, b, t) { return a + (b - a) * t; } + + // Interesting Julia constant orbits — parameter c drifts along these paths + const paths = [ + // Orbit near the main cardioid boundary — produces spirals + function (t) { + const a = t * Math.PI * 2; + const r = 0.7885; + return { cr: r * Math.cos(a), ci: r * Math.sin(a) }; + }, + // Orbit near Douady rabbit → dendrite transition + function (t) { + const a = t * Math.PI * 2; + return { cr: -0.8 + 0.15 * Math.cos(a), ci: 0.156 + 0.05 * Math.sin(a) }; + }, + // Orbit near Siegel disc + function (t) { + const a = t * Math.PI * 2; + return { cr: -0.4 + 0.05 * Math.cos(a), ci: 0.6 + 0.05 * Math.sin(a) }; + }, + ]; + + let pathIdx = Math.floor(Math.random() * paths.length); + let time = Math.random(); // Start at random phase + + const maxIter = 80; + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + + const zoom = 3.2; + let animId; + let lastFrame = 0; + const frameInterval = 50; // ~20fps + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + const path = paths[pathIdx]; + const c = path(time); + const cr = c.cr; + const ci = c.ci; + + const aspect = W / H; + const halfW = zoom * 0.5; + const halfH = halfW / aspect; + const dx = zoom / W; + const dy = (zoom / aspect) / H; + + for (let py = 0; py < H; py++) { + const y0 = -halfH + py * dy; + for (let px = 0; px < W; px++) { + const x0 = -halfW + px * dx; + let zr = x0, zi = y0; + let iter = 0; + + while (zr * zr + zi * zi <= 4 && iter < maxIter) { + const tmp = zr * zr - zi * zi + cr; + zi = 2 * zr * zi + ci; + zr = tmp; + iter++; + } + + const idx = (py * W + px) * 4; + if (iter === maxIter) { + buf[idx] = 10; buf[idx + 1] = 13; buf[idx + 2] = 20; buf[idx + 3] = 255; + } else { + const log2 = Math.log(2); + const nu = Math.log(Math.log(zr * zr + zi * zi) / log2) / log2; + const smooth = (iter + 1 - nu) / maxIter; + const ci2 = ((smooth * 255 * 3) | 0) % 256; + const col = palette[ci2]; + buf[idx] = col[0]; buf[idx + 1] = col[1]; buf[idx + 2] = col[2]; buf[idx + 3] = 255; + } + } + } + + ctx.putImageData(imgData, 0, 0); + + // Drift c parameter slowly + time += 0.0003; + if (time > 1.0) { + time = 0; + pathIdx = (pathIdx + 1) % paths.length; + } + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-life.js b/static/bg-life.js new file mode 100644 index 0000000..24f79e4 --- /dev/null +++ b/static/bg-life.js @@ -0,0 +1,192 @@ +// Conway's Game of Life — new random seed every 10 seconds +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const CELL = 3; // Pixel size per cell + const W = Math.ceil(360 / CELL); + const H = Math.ceil(240 / CELL); + canvas.width = W * CELL; + canvas.height = H * CELL; + + let grid = new Uint8Array(W * H); + let next = new Uint8Array(W * H); + + // Seed patterns + function seedRandom(density) { + for (let i = 0; i < W * H; i++) { + grid[i] = Math.random() < density ? 1 : 0; + } + } + + function seedSymmetric() { + // Generate left half randomly, mirror to right + for (let y = 0; y < H; y++) { + for (let x = 0; x < Math.ceil(W / 2); x++) { + const alive = Math.random() < 0.3 ? 1 : 0; + grid[y * W + x] = alive; + grid[y * W + (W - 1 - x)] = alive; + } + } + } + + function seedClusters() { + grid.fill(0); + const numClusters = 8 + Math.floor(Math.random() * 8); + for (let c = 0; c < numClusters; c++) { + const cx = Math.floor(Math.random() * W); + const cy = Math.floor(Math.random() * H); + const r = 5 + Math.floor(Math.random() * 10); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy <= r * r && Math.random() < 0.45) { + const x = (cx + dx + W) % W; + const y = (cy + dy + H) % H; + grid[y * W + x] = 1; + } + } + } + } + } + + function seedSoup() { + grid.fill(0); + // Dense central soup + const sx = Math.floor(W * 0.3); + const sy = Math.floor(H * 0.3); + const sw = Math.floor(W * 0.4); + const sh = Math.floor(H * 0.4); + for (let y = sy; y < sy + sh; y++) { + for (let x = sx; x < sx + sw; x++) { + grid[y * W + x] = Math.random() < 0.4 ? 1 : 0; + } + } + } + + const seedFns = [ + function () { seedRandom(0.25); }, + function () { seedRandom(0.35); }, + seedSymmetric, + seedClusters, + seedSoup, + ]; + + let seedIdx = Math.floor(Math.random() * seedFns.length); + seedFns[seedIdx](); + + // Track generation for reseed timing + let generation = 0; + let lastSeed = 0; + const reseedInterval = 10000; // 10 seconds + + // Fade buffer for glow effect + const imgData = ctx.createImageData(W * CELL, H * CELL); + const buf = imgData.data; + // Age tracking for cell brightness + const age = new Float32Array(W * H); + + function step() { + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + // Count neighbors (wrapping) + let count = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = (x + dx + W) % W; + const ny = (y + dy + H) % H; + count += grid[ny * W + nx]; + } + } + + const idx = y * W + x; + const alive = grid[idx]; + + if (alive) { + next[idx] = (count === 2 || count === 3) ? 1 : 0; + } else { + next[idx] = (count === 3) ? 1 : 0; + } + } + } + + // Swap + const tmp = grid; grid = next; next = tmp; + generation++; + } + + function draw() { + // Update age: alive cells brighten, dead cells fade + for (let i = 0; i < W * H; i++) { + if (grid[i]) { + age[i] = Math.min(1, age[i] + 0.3); + } else { + age[i] = Math.max(0, age[i] - 0.02); + } + } + + // Render cells + const cw = W * CELL; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const v = age[y * W + x]; + + // Color: dark background → accent blue when alive + const r = (10 + v * 98) | 0; + const g = (13 + v * 127) | 0; + const b = (20 + v * 235) | 0; + + // Fill cell pixels + for (let cy = 0; cy < CELL; cy++) { + for (let cx = 0; cx < CELL; cx++) { + const idx = ((y * CELL + cy) * cw + (x * CELL + cx)) * 4; + buf[idx] = r; + buf[idx + 1] = g; + buf[idx + 2] = b; + buf[idx + 3] = 255; + } + } + } + } + + ctx.putImageData(imgData, 0, 0); + } + + let animId; + let lastFrame = 0; + const frameInterval = 80; // ~12fps — deliberate, cellular automata look better slow + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + step(); + draw(); + + // Reseed every 10 seconds + if (timestamp - lastSeed > reseedInterval) { + lastSeed = timestamp; + seedIdx = (seedIdx + 1) % seedFns.length; + seedFns[seedIdx](); + age.fill(0); + } + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 50; i++) step(); + draw(); + } else { + document.addEventListener('visibilitychange', onVisibility); + lastSeed = performance.now(); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-lissajous.js b/static/bg-lissajous.js new file mode 100644 index 0000000..e5f1924 --- /dev/null +++ b/static/bg-lissajous.js @@ -0,0 +1,76 @@ +// Lissajous curves — slowly drifting frequency ratios produce evolving knots +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + const trail = document.createElement('canvas'); + trail.width = W; + trail.height = H; + const tCtx = trail.getContext('2d'); + tCtx.fillStyle = 'rgb(10, 13, 20)'; + tCtx.fillRect(0, 0, W, H); + + // Multiple curves with slightly different parameters + const curves = [ + { a: 3, b: 2, delta: 0, color: 'rgba(108, 140, 255, 0.4)', drift: 0.0007 }, + { a: 5, b: 4, delta: Math.PI / 4, color: 'rgba(34, 211, 238, 0.3)', drift: 0.0005 }, + { a: 7, b: 6, delta: Math.PI / 3, color: 'rgba(192, 132, 252, 0.3)', drift: 0.0009 }, + ]; + + let time = 0; + let animId; + let lastFrame = 0; + const frameInterval = 33; + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + tCtx.fillStyle = 'rgba(10, 13, 20, 0.008)'; + tCtx.fillRect(0, 0, W, H); + + for (let c = 0; c < curves.length; c++) { + const curve = curves[c]; + const a = curve.a + Math.sin(time * curve.drift * 10) * 0.5; + const b = curve.b + Math.cos(time * curve.drift * 8) * 0.3; + const delta = curve.delta + time * 0.02; + + tCtx.strokeStyle = curve.color; + tCtx.lineWidth = 1; + tCtx.beginPath(); + + for (let t = 0; t < Math.PI * 2; t += 0.01) { + const x = W / 2 + Math.sin(a * t + delta) * (W * 0.4); + const y = H / 2 + Math.sin(b * t) * (H * 0.4); + if (t === 0) tCtx.moveTo(x, y); + else tCtx.lineTo(x, y); + } + tCtx.stroke(); + } + + time += 0.015; + ctx.drawImage(trail, 0, 0); + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-lorenz.js b/static/bg-lorenz.js new file mode 100644 index 0000000..3739368 --- /dev/null +++ b/static/bg-lorenz.js @@ -0,0 +1,111 @@ +// Lorenz strange attractor — glowing chaotic orbit traces +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + // Trail canvas + const trail = document.createElement('canvas'); + trail.width = W; + trail.height = H; + const tCtx = trail.getContext('2d'); + tCtx.fillStyle = 'rgb(10, 13, 20)'; + tCtx.fillRect(0, 0, W, H); + + // Lorenz parameters + const sigma = 10; + const rho = 28; + const beta = 8 / 3; + const dt = 0.005; + + // Multiple traces for richer visual + const traces = []; + for (let i = 0; i < 3; i++) { + traces.push({ + x: 1 + Math.random() * 0.1, + y: 1 + Math.random() * 0.1, + z: 1 + Math.random() * 0.1, + color: [ + [108, 140, 255], + [34, 211, 238], + [192, 132, 252], + ][i], + }); + } + + // Project 3D → 2D (simple orthographic, rotated slowly) + let angle = 0; + + function project(x, y, z) { + const ca = Math.cos(angle); + const sa = Math.sin(angle); + const rx = x * ca - y * sa; + const ry = x * sa + y * ca; + // Scale and center + return { + px: W / 2 + rx * 5.5, + py: H / 2 - z * 3.5 + 70, + }; + } + + let animId; + let lastFrame = 0; + const frameInterval = 33; + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + // Fade trails + tCtx.fillStyle = 'rgba(10, 13, 20, 0.015)'; + tCtx.fillRect(0, 0, W, H); + + for (let t = 0; t < traces.length; t++) { + const tr = traces[t]; + const p1 = project(tr.x, tr.y, tr.z); + + // Integrate 4 steps per frame + for (let s = 0; s < 4; s++) { + const dx = sigma * (tr.y - tr.x) * dt; + const dy = (tr.x * (rho - tr.z) - tr.y) * dt; + const dz = (tr.x * tr.y - beta * tr.z) * dt; + tr.x += dx; + tr.y += dy; + tr.z += dz; + } + + const p2 = project(tr.x, tr.y, tr.z); + + tCtx.strokeStyle = 'rgba(' + tr.color[0] + ',' + tr.color[1] + ',' + tr.color[2] + ', 0.5)'; + tCtx.lineWidth = 1; + tCtx.beginPath(); + tCtx.moveTo(p1.px, p1.py); + tCtx.lineTo(p2.px, p2.py); + tCtx.stroke(); + } + + angle += 0.0003; + ctx.drawImage(trail, 0, 0); + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 500; i++) render(i * frameInterval); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-reaction-diffusion.js b/static/bg-reaction-diffusion.js new file mode 100644 index 0000000..37cff7a --- /dev/null +++ b/static/bg-reaction-diffusion.js @@ -0,0 +1,125 @@ +// Reaction-diffusion (Gray-Scott model) background +// Slowly evolving Turing patterns — spots, stripes, coral +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 200; + const H = 133; + canvas.width = W; + canvas.height = H; + + // Gray-Scott parameters — different presets give different patterns + const presets = [ + { f: 0.0545, k: 0.062, name: 'spots' }, + { f: 0.042, k: 0.063, name: 'stripes' }, + { f: 0.035, k: 0.065, name: 'coral' }, + { f: 0.025, k: 0.06, name: 'worms' }, + ]; + + const preset = presets[Math.floor(Math.random() * presets.length)]; + const f = preset.f; + const k = preset.k; + const Da = 1.0; + const Db = 0.5; + + // Two chemical concentrations + const size = W * H; + let a = new Float32Array(size); + let b = new Float32Array(size); + let nextA = new Float32Array(size); + let nextB = new Float32Array(size); + + // Initialize: all A=1, B=0, with a seeded region of B + for (let i = 0; i < size; i++) { a[i] = 1.0; b[i] = 0.0; } + + // Seed several random spots of chemical B + for (let s = 0; s < 12; s++) { + const sx = 20 + Math.floor(Math.random() * (W - 40)); + const sy = 20 + Math.floor(Math.random() * (H - 40)); + const r = 3 + Math.floor(Math.random() * 4); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy <= r * r) { + const idx = ((sy + dy + H) % H) * W + ((sx + dx + W) % W); + b[idx] = 1.0; + } + } + } + } + + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + + let animId; + let lastFrame = 0; + const frameInterval = 50; + + function step() { + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const idx = y * W + x; + + // Laplacian with wrapping + const up = ((y - 1 + H) % H) * W + x; + const dn = ((y + 1) % H) * W + x; + const lt = y * W + ((x - 1 + W) % W); + const rt = y * W + ((x + 1) % W); + + const lapA = a[up] + a[dn] + a[lt] + a[rt] - 4 * a[idx]; + const lapB = b[up] + b[dn] + b[lt] + b[rt] - 4 * b[idx]; + + const aVal = a[idx]; + const bVal = b[idx]; + const abb = aVal * bVal * bVal; + + nextA[idx] = aVal + Da * lapA - abb + f * (1.0 - aVal); + nextB[idx] = bVal + Db * lapB + abb - (k + f) * bVal; + } + } + + // Swap buffers + const tmpA = a; a = nextA; nextA = tmpA; + const tmpB = b; b = nextB; nextB = tmpB; + } + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + // Run multiple simulation steps per frame for visible evolution + for (let s = 0; s < 8; s++) step(); + + // Render: map chemical B concentration to color + for (let i = 0; i < size; i++) { + const v = Math.min(1, Math.max(0, b[i])); + const idx = i * 4; + + // Dark blue/cyan palette matching site colors + buf[idx] = (10 + v * 98) | 0; // R: 10 → 108 + buf[idx + 1] = (13 + v * 127) | 0; // G: 13 → 140 + buf[idx + 2] = (20 + v * 235) | 0; // B: 20 → 255 + buf[idx + 3] = 255; + } + + ctx.putImageData(imgData, 0, 0); + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 2000; i++) step(); + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-sierpinski.js b/static/bg-sierpinski.js new file mode 100644 index 0000000..68ff138 --- /dev/null +++ b/static/bg-sierpinski.js @@ -0,0 +1,94 @@ +// Sierpinski triangle — chaos game with slowly rotating vertices +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + // Accumulation buffer — counts how many times each pixel is hit + const accum = new Float32Array(W * H); + + // Fill background + for (let i = 0; i < W * H * 4; i += 4) { + buf[i] = 10; buf[i + 1] = 13; buf[i + 2] = 20; buf[i + 3] = 255; + } + + let angle = 0; + let px = W / 2; + let py = H / 2; + + let animId; + let lastFrame = 0; + const frameInterval = 50; + let generation = 0; + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + // Three vertices of the triangle, slowly rotating + const cx = W / 2; + const cy = H / 2; + const r = Math.min(W, H) * 0.45; + const vertices = []; + for (let i = 0; i < 3; i++) { + const a = angle + (i * Math.PI * 2) / 3 - Math.PI / 2; + vertices.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); + } + + // Run chaos game iterations + for (let i = 0; i < 500; i++) { + const v = vertices[Math.floor(Math.random() * 3)]; + px = (px + v.x) / 2; + py = (py + v.y) / 2; + + const ix = Math.floor(px); + const iy = Math.floor(py); + if (ix >= 0 && ix < W && iy >= 0 && iy < H) { + accum[iy * W + ix] += 0.15; + } + } + + // Fade accumulation slowly + for (let i = 0; i < W * H; i++) { + accum[i] *= 0.998; + const v = Math.min(1, accum[i]); + const idx = i * 4; + buf[idx] = (10 + v * 98) | 0; + buf[idx + 1] = (13 + v * 127) | 0; + buf[idx + 2] = (20 + v * 235) | 0; + } + + ctx.putImageData(imgData, 0, 0); + + angle += 0.001; + generation++; + + // Periodically clear for fresh pattern + if (generation % 3000 === 0) { + accum.fill(0); + } + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 100; i++) render(i * frameInterval); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/static/bg-waves.js b/static/bg-waves.js new file mode 100644 index 0000000..6c13f17 --- /dev/null +++ b/static/bg-waves.js @@ -0,0 +1,88 @@ +// Wave interference — overlapping concentric sine waves creating moiré patterns +(function () { + 'use strict'; + + const canvas = document.getElementById('fractal-bg'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const W = 360; + const H = 240; + canvas.width = W; + canvas.height = H; + + const imgData = ctx.createImageData(W, H); + const buf = imgData.data; + + // Wave sources — slowly drifting positions + const sources = [ + { x: W * 0.3, y: H * 0.4, freq: 0.15, phase: 0, dx: 0.12, dy: 0.08 }, + { x: W * 0.7, y: H * 0.6, freq: 0.18, phase: 1.5, dx: -0.09, dy: 0.11 }, + { x: W * 0.5, y: H * 0.2, freq: 0.12, phase: 3.0, dx: 0.07, dy: -0.06 }, + { x: W * 0.2, y: H * 0.8, freq: 0.20, phase: 4.5, dx: -0.05, dy: -0.10 }, + ]; + + let time = 0; + let animId; + let lastFrame = 0; + const frameInterval = 50; + + function render(timestamp) { + animId = requestAnimationFrame(render); + if (timestamp - lastFrame < frameInterval) return; + lastFrame = timestamp; + + for (let py = 0; py < H; py++) { + for (let px = 0; px < W; px++) { + let sum = 0; + + for (let s = 0; s < sources.length; s++) { + const src = sources[s]; + const dx = px - src.x; + const dy = py - src.y; + const dist = Math.sqrt(dx * dx + dy * dy); + sum += Math.sin(dist * src.freq - time * 2 + src.phase); + } + + // Normalize to 0-1 + const v = (sum / sources.length + 1) * 0.5; + const idx = (py * W + px) * 4; + + // Map to site palette + buf[idx] = (10 + v * 98) | 0; + buf[idx + 1] = (13 + v * 127) | 0; + buf[idx + 2] = (20 + v * 235) | 0; + buf[idx + 3] = 255; + } + } + + ctx.putImageData(imgData, 0, 0); + + // Drift sources slowly + for (let s = 0; s < sources.length; s++) { + const src = sources[s]; + src.x += src.dx; + src.y += src.dy; + // Bounce off edges + if (src.x < 0 || src.x >= W) src.dx *= -1; + if (src.y < 0 || src.y >= H) src.dy *= -1; + src.x = Math.max(0, Math.min(W - 1, src.x)); + src.y = Math.max(0, Math.min(H - 1, src.y)); + } + + time += 0.03; + } + + function onVisibility() { + if (document.hidden) { cancelAnimationFrame(animId); } + else { animId = requestAnimationFrame(render); } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + render(0); + cancelAnimationFrame(animId); + } else { + document.addEventListener('visibilitychange', onVisibility); + animId = requestAnimationFrame(render); + } +})(); diff --git a/templates/base.html b/templates/base.html index 787a923..b56211b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -95,6 +95,6 @@ - +