particle-field.ts (38146B)
1 /** 2 * The dot field, framework-free. Generated from daemon-sec's 3 * client/src/components/daemon/particle-field.tsx (same shaders, same 4 * timing) so the sibling sites share one look without React. 5 * 6 * Two layers. The cloud: blue-noise dashes that drift and lean toward a 7 * ring lagging the pointer, thinning under any `[data-index-zone]` box. 8 * The icon layer (opt-in): dots that gather into the lit shelf's icon, 9 * placed on a `[data-field-icon]` box inside the canvas's 10 * `[data-field-host]` ancestor. On a light ground dots get a minimum size 11 * and more ink. Colours are read off the canvas's computed style and 12 * re-read whenever <html> changes its theme attributes. Pauses off-screen 13 * and in hidden tabs; one still frame under reduced motion (OS setting or 14 * `<html data-motion="reduce">`); `onFail` if WebGL is unavailable. 15 */ 16 import { minDistFor, poisson, rng } from "./poisson"; 17 import { nearestInked, rasterise, targetsFor } from "./icon-targets"; 18 19 export type Accent = string; 20 export interface LitShelf { 21 index: number | null; 22 accent: Accent | null; 23 /** True while a pointer holds the row (vs. a tour lighting it). */ 24 held: boolean; 25 } 26 export interface ShelfSource { 27 get(): LitShelf; 28 subscribe(f: (s: LitShelf) => void): () => void; 29 } 30 31 export interface FieldOptions { 32 /** Custom-property names for colours, resolved on the canvas. */ 33 vars?: { ink: string; base: string; iris: string; love: string }; 34 /** Accent colour variable for a shelf accent name. */ 35 accentVar?: (accent: string) => string; 36 pageFamily?: string; 37 plateFamily?: string; 38 tone?: "page" | "plate"; 39 /** Draw the drifting cloud (default true). */ 40 cloud?: boolean; 41 /** SVG markup per shelf, in shelf order; enables the icon layer. */ 42 icons?: string[]; 43 /** Which shelf is lit; required with `icons`. */ 44 shelf?: ShelfSource; 45 /** Icon dots rest spread across the canvas and stay visible. */ 46 restFill?: boolean; 47 /** Also gather for a shelf a tour lights, not only a held one. */ 48 tour?: boolean; 49 /** Override motion; defaults to off under reduced motion. */ 50 live?: boolean; 51 onFail?: () => void; 52 } 53 54 const NO_SHELF: ShelfSource = { 55 get: () => ({ index: null, accent: null, held: false }), 56 subscribe: () => () => {}, 57 }; 58 59 type Rgb = [number, number, number]; 60 61 function hexToRgb(hex: string, fallback: Rgb): Rgb { 62 const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); 63 if (!m) return fallback; 64 const n = parseInt(m[1], 16); 65 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; 66 } 67 68 function readVar(el: HTMLElement, name: string, fallback: Rgb): Rgb { 69 return hexToRgb(getComputedStyle(el).getPropertyValue(name), fallback); 70 } 71 72 const NOISE = ` 73 vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } 74 vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } 75 vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } 76 vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } 77 float snoise(vec3 v) { 78 const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); 79 const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); 80 vec3 i = floor(v + dot(v, C.yyy)); 81 vec3 x0 = v - i + dot(i, C.xxx); 82 vec3 g = step(x0.yzx, x0.xyz); 83 vec3 l = 1.0 - g; 84 vec3 i1 = min(g.xyz, l.zxy); 85 vec3 i2 = max(g.xyz, l.zxy); 86 vec3 x1 = x0 - i1 + C.xxx; 87 vec3 x2 = x0 - i2 + C.yyy; 88 vec3 x3 = x0 - D.yyy; 89 i = mod289(i); 90 vec4 p = permute(permute(permute( 91 i.z + vec4(0.0, i1.z, i2.z, 1.0)) 92 + i.y + vec4(0.0, i1.y, i2.y, 1.0)) 93 + i.x + vec4(0.0, i1.x, i2.x, 1.0)); 94 float n_ = 0.142857142857; 95 vec3 ns = n_ * D.wyz - D.xzx; 96 vec4 j = p - 49.0 * floor(p * ns.z * ns.z); 97 vec4 x_ = floor(j * ns.z); 98 vec4 y_ = floor(j - 7.0 * x_); 99 vec4 x = x_ * ns.x + ns.yyyy; 100 vec4 y = y_ * ns.x + ns.yyyy; 101 vec4 h = 1.0 - abs(x) - abs(y); 102 vec4 b0 = vec4(x.xy, y.xy); 103 vec4 b1 = vec4(x.zw, y.zw); 104 vec4 s0 = floor(b0) * 2.0 + 1.0; 105 vec4 s1 = floor(b1) * 2.0 + 1.0; 106 vec4 sh = -step(h, vec4(0.0)); 107 vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; 108 vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; 109 vec3 p0 = vec3(a0.xy, h.x); 110 vec3 p1 = vec3(a0.zw, h.y); 111 vec3 p2 = vec3(a1.xy, h.z); 112 vec3 p3 = vec3(a1.zw, h.w); 113 vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); 114 p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; 115 vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); 116 m = m * m; 117 return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); 118 } 119 `; 120 121 /* Field units: y runs -1..1 over the canvas height, x is ±aspect. */ 122 const VERT = ` 123 precision highp float; 124 attribute vec2 a_rest; 125 attribute vec4 a_seed; 126 attribute vec2 a_from; 127 attribute vec2 a_to; 128 129 uniform float u_time; 130 uniform vec2 u_extent; 131 uniform float u_pr; 132 uniform float u_pscale; 133 uniform float u_maxPoint; 134 uniform float u_layer; 135 uniform float u_hover; 136 uniform float u_morph; 137 uniform float u_pulse; 138 uniform float u_iconReady; 139 uniform float u_fill; 140 uniform float u_light; 141 uniform float u_ringR; 142 uniform float u_ringW; 143 uniform float u_ringW2; 144 uniform float u_ringDisp; 145 uniform vec2 u_ring; 146 uniform vec2 u_ringLag; 147 uniform vec4 u_zone; 148 uniform vec4 u_icon; 149 150 varying float v_scale; 151 varying float v_near; 152 varying float v_col; 153 varying vec2 v_dir; 154 155 ${NOISE} 156 157 /* The cloud thins under the shelf list so the rows stay readable, but 158 only partly and on a wide, rounded falloff: a hard cut-out read as a 159 box drawn round the list. Measured as a rounded rectangle with a 160 radius as big as its short side, so there is no corner to see. 161 No list, no thinning. */ 162 float zoneFade(vec2 p, float margin) { 163 if (u_zone.z <= u_zone.x) return 1.0; 164 vec2 c = 0.5 * (u_zone.xy + u_zone.zw); 165 vec2 h = 0.5 * (u_zone.zw - u_zone.xy); 166 float r = min(h.x, h.y) * 0.9; 167 vec2 q = abs(p - c) - h + r; 168 float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; 169 return mix(0.45, 1.0, smoothstep(-0.25, margin, sd)); 170 } 171 172 void main() { 173 float t = u_time * 0.5; 174 vec2 p; 175 float scale; 176 float near = 0.0; 177 178 if (u_layer < 0.5) { 179 vec2 rest = a_rest; 180 /* each dot sees the ring a little behind where it is, by its own 181 amount, which spreads the settle across the field */ 182 vec2 ring = mix(u_ringLag, u_ring, a_seed.w); 183 float dist = distance(rest, ring); 184 float noise0 = snoise(vec3(rest * 0.2 + vec2(18.4924, 72.9744), t * 0.5)); 185 float dist1 = distance(rest + noise0 * 0.005, ring); 186 float R = u_ringR; 187 float t1 = smoothstep(R - u_ringW * 2.0, R, dist) - smoothstep(R, R + u_ringW, dist1); 188 float t2 = smoothstep(R - u_ringW2 * 2.0, R, dist) - smoothstep(R, R + u_ringW2, dist1); 189 float t3 = smoothstep(R + u_ringW2, R, dist); 190 t1 = t1 * t1; 191 t2 = t2 * t2 * t2; 192 float s = t1 + t2 * 3.0 + t3 * 0.4; 193 s += snoise(vec3(rest * 30.0 + vec2(11.4924, 12.9744), t * 0.5)) * t3 * 0.5; 194 float nS = snoise(vec3(rest * 2.0 + vec2(18.4924, 72.9744), t * 0.5)); 195 s += pow((nS + 1.5) * 0.5, 2.0) * 0.6; 196 197 float n1 = snoise(vec3(rest * 4.0 + vec2(88.494, 32.4397), t * 0.35)); 198 float n2 = snoise(vec3(rest * 4.0 + vec2(50.904, 120.947), t * 0.35)); 199 float n3 = snoise(vec3(rest * 20.0 + vec2(18.4924, 72.9744), t * 0.5)); 200 float n4 = snoise(vec3(rest * 20.0 + vec2(50.904, 120.947), t * 0.5)); 201 vec2 disp = vec2(n1, n2) * 0.03 + vec2(n3, n4) * 0.005; 202 float reach = clamp(dist, 0.0, 1.0); 203 disp.x += sin(rest.x * 20.0 + t * 4.0) * 0.02 * reach; 204 disp.y += cos(rest.y * 20.0 + t * 3.0) * 0.02 * reach; 205 206 vec2 cur = rest + disp; 207 /* the rim is pushed off the ring */ 208 p = cur - (ring - cur) * pow(max(t2, 0.0), 0.75) * u_ringDisp * 0.3; 209 scale = (s + 0.2) * zoneFade(p, 0.25); 210 211 float nA = snoise(vec3(rest * 10.0 + vec2(18.4924, 72.9744), u_time * 0.85)); 212 float ang = atan(p.y - ring.y, p.x - ring.x) + nA * 0.5; 213 v_dir = vec2(cos(ang), sin(ang)); 214 v_col = (snoise(vec3(rest * 2.0 + vec2(74.664, 91.556), u_time * 0.5)) + 1.0) * 0.5; 215 } else { 216 vec2 iconLocal = mix(a_from, a_to, smoothstep(0.0, 1.0, u_morph)); 217 218 /* The icon as a volume, not a stencil. Each dot has its own depth 219 through the figure (a stable hash of its seed), and the figure 220 turns a little: a slow idle sway, plus a lean toward the pointer 221 so it follows the hand the way the cloud does. With depth, the 222 turn makes the front of the figure travel further than the back 223 and the dots nearest the eye draw larger and brighter (below), so 224 a gathered shelf icon reads as a small object made of dots 225 rather than a flat outline — the same outline, with a thickness. 226 The rotation is small enough that the glyph never stops reading 227 as itself. */ 228 float depth = fract(a_seed.y * 13.37 + a_seed.z * 0.71) - 0.5; 229 vec2 lean = clamp((u_ringLag - u_icon.xy) / max(u_icon.z * 3.0, 0.001), -1.0, 1.0); 230 float ay = sin(u_time * 0.55) * 0.28 + lean.x * 0.3; 231 float ax = cos(u_time * 0.41) * 0.18 - lean.y * 0.22; 232 vec3 q = vec3(iconLocal, depth * 0.34); 233 q = vec3(q.x * cos(ay) + q.z * sin(ay), q.y, -q.x * sin(ay) + q.z * cos(ay)); 234 q = vec3(q.x, q.y * cos(ax) - q.z * sin(ax), q.y * sin(ax) + q.z * cos(ax)); 235 /* a touch of perspective: what is nearer is a little bigger */ 236 float nearness = q.z / 0.34; 237 vec2 iconPos = u_icon.xy + q.xy * (1.0 + nearness * 0.09) * u_icon.zw; 238 /* Where a dot sets out from: a soft disc round the icon (the square 239 sample, folded to polar), so nothing square is ever drawn. */ 240 float ang0 = atan(a_rest.y, a_rest.x); 241 float rad0 = max(abs(a_rest.x), abs(a_rest.y)) * 2.0; 242 vec2 restDisc = u_icon.xy + vec2(cos(ang0), sin(ang0)) * sqrt(rad0) * u_icon.z * 0.95; 243 /* or, on the plate, spread over the whole surface: the plate's own 244 dots are the ones that gather */ 245 vec2 restFill = a_rest * 2.0 * u_extent * 0.94; 246 vec2 restW = mix(restDisc, restFill, u_fill); 247 restW += 0.02 * vec2( 248 snoise(vec3(a_rest * 3.0, t * 0.2)), 249 snoise(vec3(a_rest * 3.0 + 31.7, t * 0.2))); 250 251 /* arrival, staggered per dot and eased like power3.out */ 252 float h = clamp((u_hover - a_seed.x * 0.35) / 0.65, 0.0, 1.0); 253 h = 1.0 - pow(1.0 - h, 3.0); 254 h *= u_iconReady; 255 /* On the plate a share of the dots stays behind, so the card keeps 256 its texture round the icon instead of emptying out. */ 257 h *= mix(1.0, step(0.3, a_seed.w), u_fill); 258 259 /* twinkle: a life cycle from the seed, invisible at its wrap */ 260 float life = fract(u_time / a_seed.z + a_seed.y); 261 float env = smoothstep(0.0, 0.2, life) * (1.0 - smoothstep(0.65, 1.0, life)); 262 263 p = mix(restW, iconPos, h); 264 float dist = distance(p, iconPos) / max(u_icon.z, 0.001); 265 near = h * smoothstep(0.08, 0.0, dist); 266 267 /* a ring expanding from the icon's centre after a change, nudging 268 the dots outward as it passes */ 269 if (u_pulse >= 0.0) { 270 vec2 fromC = p - u_icon.xy; 271 float ringD = abs(length(fromC) - u_pulse * u_icon.z * 0.9); 272 float k = ringD / max(u_icon.z, 0.001); 273 p += normalize(fromC + 0.0001) * 0.025 * u_icon.z * exp(-k * k * 60.0) * (1.0 - u_pulse); 274 } 275 276 /* Unseen at rest: the icon's dots only exist while a shelf is 277 hovered, appearing as they set out and fading as they return. */ 278 float shown = mix(smoothstep(0.0, 0.3, h), 1.0, u_fill); 279 scale = (env * (0.45 + 0.55 * a_seed.w) * 0.7 * (1.0 - h) + 1.6 * near + 0.3 * env * h) * shown; 280 /* the front of the figure draws a size up, the back a size down; 281 only once the dot has arrived, so the journey is unchanged */ 282 scale *= 1.0 + nearness * 0.22 * near; 283 v_col = near; 284 /* the depth rides out on v_dir, which the icon layer never reads 285 for direction: x is nearness in -1..1 */ 286 v_dir = vec2(nearness, 0.0); 287 } 288 289 v_near = near; 290 v_scale = scale; 291 if (scale < 0.1) { 292 gl_Position = vec4(2.0, 2.0, 2.0, 1.0); 293 gl_PointSize = 1.0; 294 return; 295 } 296 /* The light-ground minimum is for the page cloud; the plate's dots 297 are already drawn a size up, and doubling it made them chunky. */ 298 float px = scale * 7.0 * u_pr * 0.5 * u_pscale + u_light * 1.1 * u_pr * (1.0 - u_layer); 299 /* The plate's dots are its whole surface, not a wash behind a 300 page, so they are drawn a size up. */ 301 if (u_layer > 0.5) px = (px * 1.25 + 0.6 * u_pr) * (1.0 + 0.7 * u_fill); 302 gl_PointSize = min(u_maxPoint, px); 303 gl_Position = vec4(p.x / u_extent.x, p.y / u_extent.y, 0.0, 1.0); 304 } 305 `; 306 307 const FRAG = ` 308 precision highp float; 309 uniform float u_layer; 310 uniform float u_inkAlpha; 311 uniform float u_iconAlpha; 312 uniform vec3 u_c1; 313 uniform vec3 u_c2; 314 uniform vec3 u_c3; 315 uniform vec3 u_ink; 316 uniform vec3 u_accent; 317 uniform float u_light; 318 319 varying float v_scale; 320 varying float v_near; 321 varying float v_col; 322 varying vec2 v_dir; 323 324 float sdRoundBox(vec2 p, vec2 b, float r) { 325 vec2 q = abs(p) - b + r; 326 return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; 327 } 328 329 void main() { 330 vec2 uv = gl_PointCoord - 0.5; 331 uv.y = -uv.y; 332 float a; 333 vec3 c; 334 if (u_layer < 0.5) { 335 /* a short dash, turned to face the ring */ 336 vec2 q = vec2(uv.x * v_dir.x + uv.y * v_dir.y, -uv.x * v_dir.y + uv.y * v_dir.x); 337 float sd = sdRoundBox(q, vec2(0.5, mix(0.2, 0.3, u_light)), 0.25); 338 float shape = smoothstep(0.1, 0.0, sd); 339 a = shape * smoothstep(0.1, 0.2, v_scale) * u_inkAlpha; 340 float h = 0.8; 341 c = v_col < h ? mix(u_c1, u_c2, v_col / h) : mix(u_c2, u_c3, (v_col - h) / (1.0 - h)); 342 } else { 343 float disc = smoothstep(0.5, 0.4, length(uv)); 344 a = disc * smoothstep(0.1, 0.2, v_scale) * mix(u_inkAlpha, u_iconAlpha, v_near); 345 /* the back of the figure sits a little further into the ground 346 than the front: the depth cue the size change alone lacks */ 347 a *= 1.0 - 0.28 * v_near * clamp(-v_dir.x, 0.0, 1.0); 348 c = mix(u_ink, u_accent, smoothstep(0.05, 0.6, v_near)); 349 } 350 if (a < 0.01) discard; 351 gl_FragColor = vec4(c * a, a); 352 } 353 `; 354 355 function compile(gl: WebGLRenderingContext, type: number, src: string) { 356 const sh = gl.createShader(type); 357 if (!sh) return null; 358 gl.shaderSource(sh, src); 359 gl.compileShader(sh); 360 if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { 361 if (import.meta.env.DEV) console.error("particle-field shader:", gl.getShaderInfoLog(sh)); 362 gl.deleteShader(sh); 363 return null; 364 } 365 return sh; 366 } 367 368 /* ── Tweens, the GSAP way: retarget from wherever the value is now ── */ 369 const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3); 370 371 class Tween { 372 value: number; 373 private from: number; 374 private to: number; 375 private t0 = 0; 376 private dur = 1; 377 constructor(v: number) { 378 this.value = v; 379 this.from = v; 380 this.to = v; 381 } 382 go(to: number, dur: number, now: number) { 383 if (to === this.to && dur === this.dur) return; 384 this.from = this.value; 385 this.to = to; 386 this.dur = Math.max(1, dur); 387 this.t0 = now; 388 } 389 set(v: number) { 390 this.value = this.from = this.to = v; 391 } 392 tick(now: number) { 393 const x = Math.min(1, (now - this.t0) / this.dur); 394 this.value = this.from + (this.to - this.from) * easeOutCubic(x); 395 return this.value; 396 } 397 } 398 399 /** 400 * Pixels per field unit. The hero is a unit per half its height; a 401 * shorter band keeps roughly the hero's scale instead of squeezing the 402 * same cloud into less height, so dot spacing, the ring and the drift 403 * read the same on every page. 404 */ 405 function unitPx(w: number, h: number) { 406 return Math.max(h / 2, w * 0.26, 1); 407 } 408 409 /** The phase the still frame is drawn at. */ 410 const HELD_TIME = 31.0; 411 /** How long the pointer may rest before the ring starts to wander. */ 412 const IDLE_MS = 2800; 413 414 415 export function mountParticleField(cv: HTMLCanvasElement, options: FieldOptions = {}): () => void { 416 const fam0 = options.tone === "plate" ? options.plateFamily ?? "night" : options.pageFamily ?? "dawn"; 417 const opts = { 418 pageFamily: "dawn", 419 plateFamily: "night", 420 accentVar: (a: string) => `--color-${fam0}-${a}`, 421 ...options, 422 }; 423 const tone = opts.tone ?? "page"; 424 const withIcons = Boolean(opts.icons && opts.icons.length); 425 const withCloud = opts.cloud ?? true; 426 const restFill = opts.restFill ?? false; 427 const tour = opts.tour ?? false; 428 const heroShelf = opts.shelf ?? NO_SHELF; 429 const live = 430 opts.live ?? 431 (!window.matchMedia("(prefers-reduced-motion: reduce)").matches && 432 document.documentElement.dataset.motion !== "reduce"); 433 const coarse = window.matchMedia("(pointer: coarse)").matches; 434 const setFailed = (_f: boolean) => opts.onFail?.(); 435 436 const attrs: WebGLContextAttributes = { 437 alpha: true, 438 premultipliedAlpha: true, 439 antialias: false, 440 depth: false, 441 stencil: false, 442 powerPreference: "low-power", 443 }; 444 const gl = 445 cv.getContext("webgl", attrs) || 446 (cv.getContext("experimental-webgl", attrs) as WebGLRenderingContext | null); 447 if (!gl) { 448 if (import.meta.env.DEV) console.error("particle-field: no WebGL context"); 449 setFailed(true); 450 return () => {}; 451 } 452 const vs = compile(gl, gl.VERTEX_SHADER, VERT); 453 const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG); 454 const prog = gl.createProgram(); 455 if (!vs || !fs || !prog) { 456 setFailed(true); 457 return () => {}; 458 } 459 gl.attachShader(prog, vs); 460 gl.attachShader(prog, fs); 461 gl.linkProgram(prog); 462 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { 463 if (import.meta.env.DEV) console.error("particle-field link:", gl.getProgramInfoLog(prog)); 464 setFailed(true); 465 return () => {}; 466 } 467 gl.useProgram(prog); 468 gl.enable(gl.BLEND); 469 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); 470 gl.clearColor(0, 0, 0, 0); 471 472 /* ── Geometry ── */ 473 /* Dots per square field unit: the hero's 8,000 over its 8.8. */ 474 const perUnit = coarse ? 400 : 900; 475 const morphCount = coarse ? 1600 : 2400; 476 /* Half-extents of the cloud in field units, regrown when the canvas 477 outgrows them. */ 478 const domain = { w: 0, h: 0 }; 479 const hash = rng(7); 480 481 const seeds = (n: number) => { 482 const s = new Float32Array(n * 4); 483 for (let i = 0; i < n; i++) { 484 s[i * 4] = hash(); // arrival delay 485 s[i * 4 + 1] = hash(); // life phase 486 s[i * 4 + 2] = 2 + hash() * 2; // life length, seconds 487 s[i * 4 + 3] = hash(); // general purpose 488 } 489 return s; 490 }; 491 492 const buf = { 493 cloudRest: gl.createBuffer(), 494 cloudSeed: gl.createBuffer(), 495 morphRest: gl.createBuffer(), 496 morphSeed: gl.createBuffer(), 497 morphFrom: gl.createBuffer(), 498 morphTo: gl.createBuffer(), 499 }; 500 let cloudN = 0; 501 const buildCloud = () => { 502 const area = domain.w * 2 * domain.h * 2; 503 const count = Math.min(16000, Math.round(perUnit * area)); 504 const pts = poisson({ 505 halfW: domain.w, 506 halfH: domain.h, 507 r: minDistFor(count, domain.w * 2, domain.h * 2), 508 seed: 11, 509 }); 510 cloudN = pts.length / 2; 511 gl.bindBuffer(gl.ARRAY_BUFFER, buf.cloudRest); 512 gl.bufferData(gl.ARRAY_BUFFER, pts, gl.STATIC_DRAW); 513 gl.bindBuffer(gl.ARRAY_BUFFER, buf.cloudSeed); 514 gl.bufferData(gl.ARRAY_BUFFER, seeds(cloudN), gl.STATIC_DRAW); 515 }; 516 517 const morphRest = poisson({ halfW: 0.5, halfH: 0.5, r: minDistFor(morphCount, 1, 1), seed: 23 }); 518 const morphN = morphRest.length / 2; 519 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphRest); 520 gl.bufferData(gl.ARRAY_BUFFER, morphRest, gl.STATIC_DRAW); 521 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphSeed); 522 gl.bufferData(gl.ARRAY_BUFFER, seeds(morphN), gl.STATIC_DRAW); 523 const fromArr = new Float32Array(morphRest); 524 const toArr = new Float32Array(morphRest); 525 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphFrom); 526 gl.bufferData(gl.ARRAY_BUFFER, fromArr, gl.DYNAMIC_DRAW); 527 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphTo); 528 gl.bufferData(gl.ARRAY_BUFFER, toArr, gl.DYNAMIC_DRAW); 529 530 const loc = { 531 rest: gl.getAttribLocation(prog, "a_rest"), 532 seed: gl.getAttribLocation(prog, "a_seed"), 533 from: gl.getAttribLocation(prog, "a_from"), 534 to: gl.getAttribLocation(prog, "a_to"), 535 }; 536 gl.enableVertexAttribArray(loc.rest); 537 gl.enableVertexAttribArray(loc.seed); 538 gl.enableVertexAttribArray(loc.from); 539 gl.enableVertexAttribArray(loc.to); 540 541 const bindLayer = (layer: 0 | 1) => { 542 gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphRest : buf.cloudRest); 543 gl.vertexAttribPointer(loc.rest, 2, gl.FLOAT, false, 0, 0); 544 gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphSeed : buf.cloudSeed); 545 gl.vertexAttribPointer(loc.seed, 4, gl.FLOAT, false, 0, 0); 546 /* the cloud never reads these; point them at something valid */ 547 gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphFrom : buf.cloudRest); 548 gl.vertexAttribPointer(loc.from, 2, gl.FLOAT, false, 0, 0); 549 gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphTo : buf.cloudRest); 550 gl.vertexAttribPointer(loc.to, 2, gl.FLOAT, false, 0, 0); 551 }; 552 553 /* ── Uniforms ── */ 554 const u = (name: string) => gl.getUniformLocation(prog, name); 555 const U = { 556 time: u("u_time"), 557 extent: u("u_extent"), 558 pr: u("u_pr"), 559 pscale: u("u_pscale"), 560 maxPoint: u("u_maxPoint"), 561 layer: u("u_layer"), 562 hover: u("u_hover"), 563 morph: u("u_morph"), 564 pulse: u("u_pulse"), 565 iconReady: u("u_iconReady"), 566 fill: u("u_fill"), 567 light: u("u_light"), 568 ringR: u("u_ringR"), 569 ringW: u("u_ringW"), 570 ringW2: u("u_ringW2"), 571 ringDisp: u("u_ringDisp"), 572 ring: u("u_ring"), 573 ringLag: u("u_ringLag"), 574 zone: u("u_zone"), 575 icon: u("u_icon"), 576 inkAlpha: u("u_inkAlpha"), 577 iconAlpha: u("u_iconAlpha"), 578 c1: u("u_c1"), 579 c2: u("u_c2"), 580 c3: u("u_c3"), 581 ink: u("u_ink"), 582 accent: u("u_accent"), 583 }; 584 const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array; 585 gl.uniform1f(U.maxPoint, range ? range[1] : 64); 586 gl.uniform1f(U.ringW, 0.03); 587 gl.uniform1f(U.ringW2, 0.1); 588 gl.uniform1f(U.ringDisp, 0.5); 589 gl.uniform1f(U.inkAlpha, 0.7); 590 gl.uniform1f(U.iconAlpha, 0.6); 591 gl.uniform4f(U.zone, 0, 0, -1, -1); 592 gl.uniform4f(U.icon, 0, 0, 0.5, 0.5); 593 gl.uniform1f(U.iconReady, 0); 594 gl.uniform1f(U.fill, restFill ? 1 : 0); 595 596 /* ── Colour ── */ 597 const INK: Rgb = [0.34, 0.32, 0.47]; 598 let accentName: Accent | null = null; 599 const fam = tone === "plate" ? opts.plateFamily : opts.pageFamily; 600 /* The reading weights, not the -soft washes: the pastels sank into 601 the cream ground and read as white specks. These tokens invert 602 with the mode, so the dots stay dark on light and light on dark. */ 603 const pushPalette = () => { 604 /* Is the ground behind this field light? The page's dawn base, or 605 the plate's night base, after the mode has inverted them. */ 606 const V = opts.vars ?? { 607 ink: tone === "plate" ? `--color-${fam}-text` : `--color-${fam}-ink`, 608 base: `--color-${fam}-base`, 609 iris: `--color-${fam}-iris`, 610 love: `--color-${fam}-love`, 611 }; 612 const [br, bg, bb] = readVar(cv, V.base, [0.98, 0.96, 0.93]); 613 const light = 0.2126 * br + 0.7152 * bg + 0.0722 * bb > 0.5; 614 gl.uniform1f(U.light, light ? 1 : 0); 615 gl.uniform1f(U.inkAlpha, light ? 0.9 : 0.7); 616 const ink = readVar(cv, V.ink, INK); 617 gl.uniform3fv(U.ink, ink); 618 gl.uniform3fv(U.c1, ink); 619 gl.uniform3fv( 620 U.c2, 621 readVar(cv, V.iris, [0.47, 0.35, 0.64]), 622 ); 623 gl.uniform3fv( 624 U.c3, 625 readVar(cv, V.love, [0.69, 0.25, 0.42]), 626 ); 627 gl.uniform3fv( 628 U.accent, 629 accentName ? readVar(cv, opts.accentVar(accentName), ink) : ink, 630 ); 631 }; 632 pushPalette(); 633 634 /* ── State the frame reads ── */ 635 const box = { left: 0, pageTop: 0, width: 1, height: 1 }; 636 let unit = unitPx(cv.clientWidth, cv.clientHeight); 637 const pointer = { x: 0, y: 0, inside: false }; 638 const ring = { x: 0.3, y: 0.1 }; 639 const ringLag = { x: 0.3, y: 0.1 }; 640 const hover = new Tween(0); 641 const morph = new Tween(1); 642 let pulseT0 = -Infinity; 643 let lastMove = -Infinity; 644 let raf = 0; 645 let running = false; 646 let visible = !document.hidden; 647 let inView = true; 648 let lost = false; 649 let lastNow = 0; 650 const start = performance.now(); 651 652 const measureBox = () => { 653 const r = cv.getBoundingClientRect(); 654 box.left = r.left; 655 box.pageTop = r.top + window.scrollY; 656 box.width = r.width || 1; 657 box.height = r.height || 1; 658 }; 659 660 const size = () => { 661 const pr = Math.min(2, window.devicePixelRatio || 1); 662 const w = Math.max(1, Math.round(cv.clientWidth * pr)); 663 const h = Math.max(1, Math.round(cv.clientHeight * pr)); 664 if (cv.width !== w || cv.height !== h) { 665 cv.width = w; 666 cv.height = h; 667 } 668 gl.viewport(0, 0, w, h); 669 unit = unitPx(cv.clientWidth, cv.clientHeight); 670 const ex = cv.clientWidth / 2 / unit; 671 const ey = cv.clientHeight / 2 / unit; 672 gl.uniform2f(U.extent, ex, ey); 673 gl.uniform1f(U.pr, pr); 674 gl.uniform1f(U.pscale, (cv.clientWidth / 2000) * 0.9); 675 if (ex + 0.1 > domain.w || ey + 0.1 > domain.h) { 676 domain.w = Math.max(domain.w, ex + 0.2); 677 domain.h = Math.max(domain.h, ey + 0.15); 678 buildCloud(); 679 } 680 }; 681 682 /* ── The shelf list (keep-out for the cloud) and the icon's home ── 683 Rects are in page pixels; the canvas may sit inside a scaled 684 composition (the hero scales its 1920×1080 artboard), so a page 685 pixel is `scale` canvas pixels. */ 686 let zoneEl: HTMLElement | null = null; 687 let zoneRo: ResizeObserver | null = null; 688 let iconEl: HTMLElement | null = null; 689 const toField = (px: number, py: number, c: DOMRect) => { 690 const k = unit * (c.width / Math.max(1, cv.clientWidth)); 691 return [(px - c.left - c.width / 2) / k, (c.height / 2 - (py - c.top)) / k]; 692 }; 693 const pageUnit = (c: DOMRect) => unit * (c.width / Math.max(1, cv.clientWidth)); 694 /* The hero swaps between two layouts at 1280px and rebuilds the list 695 when it does, so a held reference goes stale: re-find it whenever 696 the one we have has left the document. */ 697 const adoptZone = (el: HTMLElement) => { 698 if (zoneEl === el) return; 699 zoneRo?.disconnect(); 700 zoneEl = el; 701 zoneRo = new ResizeObserver(() => measureZone()); 702 zoneRo.observe(el); 703 }; 704 /* Where the icon forms: a `[data-field-icon]` box inside the field's 705 own host if there is one (the plate marks its open upper half), 706 otherwise centred on the shelf list. */ 707 const findIcon = () => { 708 const host = cv.closest("[data-field-host]"); 709 iconEl = host?.querySelector<HTMLElement>("[data-field-icon]") ?? null; 710 }; 711 const measureZone = () => { 712 if (!zoneEl || !zoneEl.isConnected) { 713 const el = document.querySelector<HTMLElement>("[data-index-zone]"); 714 if (el) adoptZone(el); 715 } 716 if (withIcons && (!iconEl || !iconEl.isConnected)) findIcon(); 717 const c = cv.getBoundingClientRect(); 718 if (withCloud && zoneEl && zoneEl.isConnected) { 719 const z = zoneEl.getBoundingClientRect(); 720 const [x0, y0] = toField(z.left, z.bottom, c); 721 const [x1, y1] = toField(z.right, z.top, c); 722 gl.uniform4f(U.zone, x0, y0, x1, y1); 723 } else { 724 gl.uniform4f(U.zone, 0, 0, -1, -1); 725 } 726 if (!withIcons) return; 727 const home = iconEl && iconEl.isConnected ? iconEl : zoneEl && zoneEl.isConnected ? zoneEl : null; 728 if (!home) { 729 gl.uniform4f(U.icon, 0, 0, 0.5, 0.5); 730 return; 731 } 732 const z = home.getBoundingClientRect(); 733 const k = c.width / Math.max(1, cv.clientWidth); 734 const sizePx = Math.min(440 * k, Math.max(96 * k, 0.9 * Math.min(z.width, z.height))); 735 const [cx, cy] = toField(z.left + z.width / 2, z.top + z.height / 2, c); 736 const s = sizePx / pageUnit(c); 737 gl.uniform4f(U.icon, cx, cy, s, s); 738 }; 739 let zoneTimer = 0; 740 const findZone = (attempt = 0) => { 741 const el = document.querySelector<HTMLElement>("[data-index-zone]"); 742 if (el) { 743 adoptZone(el); 744 measureZone(); 745 return; 746 } 747 if (attempt < 8) zoneTimer = window.setTimeout(() => findZone(attempt + 1), 250); 748 }; 749 750 /* ── Icons ── */ 751 let targets: Float32Array[] | null = null; 752 let curIndex: number | null = null; 753 let pending: LitShelf = heroShelf.get(); 754 let cancelled = false; 755 const scratch = document.createElement("canvas"); 756 const jitter = rng(99); 757 const jitters = new Float32Array(morphN * 2); 758 for (let i = 0; i < jitters.length; i++) jitters[i] = jitter(); 759 760 /* The icon answers the hand only: a shelf the loop lights on its own 761 tour leaves the cloud as it is, so the gather always means "you 762 are pointing at this". */ 763 const applyShelf = (s: LitShelf, now: number, snap = false) => { 764 pending = s; 765 /* One layout read per hover change: cheap, and it means the icon 766 always forms on the list as it is laid out right now. */ 767 const wanted = s.index !== null && (s.held || tour); 768 if (wanted) measureZone(); 769 if (!wanted || s.index === null) { 770 hover.go(0, 800, now); 771 return; 772 } 773 hover.go(1, 500, now); 774 if (!targets || s.index === curIndex) return; 775 /* continue from wherever the last gather got to */ 776 const m = easeOutCubic(Math.min(1, morph.value)); 777 for (let i = 0; i < fromArr.length; i++) fromArr[i] = fromArr[i] + (toArr[i] - fromArr[i]) * m; 778 toArr.set(targets[s.index]); 779 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphFrom); 780 gl.bufferSubData(gl.ARRAY_BUFFER, 0, fromArr); 781 gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphTo); 782 gl.bufferSubData(gl.ARRAY_BUFFER, 0, toArr); 783 curIndex = s.index; 784 if (snap) { 785 morph.set(1); 786 } else { 787 morph.set(0); 788 morph.go(1, 600, now); 789 pulseT0 = now; 790 } 791 if (s.accent !== accentName) { 792 accentName = s.accent; 793 pushPalette(); 794 } 795 }; 796 797 /* ── Frames ── */ 798 const drawFrame = (t: number, now: number) => { 799 if (lost) return; 800 gl.uniform1f(U.time, t); 801 gl.uniform1f(U.ringR, 0.175 + Math.sin(t) * 0.03 + Math.cos(t * 3) * 0.02); 802 gl.uniform2f(U.ring, ring.x, ring.y); 803 gl.uniform2f(U.ringLag, ringLag.x, ringLag.y); 804 gl.uniform1f(U.hover, hover.value); 805 gl.uniform1f(U.morph, morph.value); 806 const pulse = (now - pulseT0) / 2000; 807 gl.uniform1f(U.pulse, pulse >= 0 && pulse < 1 ? pulse : -1); 808 gl.clear(gl.COLOR_BUFFER_BIT); 809 gl.uniform1f(U.layer, 0); 810 if (withCloud) { 811 bindLayer(0); 812 gl.drawArrays(gl.POINTS, 0, cloudN); 813 } 814 if (withIcons) { 815 gl.uniform1f(U.layer, 1); 816 bindLayer(1); 817 gl.drawArrays(gl.POINTS, 0, morphN); 818 } 819 }; 820 821 const still = () => { 822 size(); 823 ring.x = ringLag.x = 0.3; 824 ring.y = ringLag.y = 0.1; 825 /* A held row still gets its icon with motion off; otherwise the 826 frame is the plain cloud. */ 827 const s = withIcons ? heroShelf.get() : { index: null, accent: null, held: false }; 828 if (targets) applyShelf(s, performance.now(), true); 829 hover.set((s.held || tour) && s.index !== null ? 1 : 0); 830 morph.set(1); 831 drawFrame(HELD_TIME, -Infinity); 832 cv.classList.add("is-live"); 833 }; 834 835 const tick = (now: number) => { 836 raf = requestAnimationFrame(tick); 837 const t = (now - start) / 1000; 838 const dt = Math.min(0.05, (now - lastNow) / 1000 || 0.016); 839 lastNow = now; 840 /* The ring drifts on its own, and only partly toward the hand: 841 a 2%/frame lerp toward the pointer is what reads as weight. */ 842 const wx = 0.35 * Math.sin(t * 0.19) + 0.12 * Math.sin(t * 0.47); 843 const wy = 0.3 * Math.cos(t * 0.14) + 0.1 * Math.cos(t * 0.37); 844 const follow = pointer.inside && now - lastMove < IDLE_MS; 845 const tx = follow ? pointer.x + wx * 0.1 : wx * 0.6; 846 const ty = follow ? pointer.y + wy * 0.1 : wy * 0.6; 847 const k = 1 - Math.pow(1 - (follow ? 0.02 : 0.01), dt * 60); 848 ring.x += (tx - ring.x) * k; 849 ring.y += (ty - ring.y) * k; 850 const k2 = 1 - Math.pow(1 - 0.06, dt * 60); 851 ringLag.x += (ring.x - ringLag.x) * k2; 852 ringLag.y += (ring.y - ringLag.y) * k2; 853 hover.tick(now); 854 morph.tick(now); 855 drawFrame(t, now); 856 cv.classList.add("is-live"); 857 }; 858 859 const sync = () => { 860 const want = live && visible && inView; 861 if (want && !running) { 862 running = true; 863 size(); 864 lastNow = performance.now(); 865 raf = requestAnimationFrame(tick); 866 } else if (!want && running) { 867 running = false; 868 cancelAnimationFrame(raf); 869 } 870 if (!live) still(); 871 }; 872 873 const onMove = (e: PointerEvent) => { 874 if (e.pointerType === "touch") return; 875 const top = box.pageTop - window.scrollY; 876 const nx = ((e.clientX - box.left) / box.width) * 2 - 1; 877 const ny = -(((e.clientY - top) / box.height) * 2 - 1); 878 if (nx < -1.05 || nx > 1.05 || ny < -1.05 || ny > 1.05) { 879 pointer.inside = false; 880 return; 881 } 882 pointer.inside = true; 883 pointer.x = (nx * box.width) / 2 / unit; 884 pointer.y = (ny * box.height) / 2 / unit; 885 lastMove = e.timeStamp; 886 }; 887 const onVis = () => { 888 visible = !document.hidden; 889 sync(); 890 }; 891 const onMode = () => { 892 pushPalette(); 893 if (!running) still(); 894 }; 895 const onLost = (e: Event) => { 896 e.preventDefault(); 897 lost = true; 898 cancelAnimationFrame(raf); 899 setFailed(true); 900 }; 901 const unsubscribe = withIcons 902 ? heroShelf.subscribe((s) => { 903 if (live) applyShelf(s, performance.now()); 904 else still(); 905 }) 906 : () => {}; 907 908 let settleRaf = 0; 909 let settleTimer = 0; 910 const measureSettled = () => { 911 cancelAnimationFrame(settleRaf); 912 clearTimeout(settleTimer); 913 settleRaf = requestAnimationFrame(() => { 914 settleRaf = requestAnimationFrame(() => { 915 measureBox(); 916 measureZone(); 917 if (!running) still(); 918 }); 919 }); 920 /* and once more after the hero's own observer has re-rendered */ 921 settleTimer = window.setTimeout(() => { 922 measureBox(); 923 measureZone(); 924 if (!running) still(); 925 }, 160); 926 }; 927 const ro = new ResizeObserver(() => { 928 measureBox(); 929 size(); 930 measureZone(); 931 measureSettled(); 932 if (!running) still(); 933 }); 934 ro.observe(cv); 935 const io = 936 typeof IntersectionObserver !== "undefined" 937 ? new IntersectionObserver(([e]) => { 938 inView = e.isIntersecting; 939 sync(); 940 }) 941 : null; 942 io?.observe(cv); 943 944 if (live) window.addEventListener("pointermove", onMove, { passive: true }); 945 window.addEventListener("resize", measureSettled); 946 document.addEventListener("visibilitychange", onVis); 947 window.addEventListener("daemonmodechange", onMode); 948 const modeMo = new MutationObserver(onMode); 949 modeMo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-mode", "data-theme", "style"] }); 950 cv.addEventListener("webglcontextlost", onLost); 951 952 /* Prepare the icons; the morph layer twinkles at rest until they land. */ 953 (async () => { 954 if (!withIcons) return; 955 const svgs = opts.icons ?? []; 956 const out: Float32Array[] = []; 957 for (const svg of svgs) { 958 const ink = await rasterise(svg, scratch); 959 if (cancelled) return; 960 out.push(targetsFor(morphRest, nearestInked(ink), (i) => jitters[i])); 961 } 962 if (cancelled) return; 963 targets = out; 964 gl.uniform1f(U.iconReady, 1); 965 applyShelf(pending, performance.now(), true); 966 if (!running) still(); 967 })().catch(() => { 968 /* No icon: the morph layer just twinkles. */ 969 }); 970 971 measureBox(); 972 findZone(); 973 if (withIcons) { 974 if (live) applyShelf(heroShelf.get(), performance.now(), true); 975 } 976 sync(); 977 978 if (import.meta.env.DEV) { 979 (window as unknown as Record<string, unknown>)[withIcons ? "__dmnField" : "__dmnCloud"] = { 980 get hover() { 981 return hover.value; 982 }, 983 get morph() { 984 return morph.value; 985 }, 986 get index() { 987 return curIndex; 988 }, 989 get ready() { 990 return targets !== null; 991 }, 992 get counts() { 993 return { cloud: cloudN, morph: morphN }; 994 }, 995 get ring() { 996 return { ...ring }; 997 }, 998 get iconPx() { 999 /* the icon's centre and size in CSS px, page-relative to the canvas */ 1000 const v = gl.getUniform(prog, U.icon as WebGLUniformLocation) as Float32Array; 1001 const c = cv.getBoundingClientRect(); 1002 const k = unit * (c.width / Math.max(1, cv.clientWidth)); 1003 return { x: c.left + c.width / 2 + v[0] * k, y: c.top + c.height / 2 - v[1] * k, size: v[2] * k }; 1004 }, 1005 get pointer() { 1006 return { ...pointer, box: { ...box }, lastMove }; 1007 }, 1008 set(index: number | null, accent: Accent | null) { 1009 applyShelf({ index, accent, held: true }, performance.now()); 1010 }, 1011 }; 1012 } 1013 1014 return () => { 1015 cancelled = true; 1016 running = false; 1017 cancelAnimationFrame(raf); 1018 clearTimeout(zoneTimer); 1019 unsubscribe(); 1020 ro.disconnect(); 1021 io?.disconnect(); 1022 zoneRo?.disconnect(); 1023 window.removeEventListener("pointermove", onMove); 1024 window.removeEventListener("resize", measureSettled); 1025 cancelAnimationFrame(settleRaf); 1026 clearTimeout(settleTimer); 1027 document.removeEventListener("visibilitychange", onVis); 1028 window.removeEventListener("daemonmodechange", onMode); 1029 modeMo.disconnect(); 1030 cv.removeEventListener("webglcontextlost", onLost); 1031 Object.values(buf).forEach((b) => b && gl.deleteBuffer(b)); 1032 gl.deleteProgram(prog); 1033 gl.deleteShader(vs); 1034 gl.deleteShader(fs); 1035 }; 1036 }