Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/feedthejoe / src/ui/terrain.js |
| Date | Not specified in source |
| Repository | Janady13/feedthejoe |
| Revision | 0e608a2b515a959bf35d2783428766624400ca3c |
| Branch | main |
| Path or archive member | src/ui/terrain.js |
| Original SHA-256 | 1504d0e3d20a7b11854f56293d5081a0333705f9d1553035583567210ef417ea |
| Git blob SHA | c89de7165632e6305838b57b5bc1e2aa1cf8f2fc |
| Reading-copy SHA-256 | 1504d0e3d20a7b11854f56293d5081a0333705f9d1553035583567210ef417ea |
Recovered source transcript
Download complete source text ↓259 lines · 9.8 KB
/**
* The terrain.
*
* A drawing of the actual shape of the corpus.
*
* The 34 pieces of work are embedded in 384 dimensions, projected to a plane by
* SMACOF, and summed into a scalar field. What you see is the contour map of
* that field — the measured topography of everything Joseph has written. The
* chapters form a massif; theory sits on its own ridge; the papers stand apart.
* None of that is designed. It is what the text does.
*
* TONE IS BUILT THE WAY AN ENGRAVER BUILDS IT. Rather than shading, the drawing
* uses many closely spaced contour lines. Where the terrain is steep the lines
* crowd together and the passage reads dark; where it is flat they spread and it
* reads light. That is precisely how a burin builds value on a plate, and it
* means the tone is a consequence of the form rather than a coat of paint over
* it. Dürer's hatching follows the surface; so does this.
*
* COST. The drawing is rendered ONCE into an offscreen canvas — tens of
* thousands of vertices, far too many to redraw per frame. Each animation frame
* then costs three composite operations: blit the plate, multiply the light over
* it, add the highlight. The light moves; the engraving does not.
*/
import { sampleField, sampleAt } from '../core/field/density.js';
import { contours } from '../core/field/contour.js';
/**
* Grid resolution of the scalar field, expressed as a target cell count.
* The actual dimensions are derived from the canvas aspect at resize time — a
* fixed grid over a differently-shaped canvas stretches the terrain and pushes
* the landmass out of frame.
*/
const GRID_CELLS = 56000;
const GRID_MIN = 90;
/**
* Contour levels. This number IS the tonal range — more lines, finer tone,
* exactly as adding passes with a finer burin deepens a plate.
*
* Paired with the mound width, which was the setting that mattered most: at
* sigma 0.108 all 34 pieces merged into one smooth dome and the contours only
* appeared at its rim, leaving the interior flat and empty. At 0.048 each piece
* of work keeps its own summit, and the terrain reads as an archipelago with
* real internal structure — measured at 476 polylines against 173.
*/
const LEVELS = 86;
/** Ink. Warm graphite, never neutral black. */
const INK = [232, 206, 168];
const GROUND = [20, 16, 12];
/**
* @param {HTMLCanvasElement} canvas
* @param {{points: Array}} manifold
*/
export function createTerrain(canvas, manifold) {
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return { update() {}, destroy() {}, isRunning: () => false };
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let W = 0; let H = 0; let dpr = 1;
let gridW = 240; let gridH = 160;
let plate = null; // offscreen: the engraving
let plateCtx = null;
let field = null;
let levels = []; // extracted contours, low to high
let drawnUpTo = 0; // how much of the plate has been inked
let raf = 0; let running = false; let last = 0;
// The light. Starts right of centre; travels toward whatever the model
// decides the visitor cares about.
const light = { x: 0.56, y: 0.44, tx: 0.56, ty: 0.44 };
function buildField(attractors = []) {
field = sampleField(manifold.points, { width: gridW, height: gridH, sigma: 0.048, attractors });
levels = contours(field, LEVELS, 0.05);
}
function resize() {
dpr = Math.min(2, devicePixelRatio || 1);
W = Math.max(1, Math.round(canvas.clientWidth * dpr));
H = Math.max(1, Math.round(canvas.clientHeight * dpr));
canvas.width = W; canvas.height = H;
// Match the sampling grid to the canvas aspect, holding the cell count
// roughly constant so cost does not depend on window shape.
const aspect = W / H;
gridW = Math.max(GRID_MIN, Math.round(Math.sqrt(GRID_CELLS * aspect)));
gridH = Math.max(GRID_MIN, Math.round(Math.sqrt(GRID_CELLS / aspect)));
plate = document.createElement('canvas');
plate.width = W; plate.height = H;
plateCtx = plate.getContext('2d');
plateCtx.clearRect(0, 0, W, H);
drawnUpTo = 0;
buildField();
}
/** Grid coordinates -> device pixels. */
const px = (gx) => (gx / (gridW - 1)) * W;
const py = (gy) => (gy / (gridH - 1)) * H;
/**
* Ink contour levels [from, to) onto the plate.
* Called incrementally during the draw-in, then never again.
*/
function inkLevels(from, to) {
if (!plateCtx) return;
plateCtx.lineCap = 'round';
plateCtx.lineJoin = 'round';
for (let i = from; i < to && i < levels.length; i += 1) {
const { t, paths } = levels[i];
// Higher contours are drawn with a finer, brighter line — the way an
// engraver lightens the burin toward a highlight.
const alpha = 0.13 + t * 0.55;
const wgt = (0.55 + (1 - t) * 0.70) * dpr;
plateCtx.strokeStyle = `rgba(${INK[0]},${INK[1]},${INK[2]},${alpha.toFixed(4)})`;
plateCtx.lineWidth = wgt;
for (const path of paths) {
if (path.length < 3) continue;
plateCtx.beginPath();
plateCtx.moveTo(px(path[0][0]), py(path[0][1]));
// Quadratic smoothing through midpoints: marching squares emits
// polylines on grid edges, and drawn raw they show the lattice. Curving
// through the midpoints removes the staircase without moving the line.
for (let k = 1; k < path.length - 1; k += 1) {
const cx = px(path[k][0]); const cy = py(path[k][1]);
const mx = (cx + px(path[k + 1][0])) / 2;
const my = (cy + py(path[k + 1][1])) / 2;
plateCtx.quadraticCurveTo(cx, cy, mx, my);
}
plateCtx.stroke();
}
}
}
function composite() {
// 1. ground
ctx.fillStyle = `rgb(${GROUND[0]},${GROUND[1]},${GROUND[2]})`;
ctx.fillRect(0, 0, W, H);
// 2. the engraving
ctx.drawImage(plate, 0, 0);
// 3. the light, multiplied over the plate so ink in shadow darkens rather
// than being covered — the plate is beneath the light, not behind a veil.
const lx = light.x * W; const ly = light.y * H;
const reach = Math.hypot(W, H) * 0.60;
const shade = ctx.createRadialGradient(lx, ly, 0, lx, ly, reach);
shade.addColorStop(0.00, 'rgba(255,255,255,1)');
shade.addColorStop(0.32, 'rgba(246,238,222,1)');
shade.addColorStop(0.64, 'rgba(196,176,148,1)');
shade.addColorStop(0.88, 'rgba(132,116,96,1)');
shade.addColorStop(1.00, 'rgba(92,78,64,1)');
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = shade;
ctx.fillRect(0, 0, W, H);
// 4. the highlight — warm air where the light is strongest
ctx.globalCompositeOperation = 'lighter';
const glow = ctx.createRadialGradient(lx, ly, 0, lx, ly, reach * 0.72);
glow.addColorStop(0.00, 'rgba(146,106,58,0.34)');
glow.addColorStop(0.42, 'rgba(88,64,38,0.16)');
glow.addColorStop(1.00, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
}
function frame(t) {
if (!running) return;
const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
// THE DRAW-IN. The plate inks itself over roughly two and a half seconds,
// lightest structure first, so the terrain assembles in front of the
// viewer instead of simply being present. This is the one orchestrated
// moment on the page; everything else responds to the reader.
if (drawnUpTo < levels.length) {
const next = Math.min(levels.length, drawnUpTo + Math.max(1, Math.ceil(levels.length * dt / 2.4)));
inkLevels(drawnUpTo, next);
drawnUpTo = next;
}
// The light eases toward its target rather than jumping, so a change of
// interest reads as the sun moving across a landscape.
light.x += (light.tx - light.x) * Math.min(1, dt * 1.1);
light.y += (light.ty - light.y) * Math.min(1, dt * 1.1);
composite();
raf = requestAnimationFrame(frame);
}
function start() { if (running) return; running = true; last = performance.now(); raf = requestAnimationFrame(frame); }
function stop() { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
/**
* Move the light toward a region of the manifold, and optionally raise the
* terrain there.
* @param {{focus?: {x: number, y: number}, attractors?: Array}} s
*/
function update(s = {}) {
if (s.focus) { light.tx = s.focus.x; light.ty = s.focus.y; }
if (s.attractors) {
buildField(s.attractors);
// Re-ink from scratch: the terrain itself changed shape.
plateCtx.clearRect(0, 0, W, H);
drawnUpTo = 0;
}
}
// --- lifecycle ---------------------------------------------------------
const ro = new ResizeObserver(() => { resize(); });
ro.observe(canvas);
const onVis = () => (document.visibilityState === 'visible' ? start() : stop());
document.addEventListener('visibilitychange', onVis);
const host = canvas.closest('[data-instrument-host]') ?? canvas;
let usedCVAS = false;
if ('contentVisibility' in document.documentElement.style) {
host.addEventListener('contentvisibilityautostatechange', (ev) => (ev.skipped ? stop() : start()));
usedCVAS = true;
}
const io = usedCVAS ? null : new IntersectionObserver(
(es) => es.forEach((e) => (e.isIntersecting ? start() : stop())),
{ rootMargin: '200px' },
);
io?.observe(canvas);
resize();
if (reduced) {
// Reduced motion gets the finished plate, not an empty frame. A drawing is
// not motion; only its assembly was.
inkLevels(0, levels.length);
drawnUpTo = levels.length;
composite();
} else {
start();
}
return {
update,
isRunning: () => running,
stats: () => ({ levels: levels.length, paths: levels.reduce((n, l) => n + l.paths.length, 0) }),
destroy() { stop(); ro.disconnect(); io?.disconnect(); document.removeEventListener('visibilitychange', onVis); },
};
}
/** Re-export for the composition root, which needs it to place attractors. */
export { sampleAt };