contour map

This commit is contained in:
2026-09-06 13:13:39 -05:00
parent bbbf9795bc
commit 062b76af09
3 changed files with 277 additions and 90 deletions

View File

@@ -1,105 +1,293 @@
const balls = []; /**
const density = 0.00005; * Ambient background: "Contour Field"
let screenWidth = window.innerWidth + 10; *
let screenHeight = window.innerHeight + 10; * 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; var container = document.getElementById("bg");
const MAX_DIST_SQUARED = MAX_DIST * MAX_DIST; if (!container) return;
class Ball { var canvas = document.createElement("canvas");
constructor(x, y, size, speed, angle) { canvas.setAttribute("aria-hidden", "true");
this.x = x; container.appendChild(canvas);
this.y = y; var ctx = canvas.getContext("2d");
this.size = size; if (!ctx) return;
this.speed = speed;
this.angle = angle; var reduceMotion =
this.calcChange(); window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------------------------------------------------------------
// 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;
}
perm.set(table);
})();
function lattice(ix, iy) {
var a = perm[ix & PERM_MASK];
return perm[(a + iy) & PERM_MASK] / 255;
} }
calcChange() { function fade(t) {
const radians = (this.angle * Math.PI) / 180 return t * t * t * (t * (t * 6 - 15) + 10);
this.xSpeed = this.speed * Math.sin(radians);
this.ySpeed = this.speed * Math.cos(radians);
} }
update() { function noise2D(x, y) {
this.x += this.xSpeed; var xi = Math.floor(x);
this.y += this.ySpeed; var yi = Math.floor(y);
if (this.x > screenWidth) { var xf = x - xi;
this.x -= screenWidth; var yf = y - yi;
} else if (this.x < 0) { var u = fade(xf);
this.x += screenWidth; var v = fade(yf);
} var n00 = lattice(xi, yi);
if (this.y > screenHeight) { var n10 = lattice(xi + 1, yi);
this.y -= screenHeight; var n01 = lattice(xi, yi + 1);
} else if (this.y < 0) { var n11 = lattice(xi + 1, yi + 1);
this.y += screenHeight; var nx0 = n00 + (n10 - n00) * u;
} var nx1 = n01 + (n11 - n01) * u;
this.draw(); return nx0 + (nx1 - nx0) * v;
} }
draw() { // Two-octave fractal sum, warped slightly so the field folds and
stroke(200, 100); // breathes rather than just sliding sideways.
strokeWeight(2); var FREQ_1 = 1 / 460; // px per full noise cycle, octave 1
fill(0); var FREQ_2 = FREQ_1 * 2.3; // octave 2
ellipse(this.x, this.y, this.size, this.size); 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,
function setup() { (y + driftY + warpY) * FREQ_2
frameRate(15); );
const pixels = screenHeight * screenWidth; var fbm = n1 * 0.68 + n2 * 0.32; // ~[0,1]
const canvas = createCanvas(screenWidth, screenHeight); return fbm * 2 - 1; // ~[-1,1], centered
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);
}
function windowResized() {
screenWidth = window.innerWidth + 10;
screenHeight = window.innerHeight + 10;
resizeCanvas(screenWidth, screenHeight);
}
function draw() {
background(24);
for (let i = 0; i < balls.length; i++) {
balls[i].update();
} }
// Draw connection lines with additive blending so overlaps brighten // ---------------------------------------------------------------
blendMode(ADD); // Marching squares
strokeWeight(2); // 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
];
for (let i = 0; i < balls.length - 1; i++) { function edgePoint(edge, x, y, cell, v0, v1, v2, v3, threshold) {
const a = balls[i]; var t;
for (let j = i + 1; j < balls.length; j++) { switch (edge) {
const b = balls[j]; case 0: // top: v0 -> v1
const dx = b.x - a.x; t = (threshold - v0) / (v1 - v0 || 1e-6);
const dy = b.y - a.y; return [x + cell * t, y];
const distSquared = dx * dx + dy * dy; case 1: // right: v1 -> v2
t = (threshold - v1) / (v2 - v1 || 1e-6);
if (distSquared < MAX_DIST_SQUARED) { return [x + cell, y + cell * t];
const distance = Math.sqrt(distSquared); case 2: // bottom: v3 -> v2
if (distance < 75) { t = (threshold - v3) / (v2 - v3 || 1e-6);
stroke(255, 85); return [x + cell * t, y + cell];
} else { case 3: // left: v0 -> v3
const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / MAX_DIST); t = (threshold - v0) / (v3 - v0 || 1e-6);
stroke(255, chance < 0.5 ? 40 : 75); return [x, y + cell * t];
} }
line(a.x, a.y, b.x, b.y); }
// ---------------------------------------------------------------
// 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)
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 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);
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));
}
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();
})();

View File

@@ -11,7 +11,6 @@
<!-- Resource hints for performance --> <!-- Resource hints for performance -->
<link rel="preconnect" href="https://www.googletagmanager.com" /> <link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<meta name="description" content="{{ var['description'] }}" /> <meta name="description" content="{{ var['description'] }}" />
<meta property="og:title" content="Andrew Simonson" /> <meta property="og:title" content="Andrew Simonson" />
<meta property="og:description" content="{{ var['description'] }}" /> <meta property="og:description" content="{{ var['description'] }}" />
@@ -76,7 +75,6 @@
<link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" /> <link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" />
<script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script> <script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/idler.js') }}"></script> <script defer src="{{ url_for('static', filename='js/idler.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/p5@1.4.1/lib/p5.min.js"></script>
<title>{{ var['title'] }}</title> <title>{{ var['title'] }}</title>
</head> </head>
{% block header %} {% block header %}

View File

@@ -86,6 +86,7 @@
<span>{{ f.summary or f.tagline }}</span> <span>{{ f.summary or f.tagline }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ol>
</section> </section>
<section class="section" aria-labelledby="site-heading"> <section class="section" aria-labelledby="site-heading">