PageSourceSearch

https://www.blackhillsinfosec.com/_content/WebsiteBuilder.Blazor/js/website-builder/buffalo-trail.js?v=2

js blackhillsinfosec.com collected 2026-09-24 09:06:52 UTC 32,701 bytes, 934 lines download raw bytes

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.