1/** 2 * Buffalo trail: a herd of unseen buffalo walks the viewport, each footfall disturbing a 3 * small patch of binary in the ground. One walker leads the eye to the end of its tracks, 4 * where the buffalo itself resolves out of a field of 0s and 1s, then dissolves. 5 * Ambient background only. 6 * 7 * Three layers, cheapest first: 8 * 1. hoofprints - tinted sprites of buffalo-footprint.png, rotated to the gait 9 * 2. splashes - viewport heat grid of 0/1 glyphs, stamped in a small patch per footfall 10 * 3. reveal - buffalo.svg sampled to glyph cells, materialized outward from the trail end 11 * 12 * CSS vars (per theme mode): 13 * --site-bg-style: hex | tri | binary-aura | buffalo-trail | none 14 * --hex-bg-opacity: 0 hides the layer 15 * --trail-print-color / --trail-glyph-color / --trail-buffalo-color 16 * --trail-peak-alpha 17 */ 18(function (global) { 19 "use strict"; 20 21 var canvas, ctx; 22 var width = 0, height = 0, dpr = 1; 23 var animFrame = null; 24 var _enabled = false; 25 var _listenersAttached = false; 26 var _themeObserver = null; 27 var _reducedMotion = false; 28 var _lastTick = 0; 29 30 var walkers = []; 31 var prints = []; 32 33 // Viewport glyph grid for footfall splashes. 34 var heat = null; 35 var chars = null; 36 var cols = 0, rows = 0; 37 38 var buffalo = null; // { cells, cols, rows, aspect } 39 var box = null; 40 var revealPhase = 0; // 0 none, 1 materializing, 2 holding, 3 dissolving 41 var revealStart = 0; 42 43 var footprintImg = null; 44 var footprintReady = false; 45 var sprite = null; 46 var spriteKey = ""; 47 48 var PRINT_STAMP_MS = 220; 49 var PRINT_HOLD_MS = 6000; 50 var PRINT_FADE_MS = 9000; 51 var PRINT_WIDTH = 30; 52 var STRIDE_HALF = 13; 53 var PATH_STEP_PX = 58; 54 var PLANT_INTERVAL_MS = 1250; 55 56 var CELL = 14; // splash glyph cell 57 var SPLASH_CHANCE = 0.6; // not every footfall disturbs the ground 58 var SPLASH_MIN_R = 20; 59 var SPLASH_MAX_R = 42; 60 var SPLASH_SPARSE = 0.5; // fraction of covered cells that actually light 61 var HEAT_DECAY = 0.955; 62 var HEAT_FLOOR = 0.05; 63 var IDLE_FLIP = 0.02; 64 65 var BUFFALO_COLS = 46; 66 var REVEAL_IN_MS = 2100; 67 var REVEAL_HOLD_MS = 4200; 68 var REVEAL_OUT_MS = 2200; 69 var REVEAL_SCATTER_PX = 46; 70 var LEADER_REST_MIN_MS = 14000; 71 var LEADER_REST_MAX_MS = 22000; 72 var FIRST_HUNT_MIN_MS = 2200; 73 var FIRST_HUNT_MAX_MS = 4200; 74 var BRUSH_RADIUS = 95; 75 76 // Base alphas at the default --trail-peak-alpha (0.45); scaled by the theme value. 77 var NOMINAL_PEAK = 0.45; 78 var PRINT_ALPHA = 0.17; 79 var SPLASH_ALPHA = 0.3; 80 var BUFFALO_ALPHA = 0.24; 81 82 var themePrint = { r: 212, g: 99, b: 63 }; 83 var themeGlyph = { r: 245, g: 179, b: 53 }; 84 var themeBuffalo = { r: 245, g: 179, b: 53 }; 85 var alphaScale = 1; 86 87 var mouseX = -9999, mouseY = -9999; 88 89 var ASSET_BASE = (function () { 90 try { 91 var src = document.currentScript && document.currentScript.src; 92 if (src) { 93 return src.replace(/\/js\/website-builder\/[^/]*$/, "/images/"); 94 } 95 } catch (e) { /* ignore */ } 96 return "_content/WebsiteBuilder.Blazor/images/"; 97 })(); 98 99 function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } 100 function clamp01(v) { return clamp(v, 0, 1); } 101 function smooth(t) { return t * t * (3 - 2 * t); } 102 function rand(lo, hi) { return lo + Math.random() * (hi - lo); } 103 104 function now() { 105 return global.performance && global.performance.now 106 ? global.performance.now() 107 : Date.now(); 108 } 109 110 /* ---------- theme ---------- */ 111 112 function readBgStyle() { 113 var raw = getComputedStyle(document.documentElement).getPropertyValue("--site-bg-style").trim().toLowerCase(); 114 return raw || "hex"; 115 } 116 117 function isTrailMode() { 118 return readBgStyle() === "buffalo-trail"; 119 } 120 121 function parseRgb(color, fallback) { 122 if (!color) return fallback; 123 var m = String(color).match(/[\d.]+/g); 124 if (!m || m.length < 3) return fallback; 125 return { r: +m[0], g: +m[1], b: +m[2] }; 126 } 127 128 function resolveVarColor(varName, fallback) { 129 var probe = document.createElement("span"); 130 probe.style.color = "var(" + varName + ")"; 131 probe.hidden = true; 132 document.body.appendChild(probe); 133 var color = getComputedStyle(probe).color; 134 document.body.removeChild(probe); 135 return parseRgb(color, fallback); 136 } 137 138 function readNumber(style, name, fallback) { 139 var raw = style.getPropertyValue(name).trim(); 140 if (raw === "") return fallback; 141 var v = Number(raw); 142 return isNaN(v) ? fallback : v; 143 } 144 145 function rgba(c, a) { 146 return "rgba(" + c.r + "," + c.g + "," + c.b + "," + a.toFixed(3) + ")"; 147 } 148 149 function mix(a, b, t) { 150 return { 151 r: Math.round(a.r + (b.r - a.r) * t), 152 g: Math.round(a.g + (b.g - a.g) * t), 153 b: Math.round(a.b + (b.b - a.b) * t) 154 }; 155 } 156 157 /** 158 * Splash heat ramp: print colour at the cool edge through to the buffalo colour at peak. 159 * Weighted so most of the range sits in the brighter half, since the cool end is close to 160 * invisible against a dark page. 161 */ 162 function colorForHeat(h) { 163 return h > 0.6 164 ? mix(themeGlyph, themeBuffalo, (h - 0.6) / 0.4) 165 : mix(themePrint, themeGlyph, h / 0.6); 166 } 167 168 function refreshTheme() { 169 var style = getComputedStyle(document.documentElement); 170 var opacityRaw = style.getPropertyValue("--hex-bg-opacity").trim(); 171 var opacity = opacityRaw === "" ? 1 : Number(opacityRaw); 172 if (isNaN(opacity)) opacity = 1; 173 174 themePrint = resolveVarColor("--trail-print-color", themePrint); 175 themeGlyph = resolveVarColor("--trail-glyph-color", themeGlyph); 176 themeBuffalo = resolveVarColor("--trail-buffalo-color", themeBuffalo); 177 178 var peak = readNumber(style, "--trail-peak-alpha", NOMINAL_PEAK); 179 alphaScale = clamp(peak / NOMINAL_PEAK, 0, 2.2); 180 181 // Tint is baked into the sprite, so a palette change has to rebuild it. 182 sprite = null; 183 spriteKey = ""; 184 185 syncMode(opacity > 0.001); 186 187 if (_enabled && _reducedMotion) { 188 renderStatic(); 189 } 190 } 191 192 function syncMode(opacityEnabled) { 193 var want = isTrailMode() && opacityEnabled !== false; 194 195 if (want && !_enabled) { 196 _enabled = true; 197 initCanvas(); 198 if (_reducedMotion) { 199 renderStatic(); 200 } else { 201 resetHerd(now()); 202 ensureTickRunning(); 203 } 204 } else if (!want && _enabled) { 205 _enabled = false;
206 pauseAndClear(); 207 } else if (want && _enabled && !_reducedMotion) { 208 ensureTickRunning(); 209 } 210 211 // Look the element up even when the layer never initialised, so an inactive trail 212 // canvas isn't left sitting visible over the lattice on first load. 213 var el = canvas || document.getElementById("buffalo-trail-canvas"); 214 if (el) { 215 el.style.display = want ? "" : "none"; 216 } 217 } 218 219 function pauseAndClear() { 220 cancelAnimationFrame(animFrame); 221 animFrame = null; 222 prints = []; 223 walkers = []; 224 box = null; 225 revealPhase = 0; 226 if (heat) heat.fill(0); 227 if (ctx && canvas) { 228 ctx.clearRect(0, 0, width, height); 229 } 230 } 231 232 /* ---------- setup ---------- */ 233 234 function initCanvas() { 235 canvas = document.getElementById("buffalo-trail-canvas"); 236 if (!canvas) { 237 canvas = document.createElement("canvas"); 238 canvas.id = "buffalo-trail-canvas"; 239 canvas.className = "buffalo-trail-canvas"; 240 canvas.setAttribute("aria-hidden", "true"); 241 document.body.insertBefore(canvas, document.body.firstChild); 242 } 243 ctx = canvas.getContext("2d"); 244 resize(); 245 loadAssets(); 246 247 if (!_listenersAttached) { 248 window.addEventListener("resize", onResize); 249 document.addEventListener("mousemove", onMove); 250 document.addEventListener("mouseleave", onLeave); 251 // This layer animates continuously, so never burn frames on a hidden tab. 252 document.addEventListener("visibilitychange", function () { 253 if (document.hidden) { 254 cancelAnimationFrame(animFrame); 255 animFrame = null; 256 } else if (_enabled && !_reducedMotion) { 257 resetHerd(now()); 258 ensureTickRunning(); 259 } 260 }); 261 if (window.matchMedia) { 262 var mq = window.matchMedia("(prefers-reduced-motion: reduce)"); 263 var onMq = function () { 264 _reducedMotion = mq.matches; 265 if (!_enabled) return; 266 pauseAndClear(); 267 if (_reducedMotion) { 268 renderStatic(); 269 } else { 270 resetHerd(now()); 271 ensureTickRunning(); 272 } 273 }; 274 if (mq.addEventListener) { 275 mq.addEventListener("change", onMq); 276 } else if (mq.addListener) { 277 mq.addListener(onMq); 278 } 279 } 280 _themeObserver = new MutationObserver(function () { 281 refreshTheme(); 282 }); 283 _themeObserver.observe(document.documentElement, { 284 attributes: true, 285 attributeFilter: ["data-theme", "style", "class"] 286 }); 287 _listenersAttached = true; 288 } 289 } 290 291 function resize() { 292 if (!canvas || !ctx) return; 293 dpr = Math.min(window.devicePixelRatio || 1, 2); 294 width = window.innerWidth; 295 height = window.innerHeight; 296 canvas.width = Math.floor(width * dpr); 297 canvas.height = Math.floor(height * dpr); 298 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 299 sprite = null; 300 spriteKey = ""; 301 302 cols = Math.ceil(width / CELL) + 1; 303 rows = Math.ceil(height / CELL) + 1; 304 heat = new Float32Array(cols * rows); 305 chars = new Uint8Array(cols * rows); 306 for (var i = 0; i < chars.length; i++) { 307 chars[i] = Math.random() < 0.5 ? 48 : 49; 308 } 309 } 310 311 function onResize() { 312 if (!_enabled || !canvas) return; 313 resize(); 314 if (_reducedMotion) { 315 renderStatic(); 316 } else { 317 resetHerd(now()); 318 ensureTickRunning(); 319 } 320 } 321 322 function onMove(e) { 323 mouseX = e.clientX; 324 mouseY = e.clientY; 325 } 326 327 function onLeave() { 328 mouseX = -9999; 329 mouseY = -9999; 330 } 331 332 /* ---------- assets ---------- */ 333 334 function loadAssets() { 335 if (!footprintImg) { 336 footprintImg = new Image(); 337 footprintImg.onload = function () { footprintReady = true; }; 338 footprintImg.onerror = function () { footprintReady = false; }; 339 footprintImg.src = ASSET_BASE + "buffalo-footprint.png"; 340 } 341 if (!buffalo) { 342 var svg = new Image(); 343 svg.onload = function () { 344 buffalo = sampleBuffalo(svg); 345 if (_enabled && _reducedMotion) renderStatic(); 346 }; 347 svg.src = ASSET_BASE + "buffalo.svg"; 348 } 349 } 350 351 /** Tint is baked in: the source art is near-black, invisible on a dark page. */ 352 function buildSprite() { 353 if (!footprintReady) return null; 354 var key = themePrint.r + "," + themePrint.g + "," + themePrint.b + "@" + dpr; 355 if (sprite && spriteKey === key) return sprite; 356
357 var natW = footprintImg.naturalWidth || 142; 358 var natH = footprintImg.naturalHeight || 160; 359 var w = Math.max(1, Math.round(PRINT_WIDTH * dpr)); 360 var h = Math.max(1, Math.round(w * (natH / natW))); 361 362 var c = document.createElement("canvas"); 363 c.width = w; 364 c.height = h; 365 var g = c.getContext("2d"); 366 g.drawImage(footprintImg, 0, 0, w, h); 367 g.globalCompositeOperation = "source-in"; 368 g.fillStyle = "rgb(" + themePrint.r + "," + themePrint.g + "," + themePrint.b + ")"; 369 g.fillRect(0, 0, w, h); 370 371 sprite = c; 372 spriteKey = key; 373 return sprite; 374 } 375 376 /** 377 * Reduce the silhouette to a grid of glyph cells. Drawing at 3x the cell grid and 378 * averaging each 3x3 block keeps thin features (legs, horns) from dropping out the way a 379 * single-pixel-per-cell sample would. 380 */ 381 function sampleBuffalo(img) { 382 try { 383 var aspect = (img.naturalWidth > 0 && img.naturalHeight > 0) 384 ? img.naturalHeight / img.naturalWidth 385 : 780 / 1280; 386 var gcols = BUFFALO_COLS; 387 var grows = Math.max(1, Math.round(gcols * aspect)); 388 var SS = 3; 389 var sw = gcols * SS, sh = grows * SS; 390 391 var c = document.createElement("canvas"); 392 c.width = sw; 393 c.height = sh; 394 var g = c.getContext("2d"); 395 g.drawImage(img, 0, 0, sw, sh); 396 var data = g.getImageData(0, 0, sw, sh).data; 397 398 var cells = []; 399 for (var gy = 0; gy < grows; gy++) { 400 for (var gx = 0; gx < gcols; gx++) { 401 var sum = 0; 402 for (var oy = 0; oy < SS; oy++) { 403 for (var ox = 0; ox < SS; ox++) { 404 sum += data[(((gy * SS + oy) * sw) + (gx * SS + ox)) * 4 + 3]; 405 } 406 } 407 if (sum / (SS * SS) < 100) continue; 408 cells.push({ 409 nx: (gx + 0.5) / gcols, 410 ny: (gy + 0.5) / grows, 411 ch: Math.random() < 0.5 ? 48 : 49, 412 delay: 0, 413 sx: 0, sy: 0 414 }); 415 } 416 } 417 418 if (cells.length < 24) return null; 419 return { cells: cells, cols: gcols, rows: grows, aspect: aspect }; 420 } catch (e) { 421 // Tainted canvas or decode failure: fall back to a tracks-only effect. 422 return null; 423 } 424 } 425 426 /* ---------- paths ---------- */ 427 428 function bezier(p0, c1, c2, p3, t) { 429 var u = 1 - t; 430 var a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t; 431 return { 432 x: a * p0.x + b * c1.x + c * c2.x + d * p3.x, 433 y: a * p0.y + b * c1.y + c * c2.y + d * p3.y 434 }; 435 } 436 437 function edgePoint(margin) { 438 var edge = Math.floor(Math.random() * 4); 439 if (edge === 0) return { x: -margin, y: rand(height * 0.1, height * 0.95), edge: edge }; 440 if (edge === 1) return { x: width + margin, y: rand(height * 0.1, height * 0.95), edge: edge }; 441 if (edge === 2) return { x: rand(width * 0.05, width * 0.95), y: height + margin, edge: edge }; 442 return { x: rand(width * 0.05, width * 0.95), y: -margin, edge: edge }; 443 } 444 445 /** 446 * Leaders stop in frame so the buffalo has somewhere to stand; the rest of the herd walks 447 * clean across and off the far side. 448 */ 449 function buildPath(leader) { 450 if (width < 240 || height < 240) return null; 451 452 var start, end; 453 var minSpan = Math.hypot(width, height) * (leader ? 0.42 : 0.7); 454 for (var attempt = 0; attempt < 10; attempt++) { 455 start = edgePoint(90); 456 end = leader 457 ? { x: rand(width * 0.2, width * 0.8), y: rand(height * 0.44, height * 0.78) } 458 : edgePoint(90); 459 if (Math.hypot(end.x - start.x, end.y - start.y) >= minSpan) break; 460 } 461 462 var dx = end.x - start.x, dy = end.y - start.y; 463 var len = Math.hypot(dx, dy) || 1; 464 var nx = -dy / len, ny = dx / len; 465 var bow1 = rand(-0.2, 0.2) * len; 466 var bow2 = rand(-0.2, 0.2) * len; 467 var c1 = { x: start.x + dx * 0.33 + nx * bow1, y: start.y + dy * 0.33 + ny * bow1 }; 468 var c2 = { x: start.x + dx * 0.66 + nx * bow2, y: start.y + dy * 0.66 + ny * bow2 }; 469 470 // Walk the curve at fine resolution so prints land at even arc-length spacing. 471 var samples = []; 472 var total = 0; 473 var prev = bezier(start, c1, c2, end, 0); 474 samples.push({ x: prev.x, y: prev.y, s: 0 }); 475 var STEPS = 480; 476 for (var i = 1; i <= STEPS; i++) { 477 var pt = bezier(start, c1, c2, end, i / STEPS); 478 total += Math.hypot(pt.x - prev.x, pt.y - prev.y); 479 samples.push({ x: pt.x, y: pt.y, s: total }); 480 prev = pt; 481 } 482 483 var nodes = []; 484 var count = Math.max(6, Math.floor(total / PATH_STEP_PX)); 485 var si = 0; 486 for (var n = 0; n < count; n++) { 487 var target = (total * (n + 0.5)) / count; 488 while (si < samples.length - 2 && samples[si + 1].s < target) si++; 489 var a = samples[si]; 490 var b = samples[Math.min(si + 1, samples.length - 1)]; 491 var angle = Math.atan2(b.y - a.y, b.x - a.x); 492 var pnx = -Math.sin(angle), pny = Math.cos(angle); 493 var side = (n % 2 === 0) ? 1 : -1; 494 var jitter = rand(-3, 3); 495 496 nodes.push({ 497 x: a.x + pnx * (STRIDE_HALF * side + jitter), 498 y: a.y + pny * (STRIDE_HALF * side + jitter), 499 angle: angle, 500 mirror: side < 0 501 }); 502 } 503 504 return { nodes: nodes, end: end, endAngle: nodes.length ? nodes[nodes.length - 1].angle : 0 }; 505 } 506 507 function herdSize() { 508 // Scale on the diagonal rather than area so a laptop still gets a populated field. 509 return clamp(Math.round(Math.sqrt(width * height) / 230), 2, 6); 510 } 511 512 function makeWalker(leader, startAt) { 513 var path = buildPath(leader); 514 return { 515 leader: !!leader, 516 nodes: path ? path.nodes : [], 517 end: path ? path.end : null, 518 endAngle: path ? path.endAngle : 0, 519 i: 0, 520 startAt: startAt, 521 interval: leader ? PLANT_INTERVAL_MS : rand(PLANT_INTERVAL_MS - 200, PLANT_INTERVAL_MS + 330), 522 resting: false, 523 resumeAt: 0 524 }; 525 } 526 527 function resetHerd(t) { 528 prints = []; 529 box = null; 530 revealPhase = 0; 531 if (heat) heat.fill(0); 532 if (ctx) ctx.clearRect(0, 0, width, height); 533 534 walkers = []; 535 walkers.push(makeWalker(true, t + rand(FIRST_HUNT_MIN_MS, FIRST_HUNT_MAX_MS))); 536 var n = herdSize(); 537 for (var i = 1; i < n; i++) { 538 // Stagger the herd, and start most of them mid-crossing so the page is never bare. 539 var w = makeWalker(false, t - rand(0, 18000)); 540 walkers.push(w); 541 } 542 } 543 544 /* ---------- simulation ---------- */ 545 546 function plant(node, born, splash) { 547 prints.push({
548 x: node.x, y: node.y, 549 angle: node.angle, 550 mirror: node.mirror, 551 born: born, 552 brush: 0 553 }); 554 if (splash && Math.random() < SPLASH_CHANCE) { 555 injectSplash(node.x, node.y); 556 } 557 } 558 559 function advanceWalkers(t) { 560 for (var w = 0; w < walkers.length; w++) { 561 var k = walkers[w]; 562 563 if (k.resting) { 564 if (t >= k.resumeAt) { 565 var fresh = makeWalker(k.leader, t); 566 walkers[w] = fresh; 567 } 568 continue; 569 } 570 571 if (k.nodes.length === 0) { 572 k.resting = true; 573 k.resumeAt = t + 3000; 574 continue; 575 } 576 577 while (k.i < k.nodes.length) { 578 var due = k.startAt + k.i * k.interval; 579 if (t < due) break; 580 // Only splash for steps landing about now; a backdated catch-up shouldn't 581 // detonate a dozen rings at once. 582 plant(k.nodes[k.i], due, t - due < 400); 583 k.i++; 584 } 585 586 if (k.i >= k.nodes.length) { 587 k.resting = true; 588 if (k.leader && buffalo && computeBox(k)) { 589 revealPhase = 1; 590 revealStart = t; 591 k.resumeAt = t + REVEAL_IN_MS + REVEAL_HOLD_MS + REVEAL_OUT_MS 592 + rand(LEADER_REST_MIN_MS, LEADER_REST_MAX_MS); 593 } else { 594 k.resumeAt = t + (k.leader 595 ? rand(LEADER_REST_MIN_MS, LEADER_REST_MAX_MS) 596 : rand(1500, 6000)); 597 } 598 } 599 } 600 } 601 602 /** 603 * The buffalo stands just beyond the end of the leader's tracks: offset along the 604 * direction of travel so the trail approaches its feet instead of vanishing under its 605 * belly, and mirrored when needed so it faces the way it was walking. 606 */ 607 function computeBox(walker) { 608 if (!buffalo || !walker || !walker.end) return false; 609 var bw = Math.min(560, width * 0.42); 610 var bh = bw * buffalo.aspect; 611 var dirX = Math.cos(walker.endAngle); 612 box = { 613 x: clamp(walker.end.x + dirX * bw * 0.5 - bw / 2, 12, Math.max(12, width - bw - 12)), 614 y: clamp(walker.end.y - bh + 6, 56, Math.max(56, height - bh - 12)), 615 w: bw, 616 h: bh, 617 // Source silhouette faces left, so travelling rightward needs a mirror. 618 flip: dirX > 0 619 }; 620 621 // Materialize outward from where the tracks stop. 622 var ax = (walker.end.x - box.x) / box.w; 623 var ay = (walker.end.y - box.y) / box.h; 624 var cells = buffalo.cells; 625 var maxD = 0.0001; 626 var i, d; 627 for (i = 0; i < cells.length; i++) { 628 var nx = box.flip ? (1 - cells[i].nx) : cells[i].nx; 629 d = Math.hypot(nx - ax, (cells[i].ny - ay) * 0.6); 630 cells[i].delay = d; 631 if (d > maxD) maxD = d; 632 } 633 for (i = 0; i < cells.length; i++) { 634 cells[i].delay /= maxD; 635 var a = Math.random() * Math.PI * 2; 636 cells[i].sx = Math.cos(a) * rand(0.4, 1); 637 cells[i].sy = Math.sin(a) * rand(0.4, 1); 638 } 639 return true; 640 } 641 642 function advanceReveal(t) { 643 if (revealPhase === 0) return; 644 var age = t - revealStart; 645 if (revealPhase === 1 && age >= REVEAL_IN_MS) { revealPhase = 2; } 646 if (revealPhase === 2 && age >= REVEAL_IN_MS + REVEAL_HOLD_MS) { revealPhase = 3; } 647 if (revealPhase === 3 && age >= REVEAL_IN_MS + REVEAL_HOLD_MS + REVEAL_OUT_MS) { 648 revealPhase = 0; 649 box = null; 650 } 651 } 652 653 function decayHeat() { 654 for (var i = 0; i < heat.length; i++) { 655 if (heat[i] > HEAT_FLOOR) { 656 heat[i] *= HEAT_DECAY; 657 } else if (heat[i] > 0) { 658 heat[i] = 0; 659 } 660 } 661 } 662 663 /** 664 * A footfall disturbs a small patch of ground: heat is stamped once and left to decay, so 665 * it settles rather than pulsing. The boundary is pushed around by a couple of random 666 * harmonics and cells drop out at random, so it never reads as a clean circle. 667 */ 668 function injectSplash(x, y) { 669 var baseR = rand(SPLASH_MIN_R, SPLASH_MAX_R); 670 var lobes = 2 + Math.floor(Math.random() * 2);
671 var ph1 = Math.random() * Math.PI * 2; 672 var ph2 = Math.random() * Math.PI * 2; 673 var amp = rand(0.25, 0.5); 674 675 var span = Math.ceil((baseR * (1 + amp)) / CELL) + 1; 676 var cc = Math.floor(x / CELL), rc = Math.floor(y / CELL); 677 678 for (var dr = -span; dr <= span; dr++) { 679 var row = rc + dr; 680 if (row < 0 || row >= rows) continue; 681 for (var dc = -span; dc <= span; dc++) { 682 var col = cc + dc; 683 if (col < 0 || col >= cols) continue; 684 var ddx = col * CELL + CELL / 2 - x; 685 var ddy = row * CELL + CELL / 2 - y; 686 var dist = Math.hypot(ddx, ddy); 687 var ang = Math.atan2(ddy, ddx); 688 var edge = baseR * (1 + amp * ( 689 Math.sin(ang * lobes + ph1) * 0.6 + Math.sin(ang * 2 + ph2) * 0.4)); 690 if (edge <= 0 || dist > edge) continue; 691 if (Math.random() > SPLASH_SPARSE) continue; 692 // Lifted off zero: the few cells that do fire need to be perceptible, and the 693 // random dropout above is what keeps the patch quiet. 694 var v = (0.3 + 0.7 * (1 - dist / edge)) * rand(0.6, 1); 695 var idx = row * cols + col; 696 if (v > heat[idx]) heat[idx] = v; 697 } 698 } 699 } 700 701 /* ---------- drawing ---------- */ 702 703 function printAlpha(p, t) { 704 var age = t - p.born; 705 if (age < 0) return 0; 706 if (age < PRINT_STAMP_MS) return age / PRINT_STAMP_MS; 707 var held = age - PRINT_STAMP_MS; 708 if (held < PRINT_HOLD_MS) return 1; 709 var f = (held - PRINT_HOLD_MS) / PRINT_FADE_MS; 710 return f >= 1 ? 0 : 1 - f; 711 } 712 713 function drawSplashes() { 714 var half = CELL / 2; 715 ctx.font = (CELL - 3) + 'px "Courier New",monospace'; 716 ctx.textAlign = "center"; 717 ctx.textBaseline = "middle"; 718 719 for (var r = 0; r < rows; r++) { 720 var rowOff = r * cols; 721 for (var c = 0; c < cols; c++) { 722 var idx = rowOff + c; 723 var h = heat[idx]; 724 if (h < HEAT_FLOOR) continue; 725 var a = h * SPLASH_ALPHA * alphaScale; 726 if (a < 0.004) continue; 727 if (Math.random() < IDLE_FLIP) { 728 chars[idx] = chars[idx] === 48 ? 49 : 48; 729 } 730 ctx.fillStyle = rgba(colorForHeat(h), a); 731 ctx.fillText(String.fromCharCode(chars[idx]), c * CELL + half, r * CELL + half); 732 } 733 } 734 } 735 736 function drawPrints(t) { 737 var img = buildSprite(); 738 var alive = []; 739 740 for (var i = 0; i < prints.length; i++) { 741 var p = prints[i]; 742 var base = printAlpha(p, t); 743 if (base <= 0) continue; 744 alive.push(p); 745 746 // Brushing dust off a track: proximity re-brightens it a little. 747 var near = 0; 748 if (mouseX > -9000) { 749 var d = Math.hypot(p.x - mouseX, p.y - mouseY); 750 if (d < BRUSH_RADIUS) near = 1 - d / BRUSH_RADIUS; 751 } 752 p.brush += (near - p.brush) * 0.12; 753 754 var age = t - p.born; 755 var alpha = clamp01(base * (1 + p.brush * 1.1)) * PRINT_ALPHA * alphaScale; 756 757 if (img && alpha >= 0.003) { 758 var scale = age < PRINT_STAMP_MS 759 ? 1 + 0.09 * (1 - age / PRINT_STAMP_MS) 760 : 1; 761 var w = PRINT_WIDTH * scale; 762 var h = w * (img.height / img.width); 763 ctx.save(); 764 ctx.globalAlpha = alpha; 765 ctx.translate(p.x, p.y); 766 // rotate(t) maps image +Y to (-sin t, cos t), so +90deg puts image -Y along 767 // travel: the broad rounded end of the hoof leads and the cleft trails. 768 ctx.rotate(p.angle + Math.PI / 2); 769 if (p.mirror) ctx.scale(-1, 1); 770 ctx.drawImage(img, -w / 2, -h / 2, w, h); 771 ctx.restore(); 772 } 773 } 774 775 prints = alive; 776 } 777 778 /** The buffalo resolves out of a field of 0s and 1s, brightest along the advancing edge. */ 779 function drawBuffalo(t) { 780 if (!buffalo || !box || revealPhase === 0) return; 781 782 var age = t - revealStart; 783 var progress = clamp01(age / REVEAL_IN_MS); 784 var fade = 1, scatter = 0; 785 if (revealPhase === 3) { 786 var out = clamp01((age - REVEAL_IN_MS - REVEAL_HOLD_MS) / REVEAL_OUT_MS); 787 fade = 1 - out; 788 scatter = out; 789 } 790 791 var cells = buffalo.cells; 792 var cw = box.w / buffalo.cols; 793 var size = Math.max(7, cw * 0.95); 794 ctx.font = size.toFixed(1) + 'px "Courier New",monospace'; 795 ctx.textAlign = "center"; 796 ctx.textBaseline = "middle"; 797 798 var holding = revealPhase === 2; 799 800 for (var i = 0; i < cells.length; i++) { 801 var cell = cells[i]; 802 var local = clamp01((progress - cell.delay * 0.74) / 0.26); 803 if (local <= 0) continue; 804 var e = smooth(local); 805 806 // Advancing wavefront reads as the shape being decoded rather than fading in. 807 var edge = local < 0.34 ? (1 - local / 0.34) : 0; 808 var a = BUFFALO_ALPHA * alphaScale * e * fade * (1 + edge * 1.5); 809 if (a < 0.005) continue; 810 811 if (holding && Math.random() < 0.006) { 812 cell.ch = cell.ch === 48 ? 49 : 48; 813 } 814 815 var nx = box.flip ? (1 - cell.nx) : cell.nx; 816 var x = box.x + nx * box.w + cell.sx * scatter * REVEAL_SCATTER_PX; 817 var y = box.y + cell.ny * box.h + cell.sy * scatter * REVEAL_SCATTER_PX; 818 819 ctx.fillStyle = rgba(edge > 0.15 ? themeBuffalo : themeGlyph, a); 820 ctx.fillText(String.fromCharCode(cell.ch), x, y); 821 } 822 } 823 824 /** Reduced motion: one settled set of trails and a resting buffalo, drawn once. */
825 function renderStatic() { 826 if (!canvas || !ctx || !heat) return; 827 ctx.clearRect(0, 0, width, height); 828 heat.fill(0); 829 830 var t = now(); 831 prints = []; 832 mouseX = -9999; 833 834 var n = herdSize(); 835 var leader = null; 836 for (var w = 0; w < n; w++) { 837 var path = buildPath(w === 0); 838 if (!path) continue; 839 if (w === 0) leader = { end: path.end, endAngle: path.endAngle }; 840 for (var i = 0; i < path.nodes.length; i++) { 841 prints.push({ 842 x: path.nodes[i].x, y: path.nodes[i].y, 843 angle: path.nodes[i].angle, mirror: path.nodes[i].mirror, 844 born: t - PRINT_STAMP_MS - 1, 845 brush: 0 846 }); 847 } 848 } 849 850 drawPrints(t); 851 if (buffalo && leader && computeBox(leader)) { 852 revealPhase = 2; 853 revealStart = t - REVEAL_IN_MS; 854 drawBuffalo(t); 855 revealPhase = 0; 856 box = null; 857 } 858 prints = []; 859 } 860 861 /* ---------- loop ---------- */ 862 863 function ensureTickRunning() { 864 if (_enabled && !_reducedMotion && canvas && !animFrame && !document.hidden) { 865 _lastTick = now(); 866 animFrame = requestAnimationFrame(tick); 867 } 868 } 869 870 function tick() { 871 animFrame = null; 872 if (!_enabled || !canvas || !ctx) return; 873 874 if (!isTrailMode()) { 875 pauseAndClear(); 876 syncMode(false); 877 return; 878 } 879 880 var t = now(); 881 // Returning from a throttled background tab: restart rather than replaying a huge 882 // backlog of steps and footfall splashes. 883 if (t - _lastTick > 3000) { 884 _lastTick = t; 885 resetHerd(t); 886 animFrame = requestAnimationFrame(tick); 887 return; 888 } 889 _lastTick = t; 890 891 ctx.clearRect(0, 0, width, height); 892 advanceWalkers(t); 893 advanceReveal(t); 894 decayHeat(); 895 896 drawSplashes(); 897 drawPrints(t); 898 drawBuffalo(t); 899 900 animFrame = requestAnimationFrame(tick); 901 } 902 903 function init() { 904 refreshTheme(); 905 } 906 907 global.bhisBuffaloTrail = { 908 init: init, 909 refreshTheme: refreshTheme, 910 syncMode: function () { refreshTheme(); } 911 }; 912 913 function boot() { 914 try { 915 _reducedMotion = !!(window.matchMedia 916 && window.matchMedia("(prefers-reduced-motion: reduce)").matches); 917 } catch (e) { /* ignore */ } 918 919 init(); 920 var link = document.getElementById("site-theme-stylesheet"); 921 if (link) { 922 link.addEventListener("load", refreshTheme); 923 if (link.sheet) { 924 requestAnimationFrame(refreshTheme); 925 } 926 } 927 } 928 929 if (document.readyState === "loading") { 930 document.addEventListener("DOMContentLoaded", boot); 931 } else { 932 boot(); 933 } 934})(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.