1/* ========================================================================== 2 Living Signal â vanilla ES5-compatible IIFE. Exposes window.LivingSignal. 3 ========================================================================== */ 4(function (global) { 5 "use strict"; 6 7 var TAU = Math.PI * 2; 8 9 /* ---------------------------------------------------------------- random */ 10 /** Deterministic PRNG. Same seed -> same sequence. */ 11 function mulberry32(seed) { 12 var a = seed >>> 0; 13 return function () { 14 a += 0x6d2b79f5; 15 var t = a; 16 t = Math.imul(t ^ (t >>> 15), t | 1); 17 t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 18 return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 19 }; 20 } 21 22 /* ----------------------------------------------------------------- noise */ 23 var GRAD3 = [ 24 [1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0], 25 [1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1], 26 [0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1] 27 ]; 28 var F3 = 1 / 3, G3 = 1 / 6; 29 30 /** Seeded 3D simplex noise (after Gustavson's public-domain reference). */ 31 function createNoise3(seed) { 32 var rand = mulberry32(seed); 33 var p = new Uint8Array(256), i, j, tmp; 34 for (i = 0; i < 256; i++) p[i] = i; 35 for (i = 255; i > 0; i--) { 36 j = Math.floor(rand() * (i + 1)); 37 tmp = p[i]; p[i] = p[j]; p[j] = tmp; 38 } 39 var perm = new Uint8Array(512), permMod12 = new Uint8Array(512); 40 for (i = 0; i < 512; i++) { perm[i] = p[i & 255]; permMod12[i] = perm[i] % 12; } 41 42 return function sample(xin, yin, zin) { 43 var s = (xin + yin + zin) * F3; 44 var i0 = Math.floor(xin + s), j0 = Math.floor(yin + s), k0 = Math.floor(zin + s); 45 var t = (i0 + j0 + k0) * G3; 46 var x0 = xin - (i0 - t), y0 = yin - (j0 - t), z0 = zin - (k0 - t); 47 var i1, j1, k1, i2, j2, k2; 48 if (x0 >= y0) { 49 if (y0 >= z0) { i1=1;j1=0;k1=0; i2=1;j2=1;k2=0; } 50 else if (x0 >= z0) { i1=1;j1=0;k1=0; i2=1;j2=0;k2=1; } 51 else { i1=0;j1=0;k1=1; i2=1;j2=0;k2=1; } 52 } else { 53 if (y0 < z0) { i1=0;j1=0;k1=1; i2=0;j2=1;k2=1; } 54 else if (x0 < z0) { i1=0;j1=1;k1=0; i2=0;j2=1;k2=1; } 55 else { i1=0;j1=1;k1=0; i2=1;j2=1;k2=0; } 56 } 57 var x1=x0-i1+G3, y1=y0-j1+G3, z1=z0-k1+G3; 58 var x2=x0-i2+2*G3, y2=y0-j2+2*G3, z2=z0-k2+2*G3; 59 var x3=x0-1+3*G3, y3=y0-1+3*G3, z3=z0-1+3*G3; 60 var ii=i0&255, jj=j0&255, kk=k0&255, n=0, g, t0, t1, t2, t3; 61 t0 = 0.6-x0*x0-y0*y0-z0*z0; 62 if (t0>0){ g=GRAD3[permMod12[ii+perm[jj+perm[kk]]]]; t0*=t0; n+=t0*t0*(g[0]*x0+g[1]*y0+g[2]*z0); } 63 t1 = 0.6-x1*x1-y1*y1-z1*z1; 64 if (t1>0){ g=GRAD3[permMod12[ii+i1+perm[jj+j1+perm[kk+k1]]]]; t1*=t1; n+=t1*t1*(g[0]*x1+g[1]*y1+g[2]*z1); } 65 t2 = 0.6-x2*x2-y2*y2-z2*z2; 66 if (t2>0){ g=GRAD3[permMod12[ii+i2+perm[jj+j2+perm[kk+k2]]]]; t2*=t2; n+=t2*t2*(g[0]*x2+g[1]*y2+g[2]*z2); } 67 t3 = 0.6-x3*x3-y3*y3-z3*z3; 68 if (t3>0){ g=GRAD3[permMod12[ii+1+perm[jj+1+perm[kk+1]]]]; t3*=t3; n+=t3*t3*(g[0]*x3+g[1]*y3+g[2]*z3); } 69 return 32 * n; 70 }; 71 } 72 73 /* ================================== TIMELINE ============================ */ 74 /* 75 One conversational turn: listen -> think -> speak -> settle. 76 Every envelope is C0-continuous at the loop seam (value at u=1 equals value 77 at u=0). That is load-bearing: a discontinuity is a visible pop each loop. 78 */
79 var INTRO_MS = 1400; 80 var LOOP_MS = 11000; 81 var MAX_DT_MS = 80; 82 83 /* Motion rate limits. Kept narrow on purpose: a wide span plus a fast return 84 to normal speed reads as the animation "catching up". Deceleration reads as 85 calming; sudden acceleration reads as a glitch. */ 86 var SETTLE_SLOW = 0.18; 87 var ENERGY_SPEED = 0.28; 88 89 function smoothstep(e0, e1, x) { 90 var t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); 91 return t * t * (3 - 2 * t); 92 } 93 /** Smooth trapezoid: rises a->b, holds b->c, falls c->d. */ 94 function bump(u, a, b, c, d) { return smoothstep(a, b, u) * (1 - smoothstep(c, d, u)); } 95 96 function energyAt(u) { return 0.2 + 0.7 * bump(u, 0.24, 0.42, 0.82, 0.99); } 97 /* The down-ramp (0.91 -> 1.0) is the return to normal speed and needs the 98 whole tail of the loop to happen in. */ 99 function settleAt(u) { return bump(u, 0.84, 0.91, 0.93, 1.0); } 100 /** Global motion rate. Strictly positive, so motionT can never stall. */ 101 function speedAt(u) { 102 var active = (energyAt(u) - 0.2) / 0.7; 103 return (1 - SETTLE_SLOW * settleAt(u)) * (1 + ENERGY_SPEED * active); 104 } 105 106 /* Cumulative integral of speed over one loop, sampled once. 107 WHY: motionT must be the INTEGRAL of the rate, never elapsedTime * rate. 108 The latter scales total elapsed time by a rate that changes, which 109 retroactively rewrites the angle's whole history â measured at 182x nominal 110 angular velocity three minutes in, worsening forever. A table keeps the 111 integral O(1) and keeps the timeline a pure function of time, which is what 112 makes seek() reproducible and pause/resume seamless. */ 113 var SPEED_LUT_N = 128; 114 var speedLut = (function () { 115 var lut = new Float64Array(SPEED_LUT_N + 1), step = 1 / SPEED_LUT_N, acc = 0; 116 for (var i = 1; i <= SPEED_LUT_N; i++) { 117 acc += ((speedAt((i - 1) * step) + speedAt(i * step)) / 2) * step; 118 lut[i] = acc; 119 } 120 return lut; 121 })(); 122 123 function speedIntegral(u) { 124 var x = Math.min(1, Math.max(0, u)) * SPEED_LUT_N; 125 var i = Math.min(SPEED_LUT_N - 1, Math.floor(x)); 126 return speedLut[i] + (speedLut[i + 1] - speedLut[i]) * (x - i); 127 } 128 129 /** Integrated motion time in seconds. Monotonic; never stalls or reverses. */ 130 function motionSeconds(tMs) { 131 var t = Math.max(0, tMs) / 1000, introS = INTRO_MS / 1000; 132 if (t <= introS) return t; 133 var loopS = LOOP_MS / 1000, rel = t - introS; 134 var loops = Math.floor(rel / loopS); 135 var u = (rel - loops * loopS) / loopS; 136 return introS + (loops * speedLut[SPEED_LUT_N] + speedIntegral(u)) * loopS; 137 } 138 139 /* Speech energy: a phrase swell carrying a syllable train at ~2.8/4.3/6.6 Hz. 140 Weighted toward the MID and UPPER rates â articulation lives there; a train 141 dominated by its slowest component reads as a swell, not as speech. Deep 142 modulation is safe ONLY because `voice` drives light alone. */ 143 function speechEnergy(tSec) { 144 var s1 = 0.5 + 0.5 * Math.sin(tSec * 17.3); 145 var s2 = 0.5 + 0.5 * Math.sin(tSec * 26.9 + 1.3); 146 var s3 = 0.5 + 0.5 * Math.sin(tSec * 41.7 + 2.6); 147 var syllables = 0.45 * s1 + 0.35 * s2 + 0.2 * s3; 148 var raw = Math.min(1, Math.max(0, Math.sin(tSec * 1.9) * 0.72 + 0.5)); 149 var phrase = raw * raw * (3 - 2 * raw); 150 return phrase * (0.2 + 0.8 * syllables); 151 } 152 153 function sampleTimeline(tMs) { 154 var t = Math.max(0, tMs); 155 var inIntro = t < INTRO_MS; 156 var intro = inIntro ? smoothstep(0, 1, t / INTRO_MS) : 1; 157 var loopT = inIntro ? 0 : (t - INTRO_MS) % LOOP_MS; 158 var loopIndex = inIntro ? -1 : Math.floor((t - INTRO_MS) / LOOP_MS); 159 var u = loopT / LOOP_MS; 160 var speaking = bump(u, 0.34, 0.44, 0.78, 0.9); 161 return { 162 tMs: t, loopIndex: loopIndex, u: u, intro: intro, 163 energy: energyAt(u), 164 voice: speaking * speechEnergy(t / 1000), 165 speaking: speaking, 166 focus: 0.1 + 0.6 * bump(u, 0.22, 0.38, 0.84, 0.98), 167 breath: 1 - 0.45 * bump(u, 0.26, 0.46, 0.86, 0.99), 168 settle: settleAt(u), 169 motionT: motionSeconds(t) 170 }; 171 } 172 173 /* Static frame: energy is up but `speaking` is exactly 0, so it is never 174 caught mid-syllable. */ 175 var RESOLVED_SNAPSHOT_MS = INTRO_MS + LOOP_MS * 0.32; 176 177 function clampDt(dtMs) { 178 if (!isFinite(dtMs) || dtMs < 0) return 0; 179 return Math.min(dtMs, MAX_DT_MS); 180 } 181 182 /* ================================== RENDERER ============================ */ 183 var MAX_DPR = 2; 184 var R_FRAC = 0.264; /* orb radius / min(width, height) */ 185 var BOX_R = 2.5; /* working box around the orb, in radii */ 186 var BLUR_DIV = [6, 9]; 187 /* Fallback path (no working ctx.filter, e.g. Safari): a smear pass does the 188 real blurring, so this no longer has to be extreme to hide hard edges. */ 189 var BLUR_DIV_FALLBACK = [9, 13]; 190 /* Taps for the fallback smear, as a fraction of the blur buffer width. */ 191 var SMEAR_RADIUS = 0.055; 192 var BLUR_FRAC = 0.2; /* Gaussian radius / orb radius */ 193 var LOBE_SAMPLES = 48; 194 195 /* Silhouette morph. Set MORPH_STRENGTH to 0 for a perfect circle. 196 Amplitudes are MODULATED by per-harmonic LFOs â fixed amplitudes give a 197 slowly rotating lumpy outline that reads as a defective circle rather than 198 a living form. Orders stay low (2-3); higher orders read as dents. */ 199 var MORPH_STRENGTH = 1; 200 var MORPH_SAMPLES = 128; 201 var MORPH_ORDERS = [2, 3]; 202 var MORPH_AMP = [0.058, 0.034]; 203 var MORPH_NOISE = 0.005; 204 var BREATH_AMP = 0.03; 205 206 /* Value ramp in blue. `navy` matches a typical dark-UI surface tone. */ 207 var DEFAULT_PALETTE = { 208 deep: "#060b1a", 209 navy: "#0c1b3a", 210 blue: "#1e5bff", 211 sky: "#7fb4ee", 212 purple: "#bf5af2", 213 coreWhite: "#dceeff", 214 border: "rgba(229, 229, 234, 0.14)", 215 surfaceDeep: "#0b1b35", 216 highlight: "#aab7c8" 217 }; 218 219 function hexToRgb(hex) { 220 var h = hex.replace("#", ""); 221 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; 222 var n = parseInt(h, 16); 223 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; 224 } 225 function rgba(hex, a) { 226 var c = hexToRgb(hex); 227 return "rgba(" + c[0] + ", " + c[1] + ", " + c[2] + ", " + a + ")"; 228 } 229 230 function createRenderer(canvas, palette, seed) { 231 var ctx = canvas.getContext("2d"); 232 if (!ctx) return null; 233 var noise = createNoise3(seed ^ 0x5f3759df); 234 235 /* Masses live OUTSIDE the circle and only bleed in: radius ~1.55R centred 236 ~1.75R out, so the near edge stops around 0.2R. Sizing them to cover the 237 circle floods the dark centre and the depth disappears.
238 Ordered as a value ramp, darkest first, weights descending as tiers 239 brighten so the pale tier reads as a light source. */ 240 var masses = [ 241 { color: palette.navy, orbit: 1.50, orbitSpeed: -0.15, orbitPhase: Math.PI*0.20, size: 1.75, lobes: 3, depth: 0.12, spin: 0.24, weight: 1.00 }, 242 { color: palette.blue, orbit: 1.72, orbitSpeed: 0.27, orbitPhase: Math.PI*0.62, size: 1.55, lobes: 5, depth: 0.16, spin: 0.56, weight: 0.90 }, 243 { color: palette.sky, orbit: 1.86, orbitSpeed: 0.35, orbitPhase: Math.PI*1.05, size: 1.50, lobes: 4, depth: 0.13, spin: 0.68, weight: 0.66, gated: true }, 244 { color: palette.purple, orbit: 1.80, orbitSpeed: -0.20, orbitPhase: Math.PI*1.68, size: 1.60, lobes: 6, depth: 0.18, spin: -0.44, weight: 0.34 } 245 ]; 246 247 /* Grain: white with per-pixel ALPHA so it composites with plain 248 source-over. Mid-grey noise under `overlay` is more faithful but costs a 249 separable blend over ~2M device px per frame. */ 250 var grainPattern = (function () { 251 var N = 128, c = document.createElement("canvas"); 252 c.width = N; c.height = N; 253 var g = c.getContext("2d"); 254 if (!g) return null; 255 var img = g.createImageData(N, N), r = mulberry32(seed ^ 0x27d4eb2f); 256 for (var i = 0; i < N * N; i++) { 257 img.data[i*4] = 255; img.data[i*4+1] = 255; img.data[i*4+2] = 255; 258 img.data[i*4+3] = r() * 255; 259 } 260 g.putImageData(img, 0, 0); 261 return c; 262 })(); 263 var grainFill = null; 264 265 var morphRand = mulberry32(seed ^ 0x1b873593); 266 var morphPhase = [], morphSpeed = [], morphLfoRate = [], morphLfoPhase = []; 267 for (var k = 0; k < MORPH_ORDERS.length; k++) { 268 morphPhase[k] = morphRand() * TAU; 269 morphSpeed[k] = (0.11 + morphRand() * 0.09) * (k % 2 === 0 ? 1 : -1); 270 morphLfoRate[k] = 0.21 + morphRand() * 0.22; 271 morphLfoPhase[k] = morphRand() * TAU; 272 } 273 var morphX = new Float32Array(MORPH_SAMPLES), morphY = new Float32Array(MORPH_SAMPLES); 274 275 var bufBlur = document.createElement("canvas"); 276 var bufOrb = document.createElement("canvas"); 277 var blurCtx = bufBlur.getContext("2d"); 278 var orbCtx = bufOrb.getContext("2d"); 279 /* Ping-pong pair for the no-ctx.filter fallback (see progressiveUpscale). */ 280 var tmpA = document.createElement("canvas"), tmpB = document.createElement("canvas"); 281 var tmpACtx = tmpA.getContext("2d"), tmpBCtx = tmpB.getContext("2d"); 282 283 /** 284 * Upscale in repeated 2x steps rather than one giant jump. 285 * 286 * Only used when ctx.filter is unavailable. A single 16x bilinear step 287 * leaves large linear ramps between source texels, which read as blocks â 288 * and the masses are hard-edged polygons, so there is nothing soft to hide 289 * it. Chaining 2x steps compounds the filtering into something much closer 290 * to a real blur. This is the difference between "soft" and "pixelated" on 291 * Safari 16 and older WebViews. 292 */ 293 /** 294 * Nine-tap box smear over the blur buffer â the fallback's actual blur. 295 * 296 * Alpha is 1/(i+1) per tap, which makes each draw an exact running mean of 297 * the taps so far. Using a constant 1/n instead would let the last taps 298 * dominate and skew the result off-centre. 299 */ 300 var SMEAR_TAPS = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[0.7,0.7],[-0.7,-0.7],[0.7,-0.7],[-0.7,0.7]]; 301 var SMEAR_PASSES = 3; 302 /** 303 * @param sigmaBuf target Gaussian sigma, in blur-buffer pixels. 304 * 305 * Tap radius is derived from the sigma the ctx.filter path would have used, 306 * NOT from a fixed fraction of the buffer. A fixed 5.5% happened to land at 307 * roughly a third of the needed radius, which left the mass polygons' 308 * overlap creases clearly visible. Repeated box passes converge on a 309 * Gaussian: for n passes of radius r, sigma = r * sqrt(n/3), so n=3 gives 310 * r = sigma directly. 311 */ 312 function smearFallback(sigmaBuf) { 313 if (!blurCtx || !tmpACtx) return; 314 var w = bufBlur.width; 315 if (tmpA.width !== w) { tmpA.width = w; tmpA.height = w; } 316 var r = Math.max(1, sigmaBuf); 317 for (var pass = 0; pass < SMEAR_PASSES; pass++) { 318 tmpACtx.setTransform(1, 0, 0, 1, 0, 0); 319 tmpACtx.globalCompositeOperation = "source-over"; 320 tmpACtx.globalAlpha = 1; 321 tmpACtx.clearRect(0, 0, w, w); 322 tmpACtx.drawImage(bufBlur, 0, 0); 323 324 blurCtx.setTransform(1, 0, 0, 1, 0, 0); 325 blurCtx.globalCompositeOperation = "source-over"; 326 blurCtx.clearRect(0, 0, w, w); 327 for (var i = 0; i < SMEAR_TAPS.length; i++) { 328 /* 1/(i+1) makes each draw an exact running mean of the taps so far; 329 a constant 1/n would let the last taps dominate and skew it. */ 330 blurCtx.globalAlpha = 1 / (i + 1); 331 blurCtx.drawImage(tmpA, SMEAR_TAPS[i][0] * r, SMEAR_TAPS[i][1] * r); 332 } 333 blurCtx.globalAlpha = 1; 334 } 335 } 336 337 function progressiveUpscale(src, targetPx) { 338 var cur = src, w = src.width, useA = true; 339 while (w * 2 < targetPx) { 340 w *= 2; 341 var dst = useA ? tmpA : tmpB; 342 var dctx = useA ? tmpACtx : tmpBCtx; 343 if (!dctx) break; 344 if (dst.width !== w) { dst.width = w; dst.height = w; } 345 dctx.imageSmoothingEnabled = true; 346 dctx.clearRect(0, 0, w, w); 347 dctx.drawImage(cur, 0, 0, w, w); 348 cur = dst; 349 useA = !useA; 350 } 351 return cur; 352 } 353 354 /** 355 * Does ctx.filter ACTUALLY blur? Probe it functionally, never by property. 356 * 357 * Safari reports `ctx.filter` support â the property exists and round-trips 358 * the string â but does not apply the filter under the conditions this 359 * renderer uses it (a scaled transform with `lighter` compositing). A 360 * property check therefore returns true, the sharp path is taken, and the 361 * masses land as hard-edged polygons at 1/6 resolution. That is precisely 362 * the "works in Chrome, not blurred in Safari" symptom. 363 * 364 * So: replicate the real drawing conditions, then read back a pixel well
365 * outside the shape. If light did not spread there, the filter is a no-op 366 * regardless of what the property says. 367 */ 368 var supportsFilter = (function () { 369 try { 370 var c = document.createElement("canvas"); 371 c.width = 64; c.height = 64; 372 var g = c.getContext("2d"); 373 if (!g || typeof g.filter !== "string") return false; 374 g.setTransform(2, 0, 0, 2, 0, 0); /* scaled, as in real use */ 375 g.globalCompositeOperation = "lighter"; /* additive, as in real use */ 376 g.filter = "blur(4px)"; 377 if (g.filter !== "blur(4px)") return false; 378 g.fillStyle = "#ffffff"; 379 g.fillRect(8, 8, 16, 16); /* device px 16..48 */ 380 g.filter = "none"; 381 g.setTransform(1, 0, 0, 1, 0, 0); 382 /* Sample just outside the hard edge (device x=16). A large source and a 383 close sample keep this well clear of the noise floor â an earlier 384 version probed 8px out from an 8px rect and the true signal was only 385 ~1-3/255, so it read as "unsupported" on browsers that work fine. */ 386 var spread = g.getImageData(11, 32, 1, 1).data[3]; 387 var centre = g.getImageData(32, 32, 1, 1).data[3]; 388 return centre > 0 && spread > 8; 389 } catch (e) { 390 return false; /* tainted canvas / getImageData blocked -> assume no */ 391 } 392 })(); 393 /* Escape hatch for testing the fallback path on a browser that has a 394 working filter: LivingSignal.forceFallback = true, before mount. */ 395 if (global.LivingSignal && global.LivingSignal.forceFallback) supportsFilter = false; 396 var divTable = supportsFilter ? BLUR_DIV : BLUR_DIV_FALLBACK; 397 398 var wCss = 0, hCss = 0, dpr = 1, orbR = 0, boxCss = 0; 399 var lastTMs = -1, currentTier = 0, pendingTier = 0, lastLoopIndex = -2; 400 var emaSpacingMs = 16.7, emaCpuMs = 4, slowFrames = 0, fastFrames = 0; 401 var frameParity = 0, disposed = false; 402 403 function resizeBuffers() { 404 var boxPx = Math.max(8, Math.round(boxCss * dpr)); 405 if (bufOrb.width !== boxPx) { 406 bufOrb.width = boxPx; bufOrb.height = boxPx; 407 grainFill = null; /* resizing resets context state */ 408 } 409 var blurPx = Math.max(4, Math.round(boxPx / divTable[currentTier])); 410 if (bufBlur.width !== blurPx) { bufBlur.width = blurPx; bufBlur.height = blurPx; } 411 /* Resizing a canvas resets context state; smoothing must be re-asserted 412 or every upscale in this renderer turns into nearest-neighbour blocks. */ 413 if (blurCtx) blurCtx.imageSmoothingEnabled = true; 414 if (orbCtx) orbCtx.imageSmoothingEnabled = true; 415 if (ctx) ctx.imageSmoothingEnabled = true; 416 } 417 function applyTier() { currentTier = pendingTier; resizeBuffers(); } 418 419 function resize(w, h, ratio) { 420 wCss = Math.max(0, w); hCss = Math.max(0, h); 421 dpr = Math.min(MAX_DPR, Math.max(1, ratio)); 422 var bw = Math.round(wCss * dpr), bh = Math.round(hCss * dpr); 423 if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; } 424 orbR = Math.min(wCss, hCss) * R_FRAC; 425 boxCss = orbR * BOX_R; 426 resizeBuffers(); 427 } 428 429 /* Solid fill is load-bearing: a radial-gradient fill desaturates once blurred. */ 430 function drawMass(target, m, mt, c, r, focus) { 431 /* Keep the inward pull small â past ~10% the near edges cross the centre, 432 the dark middle floods, and the speaking beat blows out to white. */ 433 var orbit = m.orbit * (1 - 0.1 * focus); 434 var oa = m.orbitPhase + mt * m.orbitSpeed; 435 var mx = c + Math.cos(oa) * r * orbit; 436 var my = c + Math.sin(oa) * r * orbit; 437 var spin = mt * m.spin, base = r * m.size; 438 target.fillStyle = m.color; 439 target.beginPath(); 440 for (var i = 0; i <= LOBE_SAMPLES; i++) { 441 var a = (i / LOBE_SAMPLES) * TAU; 442 var wob = 1 + m.depth * Math.sin(m.lobes * (a + spin)); 443 var x = mx + Math.cos(a) * base * wob; 444 var y = my + Math.sin(a) * base * wob; 445 if (i === 0) target.moveTo(x, y); else target.lineTo(x, y); 446 } 447 target.closePath(); 448 target.fill(); 449 } 450 451 function drawBlurLayer(sample) { 452 if (!blurCtx) return; 453 var s = bufBlur.width / boxCss, mt = sample.motionT, c = boxCss / 2; 454 var introA = sample.intro * sample.intro; 455 456 blurCtx.setTransform(s, 0, 0, s, 0, 0); 457 blurCtx.globalCompositeOperation = "source-over"; 458 blurCtx.globalAlpha = 1; 459 blurCtx.filter = "none"; 460 /* Graded navy base â the shadows still carry blue, never dead black. */ 461 var bg = blurCtx.createLinearGradient(0, 0, boxCss * 0.6, boxCss); 462 bg.addColorStop(0, palette.deep); 463 bg.addColorStop(0.55, rgba(palette.navy, 0.55)); 464 bg.addColorStop(1, palette.deep); 465 blurCtx.fillStyle = bg; 466 blurCtx.fillRect(0, 0, boxCss, boxCss); 467 468 blurCtx.globalCompositeOperation = "lighter"; 469 if (supportsFilter) blurCtx.filter = "blur(" + (orbR * BLUR_FRAC * s).toFixed(2) + "px)"; 470 /* Capped below 1: saturated masses at full alpha under `lighter` sum to 471 white wherever they overlap, collapsing the value ramp. */ 472 var gain = (0.64 + 0.3 * sample.energy) * (0.86 + 0.14 * sample.voice) * introA; 473 for (var i = 0; i < masses.length; i++) { 474 var m = masses[i]; 475 var w = m.gated ? m.weight * (0.55 + 0.45 * sample.energy) : m.weight; 476 blurCtx.globalAlpha = Math.min(1, gain * w); 477 drawMass(blurCtx, m, mt, c, orbR, sample.focus); 478 } 479 blurCtx.filter = "none"; 480 481 /* Without a working ctx.filter the masses are still hard polygons at this 482 point â smear them before anything soft is layered on top. */ 483 if (!supportsFilter) { 484 smearFallback(orbR * BLUR_FRAC * s); 485 blurCtx.setTransform(s, 0, 0, s, 0, 0); 486 blurCtx.globalCompositeOperation = "lighter"; 487 } 488 489 /* Centre bloom â the "speaking" tell. Brightness, not geometry. The voice 490 term dominates: this is the syllable-rate flicker that reads as talking. */ 491 var bloomR = orbR * (0.72 + 0.2 * sample.energy); 492 var bloom = blurCtx.createRadialGradient(c, c, 0, c, c, bloomR); 493 var peak = (0.02 + 0.05 * sample.energy + 0.26 * sample.voice) * introA; 494 bloom.addColorStop(0, rgba(palette.coreWhite, peak)); 495 bloom.addColorStop(0.4, rgba(palette.sky, peak * 0.5)); 496 bloom.addColorStop(1, rgba(palette.sky, 0)); 497 blurCtx.globalAlpha = 1; 498 blurCtx.fillStyle = bloom;
499 blurCtx.fillRect(0, 0, boxCss, boxCss); 500 blurCtx.globalCompositeOperation = "source-over"; 501 } 502 503 /* Noise is sampled at (cos t, sin t) rather than at t â walking the unit 504 circle guarantees the value at 0 and 2PI matches, so the closed path has 505 no seam. Sampling at the angle directly leaves a kink at 3 o'clock. */ 506 function buildOrbPath(sample, cx, cy, r) { 507 var mt = sample.motionT, amp = 1 - 0.35 * sample.settle, j, kk; 508 for (j = 0; j < MORPH_SAMPLES; j++) { 509 var th = (j / MORPH_SAMPLES) * TAU, ct = Math.cos(th), st = Math.sin(th), d = 0; 510 for (kk = 0; kk < MORPH_ORDERS.length; kk++) { 511 /* Amplitude never fully vanishes (floor 0.18), or the shape snaps to a 512 circle and back, which reads as a glitch rather than a breath. */ 513 var lfo = 0.18 + 0.82 * (0.5 + 0.5 * Math.sin(mt * morphLfoRate[kk] + morphLfoPhase[kk])); 514 d += MORPH_AMP[kk] * lfo * Math.sin(MORPH_ORDERS[kk] * th + morphPhase[kk] + mt * morphSpeed[kk]); 515 } 516 d += MORPH_NOISE * noise(ct * 1.5, st * 1.5, mt * 0.06); 517 d *= MORPH_STRENGTH; 518 var rr = r * (1 + d * amp); 519 morphX[j] = cx + ct * rr; 520 morphY[j] = cy + st * rr; 521 } 522 var p = new Path2D(); 523 p.moveTo((morphX[0] + morphX[MORPH_SAMPLES-1]) / 2, (morphY[0] + morphY[MORPH_SAMPLES-1]) / 2); 524 for (j = 0; j < MORPH_SAMPLES; j++) { 525 var n = (j + 1) % MORPH_SAMPLES; 526 p.quadraticCurveTo(morphX[j], morphY[j], (morphX[j]+morphX[n])/2, (morphY[j]+morphY[n])/2); 527 } 528 p.closePath(); 529 return p; 530 } 531 532 function composite(sample) { 533 if (!orbCtx) return; 534 var box = bufOrb.width; 535 orbCtx.setTransform(1, 0, 0, 1, 0, 0); 536 orbCtx.globalCompositeOperation = "source-over"; 537 orbCtx.globalAlpha = 1; 538 orbCtx.clearRect(0, 0, box, box); 539 orbCtx.imageSmoothingEnabled = true; 540 var src = supportsFilter ? bufBlur : progressiveUpscale(bufBlur, box); 541 orbCtx.drawImage(src, 0, 0, src.width, src.height, 0, 0, box, box); 542 543 /* Grain BEFORE the mask, so it never dirties the page behind the orb. */ 544 if (grainPattern) { 545 if (!grainFill) grainFill = orbCtx.createPattern(grainPattern, "repeat"); 546 if (grainFill) { 547 var inset = box * 0.1; 548 orbCtx.globalAlpha = 0.05; 549 orbCtx.fillStyle = grainFill; 550 orbCtx.fillRect(inset, inset, box - inset * 2, box - inset * 2); 551 orbCtx.globalAlpha = 1; 552 } 553 } 554 555 /* Breathe on RAW time, morph on MOTION time: two independent rhythms read 556 organic where one shared clock reads as a metronome. Neither reads voice. */ 557 var t = sample.tMs / 1000; 558 var breathe = 1 + BREATH_AMP * sample.breath * Math.sin(t * 0.34); 559 var rPx = (orbR / boxCss) * box * breathe * (0.55 + 0.45 * sample.intro); 560 561 /* destination-in against a filled path, NOT ctx.clip(): clip() is not 562 reliably antialiased and a stair-stepped edge kills this look. */ 563 orbCtx.globalCompositeOperation = "destination-in"; 564 orbCtx.fillStyle = "#fff"; 565 orbCtx.fill(buildOrbPath(sample, box / 2, box / 2, rPx)); 566 orbCtx.globalCompositeOperation = "source-over"; 567 } 568 569 function frame(sample, pointerX, pointerY) { 570 if (disposed || wCss <= 2 || hCss <= 2 || orbR <= 2 || !blurCtx || !orbCtx) return; 571 var started = (performance && performance.now) ? performance.now() : 0; 572 var advancing = lastTMs >= 0 && sample.tMs !== lastTMs; 573 var settleWindow = sample.settle > 0.5 && sample.loopIndex >= 1; 574 575 if (sample.loopIndex !== lastLoopIndex) { 576 lastLoopIndex = sample.loopIndex; 577 if (currentTier !== pendingTier) applyTier(); 578 } else if (sample.settle > 0.92 && currentTier !== pendingTier) { 579 applyTier(); 580 } 581 582 /* Half-rate during the calm settle â held frames, not dropped content. A 583 frozen render (seek, paused resize) must always draw or it blanks. */ 584 frameParity ^= 1; 585 if (settleWindow && frameParity === 1 && advancing) return; 586 587 var dtSec = lastTMs < 0 ? 1/60 : Math.max(0, Math.min(0.1, (sample.tMs - lastTMs) / 1000)); 588 lastTMs = sample.tMs; 589
590 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 591 ctx.clearRect(0, 0, wCss, hCss); 592 593 var cx = wCss / 2 + pointerX * wCss * 0.014; 594 var cy = hCss / 2 + pointerY * hCss * 0.012; 595 var mt = sample.motionT; 596 597 drawBlurLayer(sample); 598 composite(sample); 599 600 /* Two-color outer glow, slowly counter-rotating. Blue leads. */ 601 var glowGain = (0.42 + 0.45 * sample.energy + 0.55 * sample.voice) * sample.intro; 602 var ga = mt * 0.1 + noise(0.3, mt * 0.04, 1.7) * 0.5; 603 var gr = orbR * 1.9; 604 ctx.globalCompositeOperation = "lighter"; 605 for (var i = 0; i < 2; i++) { 606 var col = i === 0 ? palette.blue : palette.purple; 607 var strength = i === 0 ? 1 : 0.55; 608 var ang = ga + i * Math.PI; 609 var gx = cx + Math.cos(ang) * orbR * 0.34; 610 var gy = cy + Math.sin(ang) * orbR * 0.34; 611 var g = ctx.createRadialGradient(gx, gy, orbR * 0.5, gx, gy, gr); 612 g.addColorStop(0, rgba(col, 0.3 * glowGain * strength)); 613 g.addColorStop(0.45, rgba(col, 0.1 * glowGain * strength)); 614 g.addColorStop(1, rgba(col, 0)); 615 ctx.fillStyle = g; 616 ctx.fillRect(cx - gr * 1.1, cy - gr * 1.1, gr * 2.2, gr * 2.2); 617 } 618 ctx.globalCompositeOperation = "source-over"; 619 ctx.drawImage(bufOrb, cx - boxCss / 2, cy - boxCss / 2, boxCss, boxCss); 620 621 /* Adaptive quality. Spacing, not CPU time, is the primary signal: canvas 622 rasterizes off the main thread, so a CPU timer stays green while the 623 page janks. Tier switches are applied only at seams, above. */ 624 if (advancing && !settleWindow && started) { 625 var cost = performance.now() - started; 626 emaSpacingMs = emaSpacingMs * 0.9 + dtSec * 1000 * 0.1; 627 emaCpuMs = emaCpuMs * 0.9 + cost * 0.1; 628 if (emaSpacingMs > 26) { slowFrames++; fastFrames = 0; } 629 else if (emaSpacingMs < 17.5 && emaCpuMs < 8) { fastFrames++; slowFrames = 0; } 630 else { slowFrames = 0; fastFrames = 0; } 631 if (slowFrames > 30) pendingTier = 1; 632 if (fastFrames > 360) pendingTier = 0; 633 } 634 } 635 636 function seek() { 637 lastTMs = -1; lastLoopIndex = -2; frameParity = 0; pendingTier = 0; 638 if (currentTier !== 0) applyTier(); 639 emaSpacingMs = 16.7; emaCpuMs = 4; slowFrames = 0; fastFrames = 0; 640 } 641 642 return { 643 resize: resize, frame: frame, seek: seek, 644 dispose: function () { disposed = true; }, 645 diagnostics: function () { 646 return { 647 cssSize: Math.round(wCss) + " x " + Math.round(hCss), 648 devicePixelRatio: window.devicePixelRatio || 1, 649 dprUsed: dpr, 650 canvasBacking: canvas.width + " x " + canvas.height, 651 backingPerCssPx: wCss ? +(canvas.width / wCss).toFixed(2) : 0, 652 orbRadiusCss: Math.round(orbR), 653 orbBufferPx: bufOrb.width, 654 blurBufferPx: bufBlur.width, 655 ctxFilterSupported: supportsFilter, 656 qualityTier: currentTier, 657 imageSmoothing: orbCtx ? orbCtx.imageSmoothingEnabled : null 658 }; 659 } 660 }; 661 } 662 663 /* ==================================== MOUNT ============================= */ 664 function mount(el, options) { 665 if (!el) throw new Error("LivingSignal.mount: no element"); 666 options = options || {}; 667 var palette = {}; 668 for (var key in DEFAULT_PALETTE) palette[key] = DEFAULT_PALETTE[key]; 669 if (options.palette) for (key in options.palette) palette[key] = options.palette[key]; 670 671 var pauseControl = options.pauseControl || "top-right"; 672 673 if (getComputedStyle(el).position === "static") el.style.position = "relative"; 674 675 var canvas = document.createElement("canvas"); 676 canvas.setAttribute("aria-hidden", "true"); 677 /* image-rendering is set explicitly: a host stylesheet with 678 `canvas { image-rendering: pixelated }` (common in resets aimed at sprite 679 art) turns every upscaled buffer into visible blocks. */ 680 canvas.style.cssText = 681 "position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:auto;pointer-events:none"; 682 el.appendChild(canvas); 683 el.setAttribute("role", "img"); 684 el.setAttribute("aria-label", 685 "Ambient animation: a luminous form listens, brightens as it speaks, and settles again"); 686 687 var renderer = createRenderer(canvas, palette, 20260726); 688 if (!renderer) return { destroy: function () {}, setPaused: function () {}, seek: function () {} }; 689 690 var tRef = options.startAtMs || 0; 691 var lastNow = null, raf = 0, sized = false, running = false;
692 var inView = false, docVisible = true, userPaused = false; 693 var ptr = { x: 0, y: 0 }, ptrTarget = { x: 0, y: 0 }; 694 695 var mqReduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null; 696 function reducedNow() { 697 if (typeof options.reduced === "boolean") return options.reduced; 698 return mqReduce ? mqReduce.matches : false; 699 } 700 701 /* WCAG 2.2.2: motion runs longer than 5s, so a pause control ships with the 702 component. Hide it only if the host supplies its own. */ 703 var btn = null; 704 if (pauseControl !== "hidden") { 705 btn = document.createElement("button"); 706 btn.type = "button"; 707 btn.style.cssText = 708 "position:absolute;top:16px;" + (pauseControl === "top-left" ? "left:16px;" : "right:16px;") + 709 "width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;" + 710 "border-radius:999px;border:1px solid " + palette.border + ";background:rgba(11,27,53,0.55);" + 711 "color:" + palette.highlight + ";cursor:pointer;padding:0;z-index:1"; 712 el.appendChild(btn); 713 btn.addEventListener("click", function () { setPaused(!userPaused); }); 714 } 715 function paintButton() { 716 if (!btn) return; 717 btn.setAttribute("aria-pressed", String(userPaused)); 718 btn.setAttribute("aria-label", userPaused ? "Play animation" : "Pause animation"); 719 btn.innerHTML = userPaused 720 ? '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 1.5 L10.5 6 L3 10.5 Z" fill="currentColor"/></svg>' 721 : '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><rect x="2.5" y="1.5" width="2.6" height="9" rx="0.8" fill="currentColor"/><rect x="6.9" y="1.5" width="2.6" height="9" rx="0.8" fill="currentColor"/></svg>'; 722 btn.style.display = reducedNow() ? "none" : "inline-flex"; 723 } 724 paintButton(); 725 726 function drawStill(tMs) { 727 if (!sized) return; 728 renderer.frame(sampleTimeline(tMs), ptr.x, ptr.y); 729 } 730 731 function tick(now) { 732 /* Clamped dt: a hidden tab or a long frame must never fast-forward. */ 733 var dt = lastNow === null ? 0 : clampDt(now - lastNow); 734 lastNow = now; 735 tRef += dt; 736 var k = 1 - Math.exp((-3.2 * dt) / 1000); 737 ptr.x += (ptrTarget.x - ptr.x) * k; 738 ptr.y += (ptrTarget.y - ptr.y) * k; 739 renderer.frame(sampleTimeline(tRef), ptr.x, ptr.y); 740 raf = requestAnimationFrame(tick); 741 } 742 743 function evaluate() { 744 var reduced = reducedNow(); 745 var shouldRun = inView && docVisible && !userPaused && !reduced; 746 if (shouldRun === running) return; 747 running = shouldRun; 748 if (running) { lastNow = null; raf = requestAnimationFrame(tick); } 749 else { 750 cancelAnimationFrame(raf); 751 lastNow = null; 752 /* Reduced motion shows the static frame, but NEVER by mutating the live 753 clock â a later resume must continue from the right instant. */ 754 if (reduced && options.startAtMs === undefined) { 755 renderer.seek(RESOLVED_SNAPSHOT_MS); 756 drawStill(RESOLVED_SNAPSHOT_MS); 757 } else { 758 drawStill(tRef); 759 } 760 } 761 paintButton(); 762 } 763 764 /* Observe the CANVAS, not the container. 765 The canvas is positioned inset:0, which resolves against the container's 766 PADDING box, while ResizeObserver's contentRect reports the CONTENT box. 767 If the host gives the container any padding or border, those two differ â 768 the backing store is then sized smaller than the element it paints into 769 and the browser stretches it. That reads as "pixelated" and it is the 770 most likely cause of it in an unfamiliar host layout. Measuring the 771 canvas itself makes the mismatch impossible. */ 772 var ro = new ResizeObserver(function () { 773 var r = canvas.getBoundingClientRect(); 774 renderer.resize(r.width, r.height, window.devicePixelRatio || 1); 775 sized = r.width > 2 && r.height > 2; 776 if (!running) drawStill(tRef); 777 }); 778 ro.observe(canvas); 779 780 /* A monitor change can alter DPR with no CSS-size change, so ResizeObserver 781 stays silent â re-arm a resolution query to catch it. */ 782 var dprMql = null; 783 function onDpr() { 784 var r = el.getBoundingClientRect(); 785 renderer.resize(r.width, r.height, window.devicePixelRatio || 1); 786 if (!running) drawStill(tRef); 787 armDpr(); 788 } 789 function armDpr() { 790 if (dprMql && dprMql.removeEventListener) dprMql.removeEventListener("change", onDpr); 791 dprMql = window.matchMedia("(resolution: " + (window.devicePixelRatio || 1) + "dppx)"); 792 if (dprMql.addEventListener) dprMql.addEventListener("change", onDpr); 793 } 794 armDpr(); 795 796 var io = new IntersectionObserver(function (entries) { 797 inView = entries[0].isIntersecting; 798 evaluate(); 799 }, { threshold: 0.12 }); 800 io.observe(el); 801 802 function onVisibility() { docVisible = document.visibilityState === "visible"; evaluate(); } 803 document.addEventListener("visibilitychange", onVisibility); 804 onVisibility(); 805 806 function onReduce() { evaluate(); paintButton(); } 807 if (mqReduce && mqReduce.addEventListener) mqReduce.addEventListener("change", onReduce); 808 809 function onMove(e) { 810 var r = el.getBoundingClientRect(); 811 if (r.width < 2 || r.height < 2) return; 812 ptrTarget.x = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1)); 813 ptrTarget.y = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1)); 814 } 815 function onLeave() { ptrTarget.x = 0; ptrTarget.y = 0; } 816 el.addEventListener("pointermove", onMove); 817 el.addEventListener("pointerleave", onLeave); 818 819 function setPaused(v) { userPaused = !!v; evaluate(); paintButton(); } 820 821 return { 822 setPaused: setPaused, 823 /** Run this in the console if it renders wrong. See README notes below. */ 824 diagnostics: function () { 825 var d = renderer.diagnostics(); 826 var cs = getComputedStyle(canvas); 827 d.cssImageRendering = cs.imageRendering; 828 d.canvasClientSize = Math.round(canvas.clientWidth) + " x " + Math.round(canvas.clientHeight); 829 /* Any transform on an ancestor scales the painted result without 830 changing layout size, so the backing store no longer matches pixels. */ 831 var scaled = null, n = el; 832 while (n && n !== document.body) { 833 var tr = getComputedStyle(n).transform; 834 if (tr && tr !== "none") { scaled = (n.tagName || "?") + "." + (n.className || ""); break; } 835 n = n.parentElement; 836 } 837 d.ancestorTransform = scaled; 838 return d; 839 }, 840 seek: function (ms) { tRef = ms; renderer.seek(ms); if (!running) drawStill(tRef); }, 841 destroy: function () { 842 cancelAnimationFrame(raf); 843 ro.disconnect(); io.disconnect(); 844 document.removeEventListener("visibilitychange", onVisibility); 845 if (mqReduce && mqReduce.removeEventListener) mqReduce.removeEventListener("change", onReduce); 846 if (dprMql && dprMql.removeEventListener) dprMql.removeEventListener("change", onDpr); 847 el.removeEventListener("pointermove", onMove); 848 el.removeEventListener("pointerleave", onLeave); 849 renderer.dispose(); 850 if (canvas.parentNode) canvas.parentNode.removeChild(canvas); 851 if (btn && btn.parentNode) btn.parentNode.removeChild(btn); 852 } 853 }; 854 } 855 856 global.LivingSignal = { mount: mount, DEFAULT_PALETTE: DEFAULT_PALETTE }; 857})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.