fuzz.ts (7796B)
1 // The signal field — the site's signature canvas texture, shared by the home 2 // hero and every section banner. Six pixel-sorted streak bands drifting on an 3 // additive blend plus bright filaments, looping seamlessly every nine seconds. 4 // 5 // Ported from daemon-sec.xyz's FuzzField. Four things are load-bearing and 6 // break the look if changed: the loop-global phase (so two plates on one page 7 // stay in step), the DPR backing (at 1× the streaks are 1–2px and upscaling 8 // blurs the pixel-sorted look), the erasure scrim, and the synchronous frame 9 // zero (rAF never fires in a hidden tab / print / screenshot). 10 11 /** Band geometry — six streaks, three hues between them. `hue` is a *slot* 12 * (1 = iris, 2 = foam, 3 = love), not a colour: which colour a slot resolves 13 * to, and how it composites, is read from CSS at draw time so the field 14 * follows the mode along with everything else. */ 15 const BANDS = [ 16 { cy: 0.10, ch: 0.20, hue: 1, drift: 1, cyc: 1, dens: 12 }, 17 { cy: 0.34, ch: 0.24, hue: 2, drift: -1, cyc: 2, dens: 15 }, 18 { cy: 0.22, ch: 0.14, hue: 3, drift: 2, cyc: 1, dens: 9 }, 19 { cy: 0.60, ch: 0.22, hue: 2, drift: 1, cyc: 1, dens: 13 }, 20 { cy: 0.80, ch: 0.16, hue: 1, drift: -1, cyc: 2, dens: 10 }, 21 { cy: 0.92, ch: 0.14, hue: 3, drift: 1, cyc: 2, dens: 9 }, 22 ] as const; 23 24 interface Palette { 25 blend: GlobalCompositeOperation; 26 bands: [string, string, string]; 27 filaments: [string, string, string]; 28 gain: number; 29 } 30 31 const FALLBACK: Palette = { 32 blend: 'lighter', 33 bands: ['196,167,231', '156,207,216', '235,111,146'], 34 filaments: ['224,222,244', '156,207,216', '196,167,231'], 35 gain: 1, 36 }; 37 38 /** 39 * Resolve the palette off the canvas itself, so a plate inside a scope that 40 * pins the night palette draws the dark field even while the page around it is 41 * on paper. Channels come back space-separated from CSS and the 2D context 42 * wants them comma-separated, hence the normalise. 43 */ 44 function readPalette(cv: HTMLCanvasElement): Palette { 45 const cs = getComputedStyle(cv); 46 const channels = (name: string, fallback: string) => { 47 const v = cs.getPropertyValue(name).trim(); 48 return v ? v.replace(/\s+/g, ',') : fallback; 49 }; 50 const blend = cs.getPropertyValue('--fuzz-blend').trim(); 51 const gain = parseFloat(cs.getPropertyValue('--fuzz-gain')); 52 return { 53 blend: (blend || FALLBACK.blend) as GlobalCompositeOperation, 54 bands: [ 55 channels('--fuzz-band-1', FALLBACK.bands[0]), 56 channels('--fuzz-band-2', FALLBACK.bands[1]), 57 channels('--fuzz-band-3', FALLBACK.bands[2]), 58 ], 59 filaments: [ 60 channels('--fuzz-fil-1', FALLBACK.filaments[0]), 61 channels('--fuzz-fil-2', FALLBACK.filaments[1]), 62 channels('--fuzz-fil-3', FALLBACK.filaments[2]), 63 ], 64 gain: Number.isFinite(gain) ? gain : FALLBACK.gain, 65 }; 66 } 67 68 /** One frame of the field at `phase` ∈ [0,1). Every band's drift and breathe is 69 * a whole number of cycles per loop, so the 9s loop seams cleanly and any two 70 * plates on the page stay in step. */ 71 function frame(cv: HTMLCanvasElement, phase: number, amt: number, pal: Palette): void { 72 const host = cv.parentElement; 73 const w = (cv.clientWidth || host?.clientWidth || 0) | 0; 74 const h = (cv.clientHeight || host?.clientHeight || 0) | 0; 75 if (!w || !h) return; 76 77 const dpr = Math.min(2, devicePixelRatio || 1); 78 const bw = Math.round(w * dpr); 79 const bh = Math.round(h * dpr); 80 if (cv.width !== bw || cv.height !== bh) { cv.width = bw; cv.height = bh; } 81 82 const ctx = cv.getContext('2d'); 83 if (!ctx) return; 84 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 85 86 const frac = (x: number) => x - Math.floor(x); 87 const rnd = (s: number) => frac(Math.sin(s * 127.1) * 43758.5453); 88 89 ctx.clearRect(0, 0, w, h); 90 ctx.globalCompositeOperation = pal.blend; 91 92 for (let bi = 0; bi < BANDS.length; bi++) { 93 const b = BANDS[bi]; 94 const cy = b.cy * h; 95 const ch = b.ch * h; 96 for (let y = Math.max(0, Math.round(cy - ch)); y < Math.min(h, cy + ch); y += 2) { 97 const fall = 1 - Math.abs(y - cy) / ch; 98 const breathe = 0.5 + 0.5 * Math.sin(2 * Math.PI * (b.cyc * phase + y * 0.004 + bi * 0.3)); 99 const env = fall * (0.4 + 0.6 * breathe); 100 if (env <= 0.02) continue; 101 const n = Math.round(env * b.dens * amt); 102 for (let i = 0; i < n; i++) { 103 const s = y * 7.3 + i * 13.7 + bi * 101.3; 104 const x = frac(rnd(s) + phase * b.drift) * (w + 420) - 210; 105 const len = (18 + rnd(s + 1) * 340) * (0.4 + env); 106 const hx = 0.22 + 0.78 * Math.pow(Math.sin(Math.PI * ((x / w) * (1 + (bi % 3)) + phase + rnd(bi * 9))), 2); 107 const a = (0.06 + rnd(s + 2) * 0.34) * env * hx * 1.7 * pal.gain; 108 ctx.fillStyle = `rgba(${pal.bands[b.hue - 1]},${a.toFixed(3)})`; 109 ctx.fillRect(x, y, len, rnd(s + 3) > 0.84 ? 2 : 1); 110 } 111 } 112 } 113 114 for (let i = 0; i < 54; i++) { 115 const s = i * 37.1; 116 const y = Math.round((rnd(s) * h) / 2) * 2; 117 const cyc = 1 + (i % 2); 118 const x = frac(rnd(s + 5) + phase * cyc) * (w + 700) - 350; 119 const a = (0.1 + rnd(s + 6) * 0.28) * amt * pal.gain; 120 ctx.fillStyle = `rgba(${pal.filaments[i % 3]},${a.toFixed(3)})`; 121 ctx.fillRect(x, y, 120 + rnd(s + 7) * 520, 1); 122 } 123 124 // Scrim by erasure: destination-out bands at top and bottom so type near the 125 // plate edges always sits on clean ink. The percentage clamp matters — a 126 // fixed 40px erased most of the short plates. 127 ctx.globalCompositeOperation = 'destination-out'; 128 const solid = Math.min(40, h * 0.14); 129 const ramp = Math.min(26, h * 0.1); 130 ctx.fillStyle = 'rgba(0,0,0,1)'; 131 ctx.fillRect(0, 0, w, solid); 132 ctx.fillRect(0, h - solid, w, solid); 133 let g = ctx.createLinearGradient(0, solid, 0, solid + ramp); 134 g.addColorStop(0, 'rgba(0,0,0,1)'); 135 g.addColorStop(1, 'rgba(0,0,0,0)'); 136 ctx.fillStyle = g; 137 ctx.fillRect(0, solid, w, ramp); 138 g = ctx.createLinearGradient(0, h - solid - ramp, 0, h - solid); 139 g.addColorStop(0, 'rgba(0,0,0,0)'); 140 g.addColorStop(1, 'rgba(0,0,0,1)'); 141 ctx.fillStyle = g; 142 ctx.fillRect(0, h - solid - ramp, w, ramp); 143 ctx.globalCompositeOperation = 'source-over'; 144 } 145 146 /** Density scale. The main site ships at 6; the constant exists for tuning. */ 147 const PRESS = 6; 148 /** The frame reduced motion keeps as the permanent texture — mid-loop, where 149 * the bands are at their fullest rather than at a seam. */ 150 const STILL = 0.3; 151 152 /** Bind every unbound `[data-fuzz]` canvas on the page and start its loop. 153 * Idempotent — a second call skips already-bound canvases, so it is safe to 154 * call on every `astro:page-load`. */ 155 export function initFuzz(): void { 156 const canvases = document.querySelectorAll<HTMLCanvasElement>('[data-fuzz]:not([data-bound])'); 157 canvases.forEach((cv) => { 158 cv.dataset.bound = '1'; 159 const amt = Math.max(0.2, PRESS / 5); 160 const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.dataset.motion === 'reduce'; 161 162 let pal = readPalette(cv); 163 164 const still = () => frame(cv, STILL, amt, pal); 165 frame(cv, reduced ? STILL : (performance.now() / 9000) % 1, amt, pal); 166 167 const ro = new ResizeObserver(() => { if (reduced) still(); }); 168 ro.observe(cv.parentElement ?? cv); 169 170 const onMode = () => { pal = readPalette(cv); if (reduced) still(); }; 171 addEventListener('daemonmodechange', onMode); 172 173 let raf = 0; 174 if (!reduced) { 175 const loop = (now: number) => { 176 if (!document.hidden && document.documentElement.dataset.motion !== 'reduce') frame(cv, (now / 9000) % 1, amt, pal); 177 raf = requestAnimationFrame(loop); 178 }; 179 raf = requestAnimationFrame(loop); 180 } 181 182 document.addEventListener('astro:before-swap', () => { 183 cancelAnimationFrame(raf); 184 ro.disconnect(); 185 removeEventListener('daemonmodechange', onMode); 186 }, { once: true }); 187 }); 188 }