// The hillside above the notes index, drawn for any size: shared by Eleventy (first paint) and the browser (resizes). // Keep it import-free. Everything is in CSS pixels, so the grain filter never stretches. // // Each hill keeps its own band of the art's height, so the layers overlap the same way at every size, and a // wavelength roughly fixed in pixels, so a wider window gets more rolling hills instead of stretched ones. // Waves are measured from the middle, so hills grow outward from the plants as the window widens. /** @typedef {{ id: 'sun-hill' | 'back' | 'front', base: number, amp: number, len: number, phase: number }} Layer */ /** Back to front. `base` and `amp` are fractions of the height; `len` is a wavelength in px at full height. @type {Layer[]} */ export const LAYERS = [ { id: 'sun-hill', base: 0.46, amp: 0.12, len: 720, phase: 0.5 }, { id: 'back', base: 0.3, amp: 0.1, len: 900, phase: 2.6 }, { id: 'front', base: 0.79, amp: 0.08, len: 620, phase: 4.2 }, ]; /** * Seed, sprout, bud and bloom: left to right, smallest to largest. `size` is a fraction of the height, `foot` is where * the stem ends in the 24-unit symbol, and `shift` is the yellow plate's misregistration as a fraction of the size. */ export const PLANTS = [ { id: 'seed', size: 0.21, foot: 20.4, shift: [0.045, -0.068] }, { id: 'sprout', size: 0.26, foot: 21, shift: [0.037, -0.037] }, { id: 'bud', size: 0.3, foot: 21.5, shift: [0.048, -0.032] }, { id: 'bloom', size: 0.35, foot: 22, shift: [0.041, -0.041] }, ]; const FULL = 242; // the art's height at its widest, in px: wavelengths scale from here /** Rounded to one decimal, for short path data. @type {(n: number) => number} */ const r1 = (n) => Math.round(n * 10) / 10; /** * A hill's top edge at x: a long sine with a quieter one at the golden ratio, so the line never visibly repeats. * @param {Layer} layer * @param {number} w * @param {number} h * @returns {(x: number) => number} */ function edge(layer, w, h) { const len = layer.len * (0.55 + 0.45 * Math.min(1, h / FULL)); const k = (2 * Math.PI) / len; return (x) => { const t = (x - w / 2) * k + layer.phase; return h * (layer.base + layer.amp * (0.82 * Math.sin(t) + 0.18 * Math.sin(t * 1.618 + 1.7))); }; } /** * A filled hill: the edge sampled every ~32px, smoothed with Catmull-Rom curves, closed along the bottom. * @param {(x: number) => number} y the hill's edge * @param {number} w * @param {number} h */ function hillPath(y, w, h) { const n = Math.max(8, Math.ceil(w / 32)); const pts = Array.from({ length: n + 1 }, (_, i) => [(w * i) / n, y((w * i) / n)]); let d = `M0 ${r1(pts[0][1])}`; for (let i = 0; i < n; i++) { const [p0, p1, p2, p3] = [pts[Math.max(0, i - 1)], pts[i], pts[i + 1], pts[Math.min(n, i + 2)]]; const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; d += `C${r1(c1[0])} ${r1(c1[1])} ${r1(c2[0])} ${r1(c2[1])} ${r1(p2[0])} ${r1(p2[1])}`; } return `${d}V${r1(h)}H0Z`; } /** * The whole scene for a w×h box: hill paths by layer id, the sun, and where each plant stands. * The plants spread across the middle (never wider than ~2.9 heights) and stand between the sunlit hill's * crest and the front hill, so they ride the hills as they move. The sun sits just behind the bloom. * @param {number} w * @param {number} h */ export function hillScene(w, h) { const edges = /** @type {Record number>} */ (Object.fromEntries(LAYERS.map((l) => [l.id, edge(l, w, h)]))); const hills = /** @type {Record} */ (Object.fromEntries(LAYERS.map((l) => [l.id, hillPath(edges[l.id], w, h)]))); const span = Math.min(w * 0.6, h * 2.9); const mid = w * 0.51; const plants = PLANTS.map((p, i) => { const size = h * p.size; const cx = mid + span * (i / (PLANTS.length - 1) - 0.5); const ground = edges['sun-hill'](cx) + 0.5 * (edges.front(cx) - edges['sun-hill'](cx)); return { id: p.id, size: r1(size), x: r1(cx - size / 2), y: r1(ground - (size * p.foot) / 24), plate: [r1(p.shift[0] * size), r1(p.shift[1] * size)], }; }); const bloom = plants[plants.length - 1]; const sun = { cx: r1(bloom.x + bloom.size / 2 - h * 0.24), cy: r1(h * 0.13), r: r1(h * 0.3) }; return { w, h, hills, sun, plants }; } /** * The art's inner markup at w×h, for the first paint. Colors come from the page's tokens. * @param {number} w * @param {number} h */ export function hillMarkup(w, h) { const s = hillScene(w, h); /** @type {Record} */ const fills = { 'sun-hill': 'opacity=".9" style="fill:var(--sun)"', back: 'opacity=".42" style="fill:var(--ink);mix-blend-mode:var(--blend)"', front: 'style="fill:var(--ink);mix-blend-mode:var(--blend)"', }; const plantColor = (/** @type {string} */ id) => (id === 'bloom' ? 'var(--stamp)' : 'var(--sun)'); return [ '', ``, ...LAYERS.map((l) => ``), '', ...s.plants.map((p) => `` + `` + ``), ].join(''); } /** * Redraw an already-rendered art element at w×h: four paths' worth of attributes, no new nodes. * @param {Element} svg * @param {number} w * @param {number} h */ export function drawHills(svg, w, h) { const s = hillScene(w, h); svg.setAttribute('viewBox', `0 0 ${w} ${h}`); for (const [id, d] of Object.entries(s.hills)) svg.querySelector(`[data-hill="${id}"]`)?.setAttribute('d', d); const sun = svg.querySelector('[data-sun]'); if (sun) for (const k of /** @type {const} */ (['cx', 'cy', 'r'])) sun.setAttribute(k, String(s.sun[k])); for (const p of s.plants) { const g = svg.querySelector(`[data-plant="${p.id}"]`); if (!g) continue; g.setAttribute('transform', `translate(${p.x} ${p.y})`); const [y, gl] = g.children; for (const u of [y, gl]) { u.setAttribute('width', String(p.size)); u.setAttribute('height', String(p.size)); } y.setAttribute('x', String(p.plate[0])); y.setAttribute('y', String(p.plate[1])); } } /** Keep an art element drawn at its own size: at most once per frame, and only when the size changes. @param {Element} svg */ export function watchHills(svg) { let last = ''; let frame = 0; const draw = () => { frame = 0; const { width, height } = svg.getBoundingClientRect(); const w = Math.round(width); const h = Math.round(height); if (!w || !h || `${w}x${h}` === last) return; last = `${w}x${h}`; drawHills(svg, w, h); }; draw(); if (typeof ResizeObserver === 'function') { new ResizeObserver(() => { if (!frame) frame = requestAnimationFrame(draw); }).observe(svg); } }