|
| 1 | +--- |
| 2 | +/* |
| 3 | + * Thread sketch — 扎 / a horizontal weave that responds to cues. |
| 4 | + * |
| 5 | + * Stage for engineering essays whose prose moves through structural |
| 6 | + * states. Listens for `stage:cue` events from EssayLayout's reading- |
| 7 | + * line observer and shifts a field of horizontal threads through |
| 8 | + * preset configurations. Each cue's visual state pairs with a |
| 9 | + * structural insight in the prose: |
| 10 | + * |
| 11 | + * empty — one thread drifting; the "before" state. |
| 12 | + * strip — the thread lays flat across the canvas. |
| 13 | + * pipeline — the thread colored in three zones (raw → done). |
| 14 | + * layers — eight threads arranged in three stacked groups. |
| 15 | + * lifecycle — threads visible only through a moving window mask. |
| 16 | + * ticking — two perpendicular pulse bars sweep with phase offset. |
| 17 | + * recap — full weave, gentle breathing. |
| 18 | + * |
| 19 | + * Per-frame: lerp current params toward target (cue-driven), draw |
| 20 | + * threads as sine-wobbled polylines, optionally apply a window mask |
| 21 | + * or sweep bars. Threads have stable random phase + amplitude |
| 22 | + * multipliers so transitions look organic rather than synced. |
| 23 | + * |
| 24 | + * Lifecycle: see src/lib/sketch-lifecycle.ts. |
| 25 | + */ |
| 26 | +--- |
| 27 | + |
| 28 | +<canvas id="thread"></canvas> |
| 29 | + |
| 30 | +<script> |
| 31 | + import { mountSketch } from '../lib/sketch-lifecycle'; |
| 32 | + |
| 33 | + mountSketch(() => { |
| 34 | + const canvas = document.getElementById('thread') as HTMLCanvasElement | null; |
| 35 | + if (!canvas) return; |
| 36 | + const ctx = canvas.getContext('2d'); |
| 37 | + if (!ctx) return; |
| 38 | + |
| 39 | + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); |
| 40 | + let w = 0, h = 0; |
| 41 | + |
| 42 | + function resize() { |
| 43 | + const rect = canvas!.getBoundingClientRect(); |
| 44 | + w = rect.width; |
| 45 | + h = rect.height; |
| 46 | + canvas!.width = w * dpr; |
| 47 | + canvas!.height = h * dpr; |
| 48 | + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 49 | + } |
| 50 | + resize(); |
| 51 | + |
| 52 | + // Read CSS variables once at mount. Tint classes are scoped to the |
| 53 | + // article (e.g. `.tint-dune`), not :root — so reading from |
| 54 | + // documentElement gives us the un-tinted defaults (--accent === --fg, |
| 55 | + // i.e. nearly white). Read from the tinted article when present so |
| 56 | + // pulses and zone gradients pick up the page's accent. We're torn |
| 57 | + // down + remounted on SPA swap so any tint change arrives fresh. |
| 58 | + const tinted = document.querySelector('article[class*="tint-"]') ?? document.documentElement; |
| 59 | + const cs = getComputedStyle(tinted); |
| 60 | + const fgDim = (cs.getPropertyValue('--fg-dim').trim() || '#aaa'); |
| 61 | + const accent = (cs.getPropertyValue('--accent').trim() || '#b6a078'); |
| 62 | + |
| 63 | + // Eight thread slots. Each slot is always processed each frame; its |
| 64 | + // alpha + y are driven by the active preset. Empty cues just hide |
| 65 | + // most slots by lerping their alpha to 0. |
| 66 | + const SLOTS = 8; |
| 67 | + interface Slot { |
| 68 | + phase: number; |
| 69 | + freqMult: number; |
| 70 | + ampMult: number; |
| 71 | + } |
| 72 | + const slots: Slot[] = Array.from({ length: SLOTS }, () => ({ |
| 73 | + phase: Math.random() * Math.PI * 2, |
| 74 | + freqMult: 0.85 + Math.random() * 0.3, |
| 75 | + ampMult: 0.85 + Math.random() * 0.3, |
| 76 | + })); |
| 77 | + |
| 78 | + // A preset is "what target state a cue maps to". yNorm is normalized |
| 79 | + // [0,1] vertical position; visible booleans become alpha targets. |
| 80 | + interface Preset { |
| 81 | + visible: boolean[]; |
| 82 | + yNorm: number[]; |
| 83 | + amplitude: number; |
| 84 | + freq: number; |
| 85 | + zoned: number; // 0..1 — strength of 3-zone gradient |
| 86 | + pulses: number; // 0..2 — number of sweep bars |
| 87 | + lifecycleMask: number; // 0..1 — strength of traveling window |
| 88 | + baseAlpha: number; |
| 89 | + } |
| 90 | + |
| 91 | + const PRESETS: Record<string, Preset> = { |
| 92 | + // One thread, alone, drifting more loosely. |
| 93 | + empty: { |
| 94 | + visible: [false, false, false, true, false, false, false, false], |
| 95 | + yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
| 96 | + amplitude: 8, freq: 0.45, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.35, |
| 97 | + }, |
| 98 | + // Same single thread, calmed down. The "lays flat" pose. |
| 99 | + strip: { |
| 100 | + visible: [false, false, false, true, false, false, false, false], |
| 101 | + yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
| 102 | + amplitude: 1.5, freq: 0.30, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.75, |
| 103 | + }, |
| 104 | + // Same thread but now zone-colored: 3 segments, accent → fg-dim → accent. |
| 105 | + pipeline: { |
| 106 | + visible: [false, false, false, true, false, false, false, false], |
| 107 | + yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
| 108 | + amplitude: 2, freq: 0.40, zoned: 1, pulses: 0, lifecycleMask: 0, baseAlpha: 0.85, |
| 109 | + }, |
| 110 | + // Eight threads in three loose bands. Top group, middle group, |
| 111 | + // bottom group — visually echoes pan / chrome / staff-lines. |
| 112 | + layers: { |
| 113 | + visible: [true, true, true, true, true, true, true, true], |
| 114 | + yNorm: [0.22, 0.28, 0.34, 0.46, 0.52, 0.58, 0.70, 0.76], |
| 115 | + amplitude: 4, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.7, |
| 116 | + }, |
| 117 | + // Five threads in the middle, but rendered through a moving window. |
| 118 | + lifecycle: { |
| 119 | + visible: [false, true, true, true, true, true, false, false], |
| 120 | + yNorm: [0.25, 0.36, 0.43, 0.50, 0.57, 0.64, 0.72, 0.80], |
| 121 | + amplitude: 3, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 1, baseAlpha: 0.65, |
| 122 | + }, |
| 123 | + // Six threads in two groups; two pulses sweep with phase offset. |
| 124 | + ticking: { |
| 125 | + visible: [true, true, true, false, false, true, true, true], |
| 126 | + yNorm: [0.30, 0.36, 0.42, 0.50, 0.50, 0.58, 0.64, 0.70], |
| 127 | + amplitude: 2, freq: 0.40, zoned: 0, pulses: 2, lifecycleMask: 0, baseAlpha: 0.75, |
| 128 | + }, |
| 129 | + // Full weave, gentle breathing — every slot active. |
| 130 | + recap: { |
| 131 | + visible: [true, true, true, true, true, true, true, true], |
| 132 | + yNorm: [0.22, 0.30, 0.38, 0.46, 0.54, 0.62, 0.70, 0.78], |
| 133 | + amplitude: 3, freq: 0.45, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.85, |
| 134 | + }, |
| 135 | + }; |
| 136 | + |
| 137 | + // Initial state — start at "empty" so a fresh page reads as a held |
| 138 | + // breath even before the first cue fires (compute in EssayLayout |
| 139 | + // primes a cue immediately, but rAF may render a frame first). |
| 140 | + // |
| 141 | + // y is stored as a *normalized* fraction (0..1) so a resize doesn't |
| 142 | + // strand thread positions in stale pixel coordinates; we multiply |
| 143 | + // by current h at draw time. Amplitude stays in pixels — wobble |
| 144 | + // height should feel the same regardless of canvas height. |
| 145 | + type AnimState = { |
| 146 | + alpha: number[]; // per slot 0..1 |
| 147 | + yNorm: number[]; // per slot 0..1 |
| 148 | + amplitude: number; |
| 149 | + freq: number; |
| 150 | + zoned: number; |
| 151 | + pulses: number; |
| 152 | + lifecycleMask: number; |
| 153 | + baseAlpha: number; |
| 154 | + }; |
| 155 | + |
| 156 | + function fromPreset(p: Preset): AnimState { |
| 157 | + return { |
| 158 | + alpha: p.visible.map((v) => (v ? 1 : 0)), |
| 159 | + yNorm: [...p.yNorm], |
| 160 | + amplitude: p.amplitude, |
| 161 | + freq: p.freq, |
| 162 | + zoned: p.zoned, |
| 163 | + pulses: p.pulses, |
| 164 | + lifecycleMask: p.lifecycleMask, |
| 165 | + baseAlpha: p.baseAlpha, |
| 166 | + }; |
| 167 | + } |
| 168 | + |
| 169 | + let cur: AnimState = fromPreset(PRESETS.empty); |
| 170 | + let target: AnimState = fromPreset(PRESETS.empty); |
| 171 | + |
| 172 | + function setCue(name: string) { |
| 173 | + const p = PRESETS[name]; |
| 174 | + if (!p) return; |
| 175 | + target = fromPreset(p); |
| 176 | + } |
| 177 | + |
| 178 | + function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } |
| 179 | + function lerpArr(a: number[], b: number[], t: number) { |
| 180 | + const out = new Array(a.length); |
| 181 | + for (let i = 0; i < a.length; i++) out[i] = lerp(a[i], b[i], t); |
| 182 | + return out; |
| 183 | + } |
| 184 | + |
| 185 | + let paused = false; |
| 186 | + function onPause(e: Event) { |
| 187 | + paused = !!(e as CustomEvent).detail?.paused; |
| 188 | + } |
| 189 | + function onCue(e: Event) { |
| 190 | + const cue = (e as CustomEvent).detail?.cue; |
| 191 | + if (typeof cue === 'string') setCue(cue); |
| 192 | + } |
| 193 | + window.addEventListener('stage:cue', onCue); |
| 194 | + window.addEventListener('stage:pause', onPause); |
| 195 | + |
| 196 | + // ResizeObserver picks up the canvas's actual layout size — the |
| 197 | + // sticky stage may resolve its 100vh after our initial resize() |
| 198 | + // runs, and pure window.resize doesn't fire on layout settles. |
| 199 | + const ro = new ResizeObserver(() => resize()); |
| 200 | + ro.observe(canvas); |
| 201 | + |
| 202 | + function drawThread(slot: Slot, baseY: number, amplitude: number, freq: number, time: number, alpha: number, zoned: number) { |
| 203 | + if (alpha < 0.01) return; |
| 204 | + const STEP = 6; |
| 205 | + ctx!.beginPath(); |
| 206 | + for (let x = 0; x <= w; x += STEP) { |
| 207 | + const wave = amplitude * slot.ampMult * Math.sin(slot.freqMult * freq * (x / w) * Math.PI * 4 + slot.phase + time * 0.0006); |
| 208 | + const y = baseY + wave; |
| 209 | + if (x === 0) ctx!.moveTo(x, y); |
| 210 | + else ctx!.lineTo(x, y); |
| 211 | + } |
| 212 | + if (zoned > 0.01) { |
| 213 | + // Three-zone gradient: accent → fg-dim → accent. Mix toward |
| 214 | + // pure fg-dim as zoned decreases so the transition lerps |
| 215 | + // smoothly between zoned and unzoned states. |
| 216 | + const grad = ctx!.createLinearGradient(0, 0, w, 0); |
| 217 | + grad.addColorStop(0, accent); |
| 218 | + grad.addColorStop(0.32, accent); |
| 219 | + grad.addColorStop(0.34, fgDim); |
| 220 | + grad.addColorStop(0.66, fgDim); |
| 221 | + grad.addColorStop(0.68, accent); |
| 222 | + grad.addColorStop(1, accent); |
| 223 | + ctx!.strokeStyle = grad; |
| 224 | + ctx!.globalAlpha = alpha * (0.35 + 0.5 * zoned); |
| 225 | + } else { |
| 226 | + ctx!.strokeStyle = fgDim; |
| 227 | + ctx!.globalAlpha = alpha; |
| 228 | + } |
| 229 | + ctx!.lineWidth = 1.0; |
| 230 | + ctx!.stroke(); |
| 231 | + } |
| 232 | + |
| 233 | + let aborted = false; |
| 234 | + let rafId = 0; |
| 235 | + let last = performance.now(); |
| 236 | + |
| 237 | + function frame(time: number) { |
| 238 | + if (aborted) return; |
| 239 | + const dt = Math.min(time - last, 64); |
| 240 | + last = time; |
| 241 | + if (!paused) { |
| 242 | + // ~250ms half-life feels confident without snapping. |
| 243 | + const k = 1 - Math.exp(-dt / 250); |
| 244 | + cur.alpha = lerpArr(cur.alpha, target.alpha, k); |
| 245 | + cur.yNorm = lerpArr(cur.yNorm, target.yNorm, k); |
| 246 | + cur.amplitude = lerp(cur.amplitude, target.amplitude, k); |
| 247 | + cur.freq = lerp(cur.freq, target.freq, k); |
| 248 | + cur.zoned = lerp(cur.zoned, target.zoned, k); |
| 249 | + cur.pulses = lerp(cur.pulses, target.pulses, k); |
| 250 | + cur.lifecycleMask = lerp(cur.lifecycleMask, target.lifecycleMask, k); |
| 251 | + cur.baseAlpha = lerp(cur.baseAlpha, target.baseAlpha, k); |
| 252 | + } |
| 253 | + |
| 254 | + ctx!.clearRect(0, 0, w, h); |
| 255 | + ctx!.globalCompositeOperation = 'source-over'; |
| 256 | + |
| 257 | + // Draw threads. yNorm * h resolves to current pixel position so |
| 258 | + // resize-time changes carry through automatically. |
| 259 | + for (let i = 0; i < SLOTS; i++) { |
| 260 | + drawThread(slots[i], cur.yNorm[i] * h, cur.amplitude, cur.freq, time, cur.alpha[i] * cur.baseAlpha, cur.zoned); |
| 261 | + } |
| 262 | + ctx!.globalAlpha = 1; |
| 263 | + |
| 264 | + // Lifecycle window mask: keep only what's inside a feathered |
| 265 | + // band that travels left → right. After it exits the right, a |
| 266 | + // brief gap, then it re-enters from the left. Reads as: "things |
| 267 | + // mount, drift across, unmount." |
| 268 | + if (cur.lifecycleMask > 0.01) { |
| 269 | + const period = 5200; |
| 270 | + const t = (time % period) / period; // 0..1 |
| 271 | + const winCx = -0.25 * w + t * (1.5 * w); // sweep -25%..125% |
| 272 | + const winHalf = w * 0.18; |
| 273 | + const feather = w * 0.10; |
| 274 | + ctx!.globalCompositeOperation = 'destination-in'; |
| 275 | + const grad = ctx!.createLinearGradient(0, 0, w, 0); |
| 276 | + const stops: [number, string][] = [ |
| 277 | + [0, 'rgba(0,0,0,0)'], |
| 278 | + [Math.max(0, (winCx - winHalf - feather) / w), 'rgba(0,0,0,0)'], |
| 279 | + [Math.max(0, (winCx - winHalf) / w), `rgba(0,0,0,${cur.lifecycleMask})`], |
| 280 | + [Math.min(1, (winCx + winHalf) / w), `rgba(0,0,0,${cur.lifecycleMask})`], |
| 281 | + [Math.min(1, (winCx + winHalf + feather) / w), 'rgba(0,0,0,0)'], |
| 282 | + [1, 'rgba(0,0,0,0)'], |
| 283 | + ]; |
| 284 | + // Sort by stop position (Math.max/min may have produced unsorted) |
| 285 | + stops.sort((a, b) => a[0] - b[0]); |
| 286 | + for (const [stop, color] of stops) { |
| 287 | + if (stop >= 0 && stop <= 1) grad.addColorStop(stop, color); |
| 288 | + } |
| 289 | + ctx!.fillStyle = grad; |
| 290 | + ctx!.fillRect(0, 0, w, h); |
| 291 | + |
| 292 | + // Outside the mask we want full transparency (1 - lifecycleMask |
| 293 | + // gives a pass-through during transitions so the threads aren't |
| 294 | + // erased mid-fade between cues). |
| 295 | + ctx!.globalCompositeOperation = 'destination-over'; |
| 296 | + ctx!.fillStyle = `rgba(0,0,0,${1 - cur.lifecycleMask})`; |
| 297 | + ctx!.fillRect(0, 0, w, h); |
| 298 | + ctx!.globalCompositeOperation = 'source-over'; |
| 299 | + } |
| 300 | + |
| 301 | + // Pulse bars (ticking cue). Two slim accent-colored sweeps, second |
| 302 | + // offset by a small phase so they read as not-quite-synced — the |
| 303 | + // visual analog of rAF vs AudioContext. |
| 304 | + const pulseCount = Math.round(cur.pulses); |
| 305 | + if (pulseCount > 0) { |
| 306 | + const period = 3800; |
| 307 | + const bandTop = h * 0.18; |
| 308 | + const bandH = h * 0.64; |
| 309 | + for (let p = 0; p < pulseCount; p++) { |
| 310 | + const phaseOffset = p * 0.16; |
| 311 | + const t = (((time / period) + phaseOffset) % 1 + 1) % 1; |
| 312 | + const x = t * w; |
| 313 | + const half = 22; |
| 314 | + const grad = ctx!.createLinearGradient(x - half, 0, x + half, 0); |
| 315 | + grad.addColorStop(0, 'rgba(0,0,0,0)'); |
| 316 | + grad.addColorStop(0.5, accent); |
| 317 | + grad.addColorStop(1, 'rgba(0,0,0,0)'); |
| 318 | + ctx!.globalAlpha = 0.55 * (cur.pulses / pulseCount); |
| 319 | + ctx!.fillStyle = grad; |
| 320 | + ctx!.fillRect(x - half, bandTop, half * 2, bandH); |
| 321 | + } |
| 322 | + ctx!.globalAlpha = 1; |
| 323 | + } |
| 324 | + |
| 325 | + rafId = requestAnimationFrame(frame); |
| 326 | + } |
| 327 | + rafId = requestAnimationFrame(frame); |
| 328 | + |
| 329 | + return () => { |
| 330 | + aborted = true; |
| 331 | + cancelAnimationFrame(rafId); |
| 332 | + window.removeEventListener('stage:cue', onCue); |
| 333 | + window.removeEventListener('stage:pause', onPause); |
| 334 | + ro.disconnect(); |
| 335 | + }; |
| 336 | + }); |
| 337 | +</script> |
| 338 | + |
| 339 | +<style> |
| 340 | + canvas#thread { |
| 341 | + display: block; |
| 342 | + width: 100%; |
| 343 | + height: 100%; |
| 344 | + } |
| 345 | +</style> |
0 commit comments