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.