diff --git a/src/static/js/idler.js b/src/static/js/idler.js index df39c89..2bc8420 100755 --- a/src/static/js/idler.js +++ b/src/static/js/idler.js @@ -1,105 +1,293 @@ -const balls = []; -const density = 0.00005; -let screenWidth = window.innerWidth + 10; -let screenHeight = window.innerHeight + 10; +/** + * Ambient background: "Contour Field" + * + * Thin nested contour lines trace level curves of one continuous, + * slowly-drifting 2D value-noise surface, marched with a classic + * marching-squares pass on a coarse grid. Every line on screen is a + * cross-section of the SAME field at a fixed elevation, so neighboring + * contours are geometrically correlated (they nest, they never cross, + * they bulge and pinch together) -- it reads as one continuous terrain + * rather than a scatter of independent strokes. Nods to the site + * owner's geospatial / data-surface work (watershed + elevation + * surfaces) without being literal about it. + */ +(function () { + "use strict"; -const MAX_DIST = 150; -const MAX_DIST_SQUARED = MAX_DIST * MAX_DIST; + var container = document.getElementById("bg"); + if (!container) return; -class Ball { - constructor(x, y, size, speed, angle) { - this.x = x; - this.y = y; - this.size = size; - this.speed = speed; - this.angle = angle; - this.calcChange(); - } + var canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + container.appendChild(canvas); + var ctx = canvas.getContext("2d"); + if (!ctx) return; - calcChange() { - const radians = (this.angle * Math.PI) / 180 - this.xSpeed = this.speed * Math.sin(radians); - this.ySpeed = this.speed * Math.cos(radians); - } + var reduceMotion = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; - update() { - this.x += this.xSpeed; - this.y += this.ySpeed; - if (this.x > screenWidth) { - this.x -= screenWidth; - } else if (this.x < 0) { - this.x += screenWidth; + // --------------------------------------------------------------- + // Value noise (hash lattice + smooth interpolation), no dependency. + // --------------------------------------------------------------- + var PERM_SIZE = 256; + var PERM_MASK = PERM_SIZE - 1; + var perm = new Uint8Array(PERM_SIZE); + (function buildPermutation() { + var table = new Uint8Array(PERM_SIZE); + for (var i = 0; i < PERM_SIZE; i++) table[i] = i; + for (var j = PERM_SIZE - 1; j > 0; j--) { + var k = (Math.random() * (j + 1)) | 0; + var tmp = table[j]; + table[j] = table[k]; + table[k] = tmp; } - if (this.y > screenHeight) { - this.y -= screenHeight; - } else if (this.y < 0) { - this.y += screenHeight; + perm.set(table); + })(); + + function lattice(ix, iy) { + var a = perm[ix & PERM_MASK]; + return perm[(a + iy) & PERM_MASK] / 255; + } + + function fade(t) { + return t * t * t * (t * (t * 6 - 15) + 10); + } + + function noise2D(x, y) { + var xi = Math.floor(x); + var yi = Math.floor(y); + var xf = x - xi; + var yf = y - yi; + var u = fade(xf); + var v = fade(yf); + var n00 = lattice(xi, yi); + var n10 = lattice(xi + 1, yi); + var n01 = lattice(xi, yi + 1); + var n11 = lattice(xi + 1, yi + 1); + var nx0 = n00 + (n10 - n00) * u; + var nx1 = n01 + (n11 - n01) * u; + return nx0 + (nx1 - nx0) * v; + } + + // Two-octave fractal sum, warped slightly so the field folds and + // breathes rather than just sliding sideways. + var FREQ_1 = 1 / 460; // px per full noise cycle, octave 1 + var FREQ_2 = FREQ_1 * 2.3; // octave 2 + function fieldValue(x, y, driftX, driftY, warpX, warpY) { + var n1 = noise2D((x + driftX) * FREQ_1, (y + driftY) * FREQ_1); + var n2 = noise2D( + (x + driftX + warpX) * FREQ_2, + (y + driftY + warpY) * FREQ_2 + ); + var fbm = n1 * 0.68 + n2 * 0.32; // ~[0,1] + return fbm * 2 - 1; // ~[-1,1], centered + } + + // --------------------------------------------------------------- + // Marching squares + // Corner bit order: v0=top-left, v1=top-right, v2=bottom-right, v3=bottom-left + // Edge order: 0=top, 1=right, 2=bottom, 3=left + // --------------------------------------------------------------- + var CASE_EDGES = [ + null, // 0 + [0, 3], // 1 + [0, 1], // 2 + [1, 3], // 3 + [1, 2], // 4 + [0, 3, 1, 2], // 5 (saddle, two segments) + [0, 2], // 6 + [2, 3], // 7 + [2, 3], // 8 + [0, 2], // 9 + [0, 1, 2, 3], // 10 (saddle, two segments) + [1, 2], // 11 + [1, 3], // 12 + [0, 1], // 13 + [0, 3], // 14 + null, // 15 + ]; + + function edgePoint(edge, x, y, cell, v0, v1, v2, v3, threshold) { + var t; + switch (edge) { + case 0: // top: v0 -> v1 + t = (threshold - v0) / (v1 - v0 || 1e-6); + return [x + cell * t, y]; + case 1: // right: v1 -> v2 + t = (threshold - v1) / (v2 - v1 || 1e-6); + return [x + cell, y + cell * t]; + case 2: // bottom: v3 -> v2 + t = (threshold - v3) / (v2 - v3 || 1e-6); + return [x + cell * t, y + cell]; + case 3: // left: v0 -> v3 + t = (threshold - v0) / (v3 - v0 || 1e-6); + return [x, y + cell * t]; } - this.draw(); } - draw() { - stroke(200, 100); - strokeWeight(2); - fill(0); - ellipse(this.x, this.y, this.size, this.size); - } -} + // --------------------------------------------------------------- + // Layout state + // --------------------------------------------------------------- + var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); + var width = 0; + var height = 0; + var cellSize = 28; + var cols = 0; + var rows = 0; + var grid = null; // Float32Array of (cols+1)*(rows+1) -function setup() { - frameRate(15); - const pixels = screenHeight * screenWidth; - const canvas = createCanvas(screenWidth, screenHeight); - canvas.parent('bg'); - for (let i = 0; i < pixels * density; i++) { - balls.push(new Ball( - random(screenWidth), - random(screenHeight), - random(6) + 3, - Math.exp(random(4) + 3) / 1000 + 1, - random(360) - )); - } - stroke(255); -} + var LEVELS = [-0.5, -0.333, -0.167, 0, 0.167, 0.333, 0.5]; + var BASE_COLOR = "233,230,223"; // --ink + var ACCENT_COLOR = "217,100,92"; // --accent-bright, reserved for the zero contour -function windowResized() { - screenWidth = window.innerWidth + 10; - screenHeight = window.innerHeight + 10; - resizeCanvas(screenWidth, screenHeight); -} + function resize() { + width = window.innerWidth; + height = window.innerHeight; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); -function draw() { - background(24); - - for (let i = 0; i < balls.length; i++) { - balls[i].update(); + var targetCols = 68; + cellSize = Math.max(22, width / targetCols); + cols = Math.ceil(width / cellSize) + 1; + rows = Math.ceil(height / cellSize) + 1; + grid = new Float32Array((cols + 1) * (rows + 1)); } - // Draw connection lines with additive blending so overlaps brighten - blendMode(ADD); - strokeWeight(2); - - for (let i = 0; i < balls.length - 1; i++) { - const a = balls[i]; - for (let j = i + 1; j < balls.length; j++) { - const b = balls[j]; - const dx = b.x - a.x; - const dy = b.y - a.y; - const distSquared = dx * dx + dy * dy; - - if (distSquared < MAX_DIST_SQUARED) { - const distance = Math.sqrt(distSquared); - if (distance < 75) { - stroke(255, 85); - } else { - const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / MAX_DIST); - stroke(255, chance < 0.5 ? 40 : 75); - } - line(a.x, a.y, b.x, b.y); + function sampleGrid(driftX, driftY, warpX, warpY) { + var idx = 0; + for (var ry = 0; ry <= rows; ry++) { + var y = ry * cellSize; + for (var rx = 0; rx <= cols; rx++) { + var x = rx * cellSize; + grid[idx++] = fieldValue(x, y, driftX, driftY, warpX, warpY); } } } - blendMode(BLEND); -} + function drawContours() { + var stride = cols + 1; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + + for (var li = 0; li < LEVELS.length; li++) { + var threshold = LEVELS[li]; + var isZero = threshold === 0; + var depth = Math.abs(threshold) / 0.5; // 0 (center) .. 1 (outer) + var alpha = isZero ? 0.5 : 0.3 + 0.16 * (1 - depth); + ctx.strokeStyle = isZero + ? "rgba(" + ACCENT_COLOR + "," + alpha + ")" + : "rgba(" + BASE_COLOR + "," + alpha + ")"; + ctx.lineWidth = isZero ? 1.3 : 1; + + ctx.beginPath(); + for (var cy = 0; cy < rows; cy++) { + var rowOff = cy * stride; + var rowOffNext = rowOff + stride; + var y = cy * cellSize; + for (var cx = 0; cx < cols; cx++) { + var x = cx * cellSize; + var v0 = grid[rowOff + cx]; + var v1 = grid[rowOff + cx + 1]; + var v2 = grid[rowOffNext + cx + 1]; + var v3 = grid[rowOffNext + cx]; + + var caseIndex = + (v0 >= threshold ? 1 : 0) | + (v1 >= threshold ? 2 : 0) | + (v2 >= threshold ? 4 : 0) | + (v3 >= threshold ? 8 : 0); + + var edges = CASE_EDGES[caseIndex]; + if (!edges) continue; + + for (var s = 0; s < edges.length; s += 2) { + var p0 = edgePoint(edges[s], x, y, cellSize, v0, v1, v2, v3, threshold); + var p1 = edgePoint( + edges[s + 1], + x, + y, + cellSize, + v0, + v1, + v2, + v3, + threshold + ); + ctx.moveTo(p0[0], p0[1]); + ctx.lineTo(p1[0], p1[1]); + } + } + } + ctx.stroke(); + } + } + + function render(driftX, driftY, warpX, warpY) { + ctx.clearRect(0, 0, width, height); + sampleGrid(driftX, driftY, warpX, warpY); + drawContours(); + } + + // --------------------------------------------------------------- + // Animation: slow drift, throttled to a modest frame rate since the + // motion itself unfolds over tens of seconds -- no need to sample it + // faster than that. + // --------------------------------------------------------------- + var DRIFT_VX = 3.2; // px/sec + var DRIFT_VY = -2.1; // px/sec + var startTime = null; + var lastFrameTime = 0; + var FRAME_INTERVAL = 1000 / 15; // ~15fps grid refresh, plenty for slow drift + var rafId = null; + + function frame(now) { + rafId = requestAnimationFrame(frame); + if (now - lastFrameTime < FRAME_INTERVAL) return; + lastFrameTime = now; + if (startTime === null) startTime = now; + var t = (now - startTime) / 1000; + + var driftX = t * DRIFT_VX; + var driftY = t * DRIFT_VY; + var warpX = Math.sin(t * 0.013) * 60; + var warpY = Math.cos(t * 0.009) * 60; + render(driftX, driftY, warpX, warpY); + } + + function start() { + resize(); + if (reduceMotion) { + render(0, 0, 0, 0); + return; + } + rafId = requestAnimationFrame(frame); + } + + var resizeTimer = null; + window.addEventListener("resize", function () { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + resize(); + if (reduceMotion) render(0, 0, 0, 0); + }, 150); + }); + + document.addEventListener("visibilitychange", function () { + if (reduceMotion) return; + if (document.hidden) { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } else if (rafId === null) { + lastFrameTime = 0; + rafId = requestAnimationFrame(frame); + } + }); + + start(); +})(); diff --git a/src/templates/header.html b/src/templates/header.html index 7238bab..b00a9dc 100755 --- a/src/templates/header.html +++ b/src/templates/header.html @@ -11,7 +11,6 @@ - @@ -76,7 +75,6 @@ - {{ var['title'] }} {% block header %} diff --git a/src/templates/home.html b/src/templates/home.html index b5072b2..8a76260 100755 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -86,6 +86,7 @@ {{ f.summary or f.tagline }} {% endfor %} +