/** * 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 };