Add hello-world svgUnverified
e06433e parent: 69a63a8 modified
blog/content/posts/hello-world.md +7 -1 | @@ -2,6 +2,12 @@ | ||
| 2 | 2 | date = '1990-04-13T09:00:00+01:00' |
| 3 | 3 | draft = false |
| 4 | 4 | title = 'Hello world' |
| 5 | +# Hugo derives .Summary from the opening of the *rendered* content, markup and | |
| 6 | +# all, so with the plate as the first block the auto-summary was the figure | |
| 7 | +# itself - SVG, CSS and script - repeated into the homepage excerpt | |
| 8 | +# (layouts/index.html) and the RSS <description>. Stating the summary here | |
| 9 | +# keeps the plate in .Content, where it belongs, and leaves the teaser text. | |
| 10 | +summary = ' ' | |
| 5 | 11 | +++ |
| 6 | 12 | |
| 7 | -Hello world | |
| 13 | +{{< hello-world >}} | |
| @@ -2,6 +2,12 @@ | |||
| 2 | date = '1990-04-13T09:00:00+01:00' | 2 | date = '1990-04-13T09:00:00+01:00' |
| 3 | draft = false | 3 | draft = false |
| 4 | title = 'Hello world' | 4 | title = 'Hello world' |
| 5 | +# Hugo derives .Summary from the opening of the *rendered* content, markup and | ||
| 6 | +# all, so with the plate as the first block the auto-summary was the figure | ||
| 7 | +# itself - SVG, CSS and script - repeated into the homepage excerpt | ||
| 8 | +# (layouts/index.html) and the RSS <description>. Stating the summary here | ||
| 9 | +# keeps the plate in .Content, where it belongs, and leaves the teaser text. | ||
| 10 | +summary = ' ' | ||
| 5 | +++ | 11 | +++ |
| 6 | 12 | ||
| 7 | -Hello world | 13 | +{{< hello-world >}} |
added
blog/layouts/shortcodes/hello-world.html +137 -0 | new file mode 100644 | ||
| @@ -0,0 +1,137 @@ | ||
| 1 | +{{- /* | |
| 2 | + The animated "Hello, world" plate for the post of the same name. Kept as a | |
| 3 | + shortcode rather than pasted into the markdown so the content file stays | |
| 4 | + readable and every translation (hello-world.fr.md, .ja.md, ...) can reuse | |
| 5 | + the exact same one-liner instead of carrying its own copy of 8 KB of SVG, | |
| 6 | + CSS and JS that would then have to be kept in sync five ways. | |
| 7 | + | |
| 8 | + The <style>/<script> live here rather than in assets/css/style.scss and | |
| 9 | + assets/js/ on purpose: this is a one-post ornament, and inlining it keeps | |
| 10 | + the cost off every other page in the site. If a second post ever wants it, | |
| 11 | + that's the moment to promote it to a fingerprinted asset. | |
| 12 | + | |
| 13 | + The palette is self-contained too - light-dark() reads the `color-scheme: | |
| 14 | + light dark` already declared on :root, so the plate flips with the rest of | |
| 15 | + the site and needs no wiring into the theme variables. | |
| 16 | + | |
| 17 | + The two fill="#..." attributes on the SVG paths are the no-CSS fallback: | |
| 18 | + the <style> below outranks them wherever it applies, but anywhere the | |
| 19 | + figure travels without it - a reader that strips <style>/<script>, a | |
| 20 | + saved page, a full-content feed - the paths would otherwise take the SVG | |
| 21 | + default and render a black word on a black plate. With the fills, that | |
| 22 | + context gets the still version the bare SVG already is. | |
| 23 | +*/ -}} | |
| 24 | +<figure class="hw"> | |
| 25 | +<svg viewBox="0 0 102 61" role="img" aria-labelledby="hw-title" shape-rendering="crispEdges"> | |
| 26 | +<title id="hw-title">Hello, world</title> | |
| 27 | +<path class="hw-bg" fill="#e9eff5" d="M2 0h98v1h1v1h1v57h-1v1h-1v1h-98v-1h-1v-1h-1v-57h1v-1h1z"/> | |
| 28 | +<path class="hw-word" fill="#0f3460" transform="translate(18 13)" d="M0 0h3v15h-3zM8 0h3v15h-3zM13 0h10v3h-10zM25 0h3v15h-3zM37 0h3v15h-3zM50 0h9v3h-9zM49 1h1v1h-1zM59 1h1v2h-1zM49 2h1v1h-1zM13 3h3v12h-3zM49 3h3v11h-3zM57 3h3v11h-3zM3 6h5v1h-5zM16 6h5v3h-5zM3 7h5v1h-5zM3 8h5v1h-5zM16 12h7v3h-7zM28 12h7v3h-7zM40 12h7v3h-7zM52 12h5v1h-5zM52 13h5v1h-5zM50 14h9v1h-9zM0 20h3v14h-3zM12 20h3v14h-3zM18 20h9v3h-9zM30 20h10v3h-10zM43 20h3v15h-3zM55 20h9v3h-9zM17 21h1v1h-1zM27 21h1v2h-1zM40 21h1v2h-1zM64 21h1v2h-1zM17 22h1v1h-1zM65 22h1v1h-1zM17 23h3v11h-3zM25 23h3v11h-3zM30 23h3v12h-3zM38 23h3v5h-3zM55 23h3v12h-3zM63 23h3v10h-3zM6 24h3v11h-3zM33 26h5v1h-5zM33 27h5v1h-5zM33 28h7v1h-7zM34 29h4v1h-4zM35 30h4v1h-4zM36 31h4v1h-4zM3 32h3v1h-3zM9 32h3v1h-3zM20 32h5v1h-5zM37 32h4v3h-4zM46 32h7v3h-7zM58 32h5v1h-5zM3 33h3v1h-3zM9 33h3v1h-3zM20 33h5v1h-5zM58 33h7v1h-7zM1 34h5v1h-5zM9 34h5v1h-5zM18 34h9v1h-9zM58 34h6v1h-6z"/> | |
| 29 | +</svg> | |
| 30 | +<style> | |
| 31 | +.hw { | |
| 32 | + color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */ | |
| 33 | + --hw-bg: light-dark(#e9eff5, #0a2342); | |
| 34 | + --hw-dim: light-dark(#cddbe9, #143a6b); | |
| 35 | + --hw-mid: light-dark(#8fb0d1, #2e6aa6); | |
| 36 | + --hw-lit: light-dark(#0f3460, #d6e9f8); | |
| 37 | + --hw-hover: light-dark(#f0913f, #ffb454); | |
| 38 | + --hw-crest: light-dark(#d9480f, #fff1cf); | |
| 39 | + position: relative; margin: 1.5em 0; aspect-ratio: 102 / 61; | |
| 40 | + cursor: crosshair; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; | |
| 41 | +} | |
| 42 | +.hw svg, .hw canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } | |
| 43 | +.hw canvas { image-rendering: crisp-edges; image-rendering: pixelated; } | |
| 44 | +.hw-bg { fill: var(--hw-bg); } | |
| 45 | +.hw-word { fill: var(--hw-lit); } | |
| 46 | +.hw-live svg { opacity: 0; } /* still there for screen readers */ | |
| 47 | +</style> | |
| 48 | +<script> | |
| 49 | +(() => { | |
| 50 | + const fig = document.currentScript?.closest('.hw') || document.querySelector('.hw:not(.hw-live)'); | |
| 51 | + // htmx 4 re-runs inline scripts in swapped-in content (it clones each | |
| 52 | + // <script> into a fresh, executable one), so a boosted navigation back to | |
| 53 | + // this post runs the whole IIFE a second time. `.hw-live` is the marker set | |
| 54 | + // at the end of a successful init: bail when the figure already carries it, | |
| 55 | + // or the plate collects another canvas and another rAF loop per visit. | |
| 56 | + if (!fig || fig.classList.contains('hw-live')) return; | |
| 57 | + const svg = fig.querySelector('svg'), wordEl = svg.querySelector('.hw-word'); | |
| 58 | + const [, , W, H] = svg.getAttribute('viewBox').split(' ').map(Number); | |
| 59 | + const [ox, oy] = wordEl.getAttribute('transform').match(/-?\d+/g).map(Number); | |
| 60 | + // The SVG path IS the bitmap: every "M x y h w v h" is a rectangle of lit cells. | |
| 61 | + const word = new Uint8Array(W * H); let wx = 0, wy = 0; | |
| 62 | + for (const m of wordEl.getAttribute('d').matchAll(/M(\d+) (\d+)h(\d+)v(\d+)/g)) { | |
| 63 | + const [x, y, w, h] = m.slice(1).map(Number); wx = Math.max(wx, x + w); wy = Math.max(wy, y + h); | |
| 64 | + for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) word[(r + oy) * W + c + ox] = 1; | |
| 65 | + } | |
| 66 | + const BAYER = [0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21]; | |
| 67 | + const smooth = (lo, hi, x) => { const t = Math.min(1, Math.max(0, (x - lo) / (hi - lo))); return t * t * (3 - 2 * t); }; | |
| 68 | + // Tileable value noise: a 64x64 table of randoms, smoothly interpolated. | |
| 69 | + const T = Float32Array.from({ length: 4096 }, Math.random); | |
| 70 | + const noise = (x, y) => { | |
| 71 | + const xi = Math.floor(x), yi = Math.floor(y), fx = x - xi, fy = y - yi; | |
| 72 | + const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); | |
| 73 | + const a = T[(yi & 63) << 6 | xi & 63], b = T[(yi & 63) << 6 | xi + 1 & 63], c = T[(yi + 1 & 63) << 6 | xi & 63], d = T[(yi + 1 & 63) << 6 | xi + 1 & 63]; | |
| 74 | + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; | |
| 75 | + }; | |
| 76 | + // Per cell, once: dither threshold, how quiet the clouds are near the word, when a word cell burns in. | |
| 77 | + const th = new Float32Array(W * H), hush = new Float32Array(W * H), key = new Float32Array(W * H); | |
| 78 | + for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { | |
| 79 | + const i = r * W + c, x = c + .5 - ox, y = r + .5 - oy; | |
| 80 | + th[i] = (BAYER[(r & 7) << 3 | c & 7] + .5) / 64; | |
| 81 | + hush[i] = .25 + .75 * smooth(0, 9, Math.hypot(Math.max(-2 - x, 0, x - wx - 2), Math.max(-2 - y, 0, y - wy - 2))); | |
| 82 | + key[i] = .6 * (c - ox) / wx + .4 * th[i]; | |
| 83 | + if (Math.min(c, W - 1 - c) + Math.min(r, H - 1 - r) < 2) hush[i] = -1; // notched corners | |
| 84 | + } | |
| 85 | + // One canvas pixel per cell; CSS scales it up with image-rendering: pixelated. | |
| 86 | + const cv = document.createElement('canvas'); cv.width = W; cv.height = H; cv.setAttribute('aria-hidden', 'true'); | |
| 87 | + const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H), px = new Uint32Array(img.data.buffer); | |
| 88 | + // Colours come from the CSS variables, resolved by the browser (so light-dark() and friends work). | |
| 89 | + const probe = fig.appendChild(document.createElement('i')); probe.hidden = true; | |
| 90 | + let PAL = [], LIT = 0; | |
| 91 | + const readPalette = () => { | |
| 92 | + const rgb = name => { probe.style.color = `var(--hw-${name})`; const [r, g, b] = getComputedStyle(probe).color.match(/[\d.]+/g).map(Number); return (255 << 24 | b << 16 | g << 8 | r) >>> 0; }; | |
| 93 | + PAL = ['bg', 'dim', 'mid', 'hover', 'crest'].map(rgb); LIT = rgb('lit'); | |
| 94 | + }; | |
| 95 | + const still = matchMedia('(prefers-reduced-motion: reduce)'); | |
| 96 | + const ptr = { x: 0, y: 0, tx: 0, ty: 0, on: false, k: 0 }; let rings = [], introAt = Infinity; | |
| 97 | + function draw(now) { | |
| 98 | + const t = still.matches ? 40 : now / 1000, p = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 1500)); | |
| 99 | + const intro = 1 - (1 - p) * (1 - p), swell = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 700)); | |
| 100 | + ptr.k += ((ptr.on ? 1 : 0) - ptr.k) * .12; ptr.x += (ptr.tx - ptr.x) * .2; ptr.y += (ptr.ty - ptr.y) * .2; | |
| 101 | + rings = rings.filter(k => now - k.t < 1700); | |
| 102 | + for (let r = 0, i = 0; r < H; r++) for (let c = 0; c < W; c++, i++) { | |
| 103 | + if (hush[i] < 0) { px[i] = 0; continue; } | |
| 104 | + let glow = 0, ring = 0; | |
| 105 | + if (ptr.k > .01) { const g = 1 - Math.hypot(c + .5 - ptr.x, r + .5 - ptr.y) / 11; if (g > 0) glow = g * Math.sqrt(g) * ptr.k; } | |
| 106 | + for (const k of rings) { const age = (now - k.t) / 1000, d = Math.hypot(c + .5 - k.x, r + .5 - k.y); ring = Math.max(ring, Math.max(0, 1 - Math.abs(d - age * 26) / 2.5) * (1 - age / 1.7)); } | |
| 107 | + if (word[i] && p > 0 && intro >= key[i]) { | |
| 108 | + px[i] = (p < 1 && intro - key[i] < .07) || ring > th[i] || glow > .45 + th[i] * .5 ? PAL[4] : glow * 1.6 > th[i] ? PAL[3] : LIT; | |
| 109 | + continue; | |
| 110 | + } | |
| 111 | + // Two octaves of drifting noise, shaped into clouds, dithered across bg > dim > mid > hover > crest. | |
| 112 | + const n = .62 * noise(c / 10 + t * .035, r / 10 + t * .02) + .38 * noise(c / 4.5 - t * .05 + 17, r / 4.5 + t * .03 + 9); | |
| 113 | + const level = smooth(.38, .84, n) * 2 * hush[i] * swell + glow * 3.6 + ring * 3, base = Math.floor(level); | |
| 114 | + px[i] = PAL[Math.min(4, base + (level - base > th[i] ? 1 : 0))]; | |
| 115 | + } | |
| 116 | + ctx.putImageData(img, 0, 0); | |
| 117 | + } | |
| 118 | + let visible = false, raf = 0, last = 0; | |
| 119 | + // isConnected: an htmx swap can tear this figure out of the document while | |
| 120 | + // the loop is running - the IntersectionObserver may never report it - so | |
| 121 | + // check the node is still in the page rather than animating a detached canvas. | |
| 122 | + const tick = now => { raf = 0; if (!visible || !fig.isConnected) return; if (now - last > 31) { last = now; draw(now); } if (!still.matches) raf = requestAnimationFrame(tick); }; | |
| 123 | + const kick = () => { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } }; | |
| 124 | + const repaint = () => { readPalette(); kick(); }; | |
| 125 | + const at = e => { const b = cv.getBoundingClientRect(); return { x: (e.clientX - b.left) / b.width * W, y: (e.clientY - b.top) / b.height * H }; }; | |
| 126 | + fig.addEventListener('pointermove', e => { const q = at(e); if (ptr.k < .02) { ptr.x = q.x; ptr.y = q.y; } ptr.tx = q.x; ptr.ty = q.y; ptr.on = !still.matches; }); | |
| 127 | + fig.addEventListener('pointerleave', () => { ptr.on = false; }); | |
| 128 | + fig.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') ptr.on = false; }); | |
| 129 | + fig.addEventListener('pointerdown', e => { if (still.matches) return; rings.push({ ...at(e), t: performance.now() }); if (rings.length > 4) rings.shift(); }); | |
| 130 | + new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && introAt === Infinity) introAt = performance.now(); kick(); }).observe(fig); | |
| 131 | + new MutationObserver(repaint).observe(document.documentElement, { attributes: true }); | |
| 132 | + new MutationObserver(repaint).observe(fig, { attributes: true, attributeFilter: ['style', 'class'] }); | |
| 133 | + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint); still.addEventListener('change', kick); | |
| 134 | + readPalette(); draw(performance.now()); fig.append(cv); fig.classList.add('hw-live'); | |
| 135 | +})(); | |
| 136 | +</script> | |
| 137 | +</figure> | |
| new file mode 100644 | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | +{{- /* | ||
| 2 | + The animated "Hello, world" plate for the post of the same name. Kept as a | ||
| 3 | + shortcode rather than pasted into the markdown so the content file stays | ||
| 4 | + readable and every translation (hello-world.fr.md, .ja.md, ...) can reuse | ||
| 5 | + the exact same one-liner instead of carrying its own copy of 8 KB of SVG, | ||
| 6 | + CSS and JS that would then have to be kept in sync five ways. | ||
| 7 | + | ||
| 8 | + The <style>/<script> live here rather than in assets/css/style.scss and | ||
| 9 | + assets/js/ on purpose: this is a one-post ornament, and inlining it keeps | ||
| 10 | + the cost off every other page in the site. If a second post ever wants it, | ||
| 11 | + that's the moment to promote it to a fingerprinted asset. | ||
| 12 | + | ||
| 13 | + The palette is self-contained too - light-dark() reads the `color-scheme: | ||
| 14 | + light dark` already declared on :root, so the plate flips with the rest of | ||
| 15 | + the site and needs no wiring into the theme variables. | ||
| 16 | + | ||
| 17 | + The two fill="#..." attributes on the SVG paths are the no-CSS fallback: | ||
| 18 | + the <style> below outranks them wherever it applies, but anywhere the | ||
| 19 | + figure travels without it - a reader that strips <style>/<script>, a | ||
| 20 | + saved page, a full-content feed - the paths would otherwise take the SVG | ||
| 21 | + default and render a black word on a black plate. With the fills, that | ||
| 22 | + context gets the still version the bare SVG already is. | ||
| 23 | +*/ -}} | ||
| 24 | +<figure class="hw"> | ||
| 25 | +<svg viewBox="0 0 102 61" role="img" aria-labelledby="hw-title" shape-rendering="crispEdges"> | ||
| 26 | +<title id="hw-title">Hello, world</title> | ||
| 27 | +<path class="hw-bg" fill="#e9eff5" d="M2 0h98v1h1v1h1v57h-1v1h-1v1h-98v-1h-1v-1h-1v-57h1v-1h1z"/> | ||
| 28 | +<path class="hw-word" fill="#0f3460" transform="translate(18 13)" d="M0 0h3v15h-3zM8 0h3v15h-3zM13 0h10v3h-10zM25 0h3v15h-3zM37 0h3v15h-3zM50 0h9v3h-9zM49 1h1v1h-1zM59 1h1v2h-1zM49 2h1v1h-1zM13 3h3v12h-3zM49 3h3v11h-3zM57 3h3v11h-3zM3 6h5v1h-5zM16 6h5v3h-5zM3 7h5v1h-5zM3 8h5v1h-5zM16 12h7v3h-7zM28 12h7v3h-7zM40 12h7v3h-7zM52 12h5v1h-5zM52 13h5v1h-5zM50 14h9v1h-9zM0 20h3v14h-3zM12 20h3v14h-3zM18 20h9v3h-9zM30 20h10v3h-10zM43 20h3v15h-3zM55 20h9v3h-9zM17 21h1v1h-1zM27 21h1v2h-1zM40 21h1v2h-1zM64 21h1v2h-1zM17 22h1v1h-1zM65 22h1v1h-1zM17 23h3v11h-3zM25 23h3v11h-3zM30 23h3v12h-3zM38 23h3v5h-3zM55 23h3v12h-3zM63 23h3v10h-3zM6 24h3v11h-3zM33 26h5v1h-5zM33 27h5v1h-5zM33 28h7v1h-7zM34 29h4v1h-4zM35 30h4v1h-4zM36 31h4v1h-4zM3 32h3v1h-3zM9 32h3v1h-3zM20 32h5v1h-5zM37 32h4v3h-4zM46 32h7v3h-7zM58 32h5v1h-5zM3 33h3v1h-3zM9 33h3v1h-3zM20 33h5v1h-5zM58 33h7v1h-7zM1 34h5v1h-5zM9 34h5v1h-5zM18 34h9v1h-9zM58 34h6v1h-6z"/> | ||
| 29 | +</svg> | ||
| 30 | +<style> | ||
| 31 | +.hw { | ||
| 32 | + color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */ | ||
| 33 | + --hw-bg: light-dark(#e9eff5, #0a2342); | ||
| 34 | + --hw-dim: light-dark(#cddbe9, #143a6b); | ||
| 35 | + --hw-mid: light-dark(#8fb0d1, #2e6aa6); | ||
| 36 | + --hw-lit: light-dark(#0f3460, #d6e9f8); | ||
| 37 | + --hw-hover: light-dark(#f0913f, #ffb454); | ||
| 38 | + --hw-crest: light-dark(#d9480f, #fff1cf); | ||
| 39 | + position: relative; margin: 1.5em 0; aspect-ratio: 102 / 61; | ||
| 40 | + cursor: crosshair; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; | ||
| 41 | +} | ||
| 42 | +.hw svg, .hw canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } | ||
| 43 | +.hw canvas { image-rendering: crisp-edges; image-rendering: pixelated; } | ||
| 44 | +.hw-bg { fill: var(--hw-bg); } | ||
| 45 | +.hw-word { fill: var(--hw-lit); } | ||
| 46 | +.hw-live svg { opacity: 0; } /* still there for screen readers */ | ||
| 47 | +</style> | ||
| 48 | +<script> | ||
| 49 | +(() => { | ||
| 50 | + const fig = document.currentScript?.closest('.hw') || document.querySelector('.hw:not(.hw-live)'); | ||
| 51 | + // htmx 4 re-runs inline scripts in swapped-in content (it clones each | ||
| 52 | + // <script> into a fresh, executable one), so a boosted navigation back to | ||
| 53 | + // this post runs the whole IIFE a second time. `.hw-live` is the marker set | ||
| 54 | + // at the end of a successful init: bail when the figure already carries it, | ||
| 55 | + // or the plate collects another canvas and another rAF loop per visit. | ||
| 56 | + if (!fig || fig.classList.contains('hw-live')) return; | ||
| 57 | + const svg = fig.querySelector('svg'), wordEl = svg.querySelector('.hw-word'); | ||
| 58 | + const [, , W, H] = svg.getAttribute('viewBox').split(' ').map(Number); | ||
| 59 | + const [ox, oy] = wordEl.getAttribute('transform').match(/-?\d+/g).map(Number); | ||
| 60 | + // The SVG path IS the bitmap: every "M x y h w v h" is a rectangle of lit cells. | ||
| 61 | + const word = new Uint8Array(W * H); let wx = 0, wy = 0; | ||
| 62 | + for (const m of wordEl.getAttribute('d').matchAll(/M(\d+) (\d+)h(\d+)v(\d+)/g)) { | ||
| 63 | + const [x, y, w, h] = m.slice(1).map(Number); wx = Math.max(wx, x + w); wy = Math.max(wy, y + h); | ||
| 64 | + for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) word[(r + oy) * W + c + ox] = 1; | ||
| 65 | + } | ||
| 66 | + const BAYER = [0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21]; | ||
| 67 | + const smooth = (lo, hi, x) => { const t = Math.min(1, Math.max(0, (x - lo) / (hi - lo))); return t * t * (3 - 2 * t); }; | ||
| 68 | + // Tileable value noise: a 64x64 table of randoms, smoothly interpolated. | ||
| 69 | + const T = Float32Array.from({ length: 4096 }, Math.random); | ||
| 70 | + const noise = (x, y) => { | ||
| 71 | + const xi = Math.floor(x), yi = Math.floor(y), fx = x - xi, fy = y - yi; | ||
| 72 | + const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); | ||
| 73 | + const a = T[(yi & 63) << 6 | xi & 63], b = T[(yi & 63) << 6 | xi + 1 & 63], c = T[(yi + 1 & 63) << 6 | xi & 63], d = T[(yi + 1 & 63) << 6 | xi + 1 & 63]; | ||
| 74 | + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; | ||
| 75 | + }; | ||
| 76 | + // Per cell, once: dither threshold, how quiet the clouds are near the word, when a word cell burns in. | ||
| 77 | + const th = new Float32Array(W * H), hush = new Float32Array(W * H), key = new Float32Array(W * H); | ||
| 78 | + for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { | ||
| 79 | + const i = r * W + c, x = c + .5 - ox, y = r + .5 - oy; | ||
| 80 | + th[i] = (BAYER[(r & 7) << 3 | c & 7] + .5) / 64; | ||
| 81 | + hush[i] = .25 + .75 * smooth(0, 9, Math.hypot(Math.max(-2 - x, 0, x - wx - 2), Math.max(-2 - y, 0, y - wy - 2))); | ||
| 82 | + key[i] = .6 * (c - ox) / wx + .4 * th[i]; | ||
| 83 | + if (Math.min(c, W - 1 - c) + Math.min(r, H - 1 - r) < 2) hush[i] = -1; // notched corners | ||
| 84 | + } | ||
| 85 | + // One canvas pixel per cell; CSS scales it up with image-rendering: pixelated. | ||
| 86 | + const cv = document.createElement('canvas'); cv.width = W; cv.height = H; cv.setAttribute('aria-hidden', 'true'); | ||
| 87 | + const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H), px = new Uint32Array(img.data.buffer); | ||
| 88 | + // Colours come from the CSS variables, resolved by the browser (so light-dark() and friends work). | ||
| 89 | + const probe = fig.appendChild(document.createElement('i')); probe.hidden = true; | ||
| 90 | + let PAL = [], LIT = 0; | ||
| 91 | + const readPalette = () => { | ||
| 92 | + const rgb = name => { probe.style.color = `var(--hw-${name})`; const [r, g, b] = getComputedStyle(probe).color.match(/[\d.]+/g).map(Number); return (255 << 24 | b << 16 | g << 8 | r) >>> 0; }; | ||
| 93 | + PAL = ['bg', 'dim', 'mid', 'hover', 'crest'].map(rgb); LIT = rgb('lit'); | ||
| 94 | + }; | ||
| 95 | + const still = matchMedia('(prefers-reduced-motion: reduce)'); | ||
| 96 | + const ptr = { x: 0, y: 0, tx: 0, ty: 0, on: false, k: 0 }; let rings = [], introAt = Infinity; | ||
| 97 | + function draw(now) { | ||
| 98 | + const t = still.matches ? 40 : now / 1000, p = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 1500)); | ||
| 99 | + const intro = 1 - (1 - p) * (1 - p), swell = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 700)); | ||
| 100 | + ptr.k += ((ptr.on ? 1 : 0) - ptr.k) * .12; ptr.x += (ptr.tx - ptr.x) * .2; ptr.y += (ptr.ty - ptr.y) * .2; | ||
| 101 | + rings = rings.filter(k => now - k.t < 1700); | ||
| 102 | + for (let r = 0, i = 0; r < H; r++) for (let c = 0; c < W; c++, i++) { | ||
| 103 | + if (hush[i] < 0) { px[i] = 0; continue; } | ||
| 104 | + let glow = 0, ring = 0; | ||
| 105 | + if (ptr.k > .01) { const g = 1 - Math.hypot(c + .5 - ptr.x, r + .5 - ptr.y) / 11; if (g > 0) glow = g * Math.sqrt(g) * ptr.k; } | ||
| 106 | + for (const k of rings) { const age = (now - k.t) / 1000, d = Math.hypot(c + .5 - k.x, r + .5 - k.y); ring = Math.max(ring, Math.max(0, 1 - Math.abs(d - age * 26) / 2.5) * (1 - age / 1.7)); } | ||
| 107 | + if (word[i] && p > 0 && intro >= key[i]) { | ||
| 108 | + px[i] = (p < 1 && intro - key[i] < .07) || ring > th[i] || glow > .45 + th[i] * .5 ? PAL[4] : glow * 1.6 > th[i] ? PAL[3] : LIT; | ||
| 109 | + continue; | ||
| 110 | + } | ||
| 111 | + // Two octaves of drifting noise, shaped into clouds, dithered across bg > dim > mid > hover > crest. | ||
| 112 | + const n = .62 * noise(c / 10 + t * .035, r / 10 + t * .02) + .38 * noise(c / 4.5 - t * .05 + 17, r / 4.5 + t * .03 + 9); | ||
| 113 | + const level = smooth(.38, .84, n) * 2 * hush[i] * swell + glow * 3.6 + ring * 3, base = Math.floor(level); | ||
| 114 | + px[i] = PAL[Math.min(4, base + (level - base > th[i] ? 1 : 0))]; | ||
| 115 | + } | ||
| 116 | + ctx.putImageData(img, 0, 0); | ||
| 117 | + } | ||
| 118 | + let visible = false, raf = 0, last = 0; | ||
| 119 | + // isConnected: an htmx swap can tear this figure out of the document while | ||
| 120 | + // the loop is running - the IntersectionObserver may never report it - so | ||
| 121 | + // check the node is still in the page rather than animating a detached canvas. | ||
| 122 | + const tick = now => { raf = 0; if (!visible || !fig.isConnected) return; if (now - last > 31) { last = now; draw(now); } if (!still.matches) raf = requestAnimationFrame(tick); }; | ||
| 123 | + const kick = () => { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } }; | ||
| 124 | + const repaint = () => { readPalette(); kick(); }; | ||
| 125 | + const at = e => { const b = cv.getBoundingClientRect(); return { x: (e.clientX - b.left) / b.width * W, y: (e.clientY - b.top) / b.height * H }; }; | ||
| 126 | + fig.addEventListener('pointermove', e => { const q = at(e); if (ptr.k < .02) { ptr.x = q.x; ptr.y = q.y; } ptr.tx = q.x; ptr.ty = q.y; ptr.on = !still.matches; }); | ||
| 127 | + fig.addEventListener('pointerleave', () => { ptr.on = false; }); | ||
| 128 | + fig.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') ptr.on = false; }); | ||
| 129 | + fig.addEventListener('pointerdown', e => { if (still.matches) return; rings.push({ ...at(e), t: performance.now() }); if (rings.length > 4) rings.shift(); }); | ||
| 130 | + new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && introAt === Infinity) introAt = performance.now(); kick(); }).observe(fig); | ||
| 131 | + new MutationObserver(repaint).observe(document.documentElement, { attributes: true }); | ||
| 132 | + new MutationObserver(repaint).observe(fig, { attributes: true, attributeFilter: ['style', 'class'] }); | ||
| 133 | + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint); still.addEventListener('change', kick); | ||
| 134 | + readPalette(); draw(performance.now()); fig.append(cv); fig.classList.add('hw-live'); | ||
| 135 | +})(); | ||
| 136 | +</script> | ||
| 137 | +</figure> | ||