PageSourceSearch

https://techexit.io/wp-content/themes/webbudswordpresstheme/js/tx-animations.js?v=1784678327

js techexit.io collected 2026-10-04 08:00:29 UTC 33,706 bytes, 635 lines download raw bytes

1(function () {
2    /**
3     * TX BACKGROUND ENGINE
4     * Stripped from SN 2026 (ported via BTF). Canvas animations: light_rays,
5     * plaid_diamonds, chevron_flow. (grainient is now pure CSS — no JS here.)
6     */
7    /**
8     * OFF-SCREEN GATE (added 2026-07-21)
9     * Every canvas animation below runs an uncapped requestAnimationFrame loop
10     * that repaints the WHOLE canvas each frame — and browsers only throttle rAF
11     * for hidden TABS, not for off-screen elements. So on a long page (the TX
12     * agenda is ~13,700px) the hero's canvas kept repainting at full cost the
13     * entire time you were scrolled thousands of pixels past it.
14     *
15     * This marks each canvas visible/not, and the draw loops skip their drawing
16     * work when it's off-screen. The rAF chain itself keeps ticking (an empty
17     * callback is free) so the loop resumes instantly with no re-init needed.
18     * rootMargin keeps it drawing slightly before it scrolls into view.
19     */
20    function txWatchVisibility(canvas) {
21        canvas.__txVisible = true;
22        if (typeof IntersectionObserver === 'undefined') return;
23        new IntersectionObserver(function (entries) {
24            canvas.__txVisible = entries[0].isIntersecting;
25        }, { rootMargin: '250px' }).observe(canvas);
26    }
27
28    function hexToRgb(hex) {
29        if (!hex) return { r: 255, g: 255, b: 255 };
30        let clean = hex.replace('#', '').trim();
31        if (clean.length > 6) clean = clean.substring(0, 6);
32        if (clean.length === 3) { clean = clean.split('').map(c => c + c).join(''); }
33        const num = parseInt(clean, 16);
34        return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
35    }
36
37    const AnimationFactory = {
38
39        light_rays: function(canvas, config) {
40            const ctx = canvas.getContext('2d');
41            let w, h, dpr = Math.min(window.devicePixelRatio || 1, 1.2), rays = [];
42            const color = hexToRgb(config.conical_ray_color || config.star_color || '#ffffff');
43            const speedBase = (parseFloat(config.ray_speed) || 5.0) * 1.2;
44            const countConfig = parseInt(config.conical_ray_count) || 24;
45            function init() {
46                w = canvas.offsetWidth; h = canvas.offsetHeight;
47                canvas.width = w * dpr; canvas.height = h * dpr;
48                ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
49                const count = w < 768 ? 14 : countConfig;
50                rays = Array.from({ length: count }, () => ({
51                    angle: (Math.PI / 2) + (Math.random() * 1.6 - 0.8), width: Math.random() * 0.08 + 0.02,
52                    speed: (Math.random() * 0.006 + 0.003) * speedBase, offset: Math.random() * Math.PI * 2
53                }));
54            }
55            function draw() {
56                if (canvas.__txVisible === false) { requestAnimationFrame(draw); return; }
57                ctx.clearRect(0, 0, w, h); ctx.globalCompositeOperation = 'screen';
58                const cX = w / 2, cY = w < 768 ? -250 : -120;
59                rays.forEach(r => {
60                    r.offset += r.speed; const alpha = (Math.sin(r.offset) + 1) / 2 * 0.22;
61                    const grad = ctx.createRadialGradient(cX, cY, 0, cX, cY, h * 1.8);
62                    grad.addColorStop(0, `rgba(${color.r},${color.g},${color.b},${alpha})`);
63                    grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad;
64                    ctx.beginPath(); ctx.moveTo(cX, cY);
65                    ctx.arc(cX, cY, h * 2.5, r.angle - r.width/2, r.angle + r.width/2); ctx.fill();
66                });
67                if (!canvas.classList.contains('is-ready')) canvas.classList.add('is-ready');
68                requestAnimationFrame(draw);
69            }
70            window.addEventListener('resize', init); init(); draw();
71        },
72
73        plaid_diamonds: function(canvas, config) {
74            const ctx = canvas.getContext('2d');
75            const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
76
77            // Config
78            const speedMul  = (parseFloat(config.plaid_speed) || 5) / 5;
79            const placement = config.plaid_placement || 'right';   // 'right' | 'center' | 'full'
80            // Direction is locked to vertical. Horizontal mode was removed 2026-04-18
81            // because hero-aspect sections (wide but short) only fit ~2 horizontal
82            // tracks, which kills the weave. See memory note for full geometry.
83            const direction = 'vertical';
84            const size      = config.plaid_size || 'small';         // 'small' | 'medium' | 'large'
85
86            // Read city-color from --blue-rgb on the engine element (flows via body.<city>)
87            const engine = canvas.closest('.tx-bg-engine');
88            let blueRgb = '1, 115, 230';
89            if (engine) {
90                const cityColor = getComputedStyle(engine).getPropertyValue('--blue-rgb').trim();
91                if (cityColor) blueRgb = cityColor;
92            }
93
94            let w, h, diamonds = [];
95
96            // Size buckets. Spacing is TIGHT on purpose (track ≈ 0.69× base,
97            // along ≈ 1.0× base) so neighboring diamonds overlap heavily —
98            // that overlap is what creates the woven/plaid read.
99            const SIZE_PRESETS = {
100                small:  { base: 520, track: 290, along: 520 },
101                medium: { base: 720, track: 400, along: 720 },
102                large:  { base: 960, track: 535, along: 960 },
103            };
104            const preset = SIZE_PRESETS[size] || SIZE_PRESETS.small;
105            const BASE_SIZE     = preset.base;
106            const TRACK_SPACING = preset.track;
107            const ALONG_SPACING = preset.along;
108            const BASE_SPEED    = 0.35 * speedMul;
109
110            function init() {
111                w = canvas.offsetWidth;
112                h = canvas.offsetHeight;
113                canvas.width  = w * dpr;
114                canvas.height = h * dpr;
115                ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
116
117                diamonds = [];
118
119                // Region to fill
120                let rx0 = 0, rx1 = w, ry0 = 0, ry1 = h;
121                if (placement === 'right') {
122                    rx0 = w * 0.45;
123                } else if (placement === 'center') {
124                    rx0 = w * 0.18;
125                    rx1 = w * 0.82;
126                }
127                const regionW = rx1 - rx0;
128                const regionH = ry1 - ry0;
129
130                // Small-screen reduction — same spirit as light_rays
131                const isSmall = w < 768;
132                const trackSpacing = isSmall ? TRACK_SPACING * 1.15 : TRACK_SPACING;
133                const alongSpacing = isSmall ? ALONG_SPACING * 1.15 : ALONG_SPACING;
134
135                if (direction === 'vertical') {
136                    const numCols = Math.max(2, Math.floor(regionW / trackSpacing));
137                    const colW    = regionW / numCols;
138                    const cycle   = Math.ceil((regionH + BASE_SIZE * 2) / alongSpacing) * alongSpacing;
139                    const perCol  = Math.ceil(cycle / alongSpacing);
140
141                    for (let c = 0; c < numCols; c++) {
142                        const cx       = rx0 + colW * (c + 0.5);
143                        const dirSign  = (c % 2 === 0) ? 1 : -1;
144                        const colSpeed = dirSign * BASE_SPEED * (0.7 + Math.random() * 0.6);
145                        const phase    = Math.random() * alongSpacing;
146
147                        for (let d = 0; d < perCol; d++) {
148                            diamonds.push({
149                                x: cx + (Math.random() - 0.5) * 14,
150                                y: ry0 - BASE_SIZE + d * alongSpacing + phase,
151                                size: BASE_SIZE * (0.55 + Math.random() * 0.90),
152                                opacity: 0.10 + Math.random() * 0.18,
153                                // Per-diamond nested-layer randomization so each diamond
154                                // has unique internal rhythm (not just scaled clones).
155                                r2: 0.68 + Math.random() * 0.22,   // filled middle (0.68–0.90)
156                                r3: 0.40 + Math.random() * 0.25,   // inner outline (0.40–0.65)
157                                r4: 0.16 + Math.random() * 0.22,   // bright core (0.16–0.38)
158                                sw1: 1 + Math.random() * 9,        // outer outline stroke (1–10px)
159                                sw3: 1 + Math.random() * 6,        // inner outline stroke (1–7px)
160                                vx: 0,
161                                vy: colSpeed,
162                                cycle: cycle,
163                                axis: 'y',
164                                minBound: ry0,
165                                maxBound: ry1,
166                            });
167                        }
168                    }
169                } else { // horizontal
170                    const numRows = Math.max(2, Math.floor(regionH / trackSpacing));
171                    const rowH    = regionH / numRows;
172                    const cycle   = Math.ceil((regionW + BASE_SIZE * 2) / alongSpacing) * alongSpacing;
173                    const perRow  = Math.ceil(cycle / alongSpacing);
174
175                    for (let r = 0; r < numRows; r++) {
176                        const cy       = ry0 + rowH * (r + 0.5);
177                        const dirSign  = (r % 2 === 0) ? 1 : -1;
178                        const rowSpeed = dirSign * BASE_SPEED * (0.7 + Math.random() * 0.6);
179                        const phase    = Math.random() * alongSpacing;
180
181                        for (let d = 0; d < perRow; d++) {
182                            diamonds.push({
183                                x: rx0 - BASE_SIZE + d * alongSpacing + phase,
184                                y: cy + (Math.random() - 0.5) * 14,
185                                size: BASE_SIZE * (0.55 + Math.random() * 0.90),
186                                opacity: 0.10 + Math.random() * 0.18,
187                                // Per-diamond nested-layer randomization so each diamond
188                                // has unique internal rhythm (not just scaled clones).
189                                r2: 0.68 + Math.random() * 0.22,   // filled middle (0.68–0.90)
190                                r3: 0.40 + Math.random() * 0.25,   // inner outline (0.40–0.65)
191                                r4: 0.16 + Math.random() * 0.22,   // bright core (0.16–0.38)
192                                sw1: 1 + Math.random() * 9,        // outer outline stroke (1–10px)
193                                sw3: 1 + Math.random() * 6,        // inner outline stroke (1–7px)
194                                vx: rowSpeed,
195                                vy: 0,
196                                cycle: cycle,
197                                axis: 'x',
198                                minBound: rx0,
199                                maxBound: rx1,
200                            });
201                        }
202                    }
203                }
204            }
205
206            /* FRAME-RATE CAP (2026-07-21).
207               This canvas redraws 100+ diamonds x 4 nested shapes (2 strokeRect
208               + 2 fillRect each) = 400+ canvas ops EVERY frame. Measured on the
209               live TX agenda at 4x CPU throttle: disabling this one canvas took
210               the page from 30fps / 24 janky frames to 40fps / 5 — it was by far
211               the single biggest scroll cost on the page.
212               A slow ambient background drift does not need 60fps. Capping to 30
213               halves the work. Movement is scaled by ACTUAL elapsed time (dt), so
214               the diamonds travel at exactly the same visual speed as before —
215               only the redraw frequency changes. dt is clamped 
215so a long stall
216               (background tab, GC pause) can't teleport them across the canvas. */
217            var lastDraw = 0;
218            var FRAME_MS = 1000 / 30;
219            function draw(now) {
220                if (canvas.__txVisible === false) { requestAnimationFrame(draw); return; }
221                now = now || performance.now();
222                if (now - lastDraw < FRAME_MS) { requestAnimationFrame(draw); return; }
223                var dt = lastDraw ? Math.min((now - lastDraw) / 16.667, 4) : 1;
224                lastDraw = now;
225                ctx.clearRect(0, 0, w, h);
226
227                for (let i = 0; i < diamonds.length; i++) {
228                    const dm = diamonds[i];
229                    dm.x += dm.vx * dt;
230                    dm.y += dm.vy * dt;
231
232                    // Seamless wrap — shift by one full cycle distance when exiting.
233                    if (dm.axis === 'y') {
234                        if (dm.vy > 0 && dm.y - BASE_SIZE > dm.maxBound) dm.y -= dm.cycle;
235                        else if (dm.vy < 0 && dm.y + BASE_SIZE < dm.minBound) dm.y += dm.cycle;
236                    } else {
237                        if (dm.vx > 0 && dm.x - BASE_SIZE > dm.maxBound) dm.x -= dm.cycle;
238                        else if (dm.vx < 0 && dm.x + BASE_SIZE < dm.minBound) dm.x += dm.cycle;
239                    }
240
241                    // Nested concentric structure — each "diamond" is 4 layers
242                    // (outer outline / filled middle / inner outline / bright core).
243                    // This internal rhythm is what gives the plaid read at every
244                    // scale, even before neighboring diamonds overlap.
245                    ctx.save();
246                    ctx.translate(dm.x, dm.y);
247                    ctx.rotate(Math.PI / 4);
248
249                    const s  = dm.size;
250                    const op = dm.opacity;
251
252                    // Outer outline (faintest) — size fixed to dm.size, stroke varies
253                    ctx.strokeStyle = 'rgba(' + blueRgb + ',' + (op * 0.85) + ')';
254                    ctx.lineWidth = dm.sw1;
255                    ctx.strokeRect(-s / 2, -s / 2, s, s);
256
257                    // Filled middle band
258                    const s2 = s * dm.r2;
259                    ctx.fillStyle = 'rgba(' + blueRgb + ',' + (op * 1.10) + ')';
260                    ctx.fillRect(-s2 / 2, -s2 / 2, s2, s2);
261
262                    // Inner outline
263                    const s3 = s * dm.r3;
264                    ctx.strokeStyle = 'rgba(' + blueRgb + ',' + (op * 1.60) + ')';
265                    ctx.lineWidth = dm.sw3;
266                    ctx.strokeRect(-s3 / 2, -s3 / 2, s3, s3);
267
268                    // Bright center core
269                    const s4 = s * dm.r4;
270                    ctx.fillStyle = 'rgba(' + blueRgb + ',' + (op * 2.20) + ')';
271                    ctx.fillRect(-s4 / 2, -s4 / 2, s4, s4);
272
273                    ctx.restore();
274                }
275
276                if (!canvas.classList.contains('is-ready')) canvas.classList.add('is-ready');
277                requestAnimationFrame(draw);
278            }
279
280            init();
281
282            // Post-load safety catch: fonts / AOS / images settle after the
283            // initial init. One delayed unconditional re-init fixes any
284            // canvas-buffer-vs-displayed-size mismatch from that.
285            setTimeout(init, 500);
286
287            // Width-only, debounced re-init on both window resize AND
288            // container reflow. iOS fires window.resize every time the
289            // URL bar hides/shows during scroll (it treats that chrome
290            // change as a viewport resize), which would otherwise rebuild
291            // the diamonds repeatedly and look like the animation "resets".
292            // Width check ignores those height-only events.
293            var initTimer;
294            function scheduleWidthCheck() {
295                clearTimeout(initTimer);
296                initTimer = setTimeout(function() {
297                    if (Math.abs(canvas.offsetWidth - w) > 2) init();
298                }, 180);
299            }
300
301            window.addEventListener('resize', scheduleWidthCheck);
302
303            if (typeof ResizeObserver !== 'undefined') {
304                var ro = new ResizeObserver(scheduleWidthCheck);
305                ro.observe(canvas);
306            }
307
308            draw();
309        },
310
311        // NOTE: grainient no longer has a JS handler. As of 2026-07-20 it is a
312        // pure-CSS, compositor-only background (soft transform-animated blobs) —
313        // see background-engine.php (renders .tx-grainient, not a canvas) and the
314        // GRAINIENT MODE block in css/tx-animations.css. No requestAnimationFrame,
315        // no canvas, and it needs no init here.
316
317        /**
318         * CHEVRON FLOW
319         * Series of right-pointing `>` chevrons rendered as TWO THICK STROKES
320         * (top-arm + bottom-arm) meeting at a right-pointing tip. Each arm has:
321         *   - Baseline gradient: dark at back → bright at tip (static per frame)
322         *   - Traveling bright peak at position `lp` (animated, added via 'lighter')
323         * Dark interior between the arms — no fill, no vertical backs stacking.
324         * City-color aware via --blue-rgb. Right edge fades to pure black.
325         */
326        chevron_flow: function(canvas, config) {
327            const ctx = canvas.getContext('2d');
328            const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
329            const speedMul = (parseFloat(config.chevron_flow_speed) || 5) / 5;
330            const sizeMul  = parseFloat(config.chevron_flow_size) || 1;
331            // Direction: 'rtl' (default) = peak travels tip → back (right → left).
332            // 'ltr' = peak travels back → tip (left → right). Implemented as a sign
333            // on the phase delta — negative = phase decrements = peak moves toward
334            // lower lp = right-to-left. Positive = left-to-right.
335            const dirSign = (config.chevron_flow_direction === 'ltr') ? 1 : -1;
336
337            // City-color via --blue-rgb on the engine element
338            const engine = canvas.closest('.tx-bg-engine');
339            let blueRgb = '1, 115, 230';
340            if (engine) {
341                const cityColor = getComputedStyle(engine).getPropertyValue('--blue-rgb').trim();
342                if (cityColor) blueRgb = cityColor;
343            }
344            const parts = blueRgb.split(',').map(s => parseInt(s.trim(), 10) || 0);
345            const bR = parts[0], bG = parts[1], bB = parts[2];
346
347            let w, h, chevrons = [];
348            let armThickness = 14;
349            let topBackY = 0;   // y-coord of arm back (top-left corner) — can be negative at sizeMul > 1
350            let botBackY = 0;   // y-coord of arm back (bottom-left corner) — can exceed h at sizeMul > 1
351            let phase = 0;
352
353            // Smoothstep easing — 0 below edge0, 1 above edge1, smooth cubic between.
354            // Used to shape the peak's intensity envelope so it fades in at cycle
355            // start and out at cycle end (no more "blink in" on wraparound).
356            function smoothstep(edge0, edge1, x) {
357                const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
358                return t * t * (3 - 2 * t);
359            }
360
361            function init() {
362                w = canvas.offsetWidth;
363                h = canvas.offsetHeight;
364                canvas.width  = w * dpr;
365                canvas.height = h * dpr;
366                ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
367
368                // Each chevron is defined by its horizontal span `armLen` — the
369                // horizontal distance between the back of each arm and the tip.
370                // Arms angle from top/bottom corners in to the middle-right tip.
371                // sizeMul (ACF-controlled, default 1) scales the chevron PROPORTIONALLY —
372                // armLen grows AND the arm-back y-coords extend above/below the canvas so
373                // the chevron keeps its aspect ratio instead of flattening/squishing.
374                const armLen = Math.max(120, Math.min(h * 0.55, 240)) * sizeMul;
375                const step   = armLen * 0.82;   // light overlap — cleaner tips, less bar-ish
376                armThickness = Math.max(10, Math.min(h * 0.035, 18)) * sizeMul;
377
378                // Arm-back y-coords scale with sizeMul so the chevron shape stays
379                // consistent. At sizeMul=1, backs are at y=0 and y=h (canvas edges).
380                // At sizeMul=2, backs extend to y=-h/2 and y=3h/2 (half-canvas above and
381                // below, clipped by canvas bounds but keeps the tip angle correct).
382                topBackY = (h / 2) * (1 - sizeMul);
383                botBackY = (h / 2) * (1 + sizeMul);
384
385                const leftStart = -armLen;
386                const rightEnd  = w * 0.82;
387
388                const count = Math.ceil((rightEnd - leftStart) / step) + 2;
389                chevrons = [];
390                for (let i = 0; i < count; i++) {
391                    chevrons.push({
392                        x: leftStart + i * step,
393                        armLen: armLen,
394                        phaseOffset: (i * 0.24) % 1
395                    });
396                }
397            }
398
399            /**
400             * Draw a chevron as a SINGLE path — top-back → tip → bottom-back.
401             * One continuous stroke means:
402             *   - lineJoin: round creates a smooth rounded V at the tip (no double cap)
403             *   - Horizontal gradient paints both arms in sync (peak arrives at the
404             *     same x-coordinate on both arms simultaneously)
405             * Two passes as before — static base + traveling peak with intensity envelope.
406             */
407            // ── Pass 0: Fill the chevron's triangle body with a horizontal gradient.
408            // Four-stop gradient:
409            //   0.00 — canvas color (invisible merge at back edge, no hard cutoff)
410            //   0.30 — dim tint (wider fade-in zone for a smoother back transition)
411            //   0.70 — mid tint
412            //   1.00 — bright var(--blue) at tip
413            // Wider back-fade (30%) gives a more gradual darkening from tip back toward
414            // the canvas, instead of the tint "falling off a cliff" in 15% at the back.
415            function drawChevronFill(c) {
416                const tipX = c.x + c.armLen;
417                const tipY = h / 2;
418
419                const fillG = ctx.createLinearGradient(c.x, 0, tipX, 0);
420                fillG.addColorStop(0.00, 'rgb(5, 5, 5)');
421                // Intensities bumped from 0.20 / 0.35 / 0.50 → 0.28 / 0.52 / 0.75.
422                // The original values were tuned on teal-green / curious-blue cities
423                // where 50% intensity still reads as the brand hue. Hot pink #FF0097
424                // at 50% = (128, 0, 76) which the eye reads as "dark purple/burgundy"
425                // instead of pink — the saturation collapses at low luminance. Higher
426                // tip intensity keeps the brand-color perception across all cities.
427                fillG.addColorStop(0.30, `rgb(${Math.round(bR * 0.28)}, ${Math.round(bG * 0.28)}, ${Math.round(bB * 0.28)})`);
428                fillG.addColorStop(0.70, `rgb(${Math.round(bR * 0.52)}, ${Math.round(bG * 0.52)}, ${Math.round(bB * 0.52)})`);
429                fillG.addColorStop(1.00, `rgb(${Math.round(bR * 0.75)}, ${Math.round(bG * 0.75)}, ${Math.round(bB * 0.75)})`);
430
431                ctx.save();
432                ctx.fillStyle = fillG;
433                ctx.beginPath();
434                ctx.moveTo(c.x, topBackY);
435                ctx.lineTo(tipX, tipY);
436                ctx.lineTo(c.x, botBackY);
437                ctx.closePath();
438                ctx.fill();
439                ctx.restore();
440            }
441
442            /**
443             * Draw strokes for a chevron — static base + traveling peak with intensity envelope.
444             * Called AFTER all fills so strokes of every chevron sit on top of every fill,
445             * including later chevrons' back-overlap fills. This preserves each chevron's
446             * bright tip highlight across all overlaps.
447             */
448            function drawChevronStrokes(c, lp) {
449                const tipX = c.x + c.armLen;
450                const tipY = h / 2;
451
452                // No intensity envelope — peak stays at full brightness. The gradient
453                // below extends BRIGHT all the way to positions 0 or 1 when the peak
454                // is near the canvas edges, so the highlight punches through to the
455                // tip and to the top/bottom arm backs without fading within frame.
456                // Cycle wrap creates a 1-frame teleport that's negligibly perceptible
457                // because each chevron has a different phaseOffset — a fresh peak is
458                // already mid-travel on the next chevron.
459                const peakMax = 0.95;
460                const peakMid = 0.55;
461
462                // ── Pass 1: Static base gradient — dim back → bright tip ──
463                // (Horizontal gradient; both arms of the `>` get synchronous treatment)
464                const baseG = ctx.createLinearGradient(c.x, 0, tipX, 0);
465                baseG.addColorStop(0.00, `rgba(${bR}, ${bG}, ${bB}, 0.12)`);
466                baseG.addColorStop(0.50, `rgba(${bR}, ${bG}, ${bB}, 0.35)`);
467                baseG.addColorStop(1.00, `rgba(${bR}, ${bG}, ${bB}, 0.75)`);
468
469                ctx.save();
470                ctx.strokeStyle = baseG;
471                ctx.lineWidth   = armThickness;
472                ctx.lineCap     = 'round';
473                ctx.lineJoin    = 'miter';
474                ctx.miterLimit  = 20;
475                ctx.beginPath();
476                ctx.moveTo(c.x, topBackY);  // top-back corner (can be above canvas at sizeMul>1)
477                ctx.lineTo(tipX, tipY);      // tip (smooth V-joint here)
478                ctx.lineTo(c.x, botBackY);  // bottom-back corner (can be below canvas at sizeMul>1)
479                ctx.stroke();
480                ctx.restore();
481
482                // ── Pass 2: Traveling bright peak — OFF-FRAME ENTRY/EXIT ──
483                // Map lp [0, 1] to an EXTENDED position range gp [-0.14, 1.14] so that
484                // at the start of a cycle the peak is off-canvas past the tip (gp > 1)
485                // and fades into view as gp approaches 1; at the end of the cycle the
486                // peak fades out past the back (gp < 0). The peak stops are only drawn
487                // when they land inside [0, 1], and the two boundary stops (at 0 and 1)
488                // are computed from the peak's distance so the visible edge brightness
489                // is correct no matter where gp is. This hides the cycle wrap entirely:
490                // while one peak is fading out off-canvas past the back, the NEXT cycle
491                // starts with the peak already off-canvas past the tip (also invisible),
492                // then fades in at the tip as gp descends through 1.14 → 1.00.
493                {
494                    ctx.save();
495                    ctx.globalCompositeOperation = 'lighter';
496
497                    const gp = lp * 1.28 - 0.14;
498
499                    // Peak brightness profile: peakMax at center, peakMid at ±0.04,
500                    // 0 at ±0.14. Linear falloff in each segment.
501                    const peakAlphaAt = (pos) => {
502                        const d = Math.abs(pos - gp);
503                        if (d >= 0.14) return 0;
504                        if (d <= 0.04) return peakMax + (peakMid - peakMax) * (d / 0.04);
505                        return peakMid * (1 - (d - 0.04) / 0.10);
506                    };
507
508                    const peakG = ctx.createLinearGradient(c.x, 0, tipX, 0);
509
510                    // Boundary stops — alpha computed from peak's distance so the edge
511                    // renders correctly whether gp is mid-arm, near a boundary, or past.
512                    peakG.addColorStop(0, `rgba(${bR}, ${bG}, ${bB}, ${peakAlphaAt(0)})`);
513                    peakG.addColorStop(1, `rgba(${bR}, ${bG}, ${bB}, ${peakAlphaAt(1)})`);
514
515                    // Internal peak stops — only added if within (0, 1).
516                    const addStop = (pos, alpha) => {
517                        if (pos > 0 && pos < 1) {
518                            peakG.addColorStop(pos, `rgba(${bR}, ${bG}, ${bB}, ${alpha})`);
519                        }
520                    };
521                    addStop(gp - 0.14, 0);
522                    addStop(gp - 0.04, peakMid);
523                    addStop(gp,        peakMax);
524                    addStop(gp + 0.04, peakMid);
525                    addStop(gp + 0.14, 0);
526
527                    ctx.strokeStyle = peakG;
528                    ctx.lineWidth   = armThickness;
529                    ctx.lineCap     = 'round';
530                    ctx.lineJoin    = 'round';
531                    ctx.shadowColor = `rgba(${bR}, ${bG}, ${bB}, 0.55)`;
532                    ctx.shadowBlur  = 4;
533                    ctx.beginPath();
534                    ctx.moveTo(c.x, topBackY);
535                    ctx.lineTo(tipX, tipY);
536                    ctx.lineTo(c.x, botBackY);
537                    ctx.stroke();
538                    ctx.restore();
539                }
540            }
541
542            function draw() {
543                if (canvas.__txVisible === false) { requestAnimationFrame(draw); return; }
544                ctx.clearRect(0, 0, w, h);
545                // dirSign controls direction: -1 = phase decrements = peak travels tip → back
546                // (right-to-left). +1 = phase increments = peak travels back → tip (left-to-right).
547                phase = (phase + dirSign * 0.0022 * speedMul + 1) % 1;
548
549                // Solid dark base
550                ctx.fillStyle = '#050505';
551                ctx.fillRect(0, 0, w, h);
552
553                // Two-pass rendering so stroke highlights (base + traveling peak) of
554                // EVERY chevron land on top of EVERY fill — not just its own. Previously
555                // a later chevron's back fill would overpaint the tip strokes of the
556                // chevron before it; splitting fills and strokes into separate loops
557                // keeps all tips bright and preserves the gradient-through-the-tips look.
558
559                // Pass A: fills drawn RIGHT-TO-LEFT so each chevron's bright tip paints
560                // ON TOP of the next chevron's dark back (in their ~18% overlap zone).
561                // If drawn left-to-right, the next chevron's dark back would overpaint
562                // the previous chevron's bright tip — creating a dark V cut into each body.
563                for (let i = chevrons.length - 1; i >= 0; i--) {
564                    drawChevronFill(chevrons[i]);
565                }
566                // Pass B: all strokes (base gradient + traveling peak) on top of all fills.
567                for (let i = 0; i < chevrons.length; i++) {
568                    const c = chevrons[i];
569                    const lp = (phase + c.phaseOffset) % 1;
570                    drawChevronStrokes(c, lp);
571                }
572
573                // Full-width dissolve — subtle darkening ramp from left (fully transparent)
574                // to right (fully black), so chevrons feel progressively more muted as they
575                // travel rightward across the entire canvas (not just the last 40%).
576                const fade = ctx.createLinearGradient(0, 0, w, 0);
577                fade.addColorStop(0, 'rgba(0,0,0,0)');
578                fade.addColorStop(1, 'rgba(0,0,0,1)');
579                ctx.fillStyle = fade;
580                ctx.fillRect(0, 0, w, h);
581
582                if (!canvas.classList.contains('is-ready')) canvas.classList.add('is-ready');
583                requestAnimationFrame(draw);
584            }
585
586            // Resize handling — debounced reinit
587            let resizeTimer;
588            const scheduleInit = () => {
589                clearTimeout(resizeTimer);
590                resizeTimer = setTimeout(init, 150);
591            };
592            window.addEventListener('resize', scheduleInit);
593            if (typeof ResizeObserver !== 'undefined') {
594                const ro = new ResizeObserver(scheduleInit);
595                ro.observe(canvas);
596            }
597
598            init();
599            draw();
600        }
601    };
602
603    // Decorative canvas backgrounds run an uncapped requestAnimationFrame loop
604    // that repaints the whole canvas every frame. On phones / small tablets
605    // (throttled CPUs) those frames pile up into massive main-thread blocking —
606    // a Moto G measured 7,520ms TBT — and the full-hero <canvas> also becomes
607    // the LCP element, which can't paint until this JS runs (53s LCP). Skip the
608    // animation at <=991px and when the visitor asked for reduced motion: the
609    // hero falls back to its static CSS background-color + gradient overlay,
610    // which paints at FCP. Desktop has no throttling so it keeps the animation.
611    function txSkipBgAnimation() {
612        try {
613            return window.matchMedia('(max-width: 991px)').matches
614                || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
615        } catch (e) { return false; }
616    }
617
618    function initBackgroundEngine() {
619        if (txSkipBgAnimation()) return;
620        var canvases = document.querySelectorAll('.tx-bg-canvas:not([data-initialized="true"])');
621        canvases.forEach(function(canvas) {
622            var type = canvas.dataset.animation;
623            if (!type || type === 'none' || !AnimationFactory[type]) return;
624            try {
625                var config = JSON.parse(canvas.dataset.config || '{}');
626                txWatchVisibility(canvas);   // must run BEFORE the draw loop starts
627                AnimationFactory[type](canvas, config);
628                canvas.setAttribute('data-initialized', 'true');
629            } catch (e) { console.error("TX Engine Init Error:", e); }
630        });
631    }
632    window.addEventListener('load', initBackgroundEngine);
633    document.addEventListener('DOMContentLoaded', initBackgroundEngine);
634    setTimeout(initBackgroundEngine, 1000);
635})();

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.