1/* stimulusFetch: 'lazy' */ 2 3import { Controller } from '@hotwired/stimulus'; 4 5/* 6 * Hero flow: makes the shot-arc backdrop react to a fine pointer. 7 * 8 * A warm "pulse" (halo + core stroke segment) rides the point of each arc 9 * nearest to the cursor â the closer the cursor, the brighter â the traveling 10 * dots of nearby arcs flare, a soft light follows the cursor over the floor and 11 * the plank/trace/dot layers drift at different depths. The pulse and parallax 12 * ease toward their targets in a rAF loop that parks itself once everything 13 * has settled and wakes on the next pointermove. 14 * 15 * Skipped for coarse pointers and prefers-reduced-motion: the ambient SMIL 16 * animation stays untouched there (and under reduced-motion the CSS media 17 * queries hide the dots entirely); the pulse strokes stay at opacity 0 in 18 * CSS, so nothing is visible unless this controller drives it. 19 */ 20const SAMPLES = 140; // points pre-sampled along each arc 21const NEAR = 220; // arc distance (user units) at which the pulse dies out 22const EASE = 0.12; // per-frame ease for pulse position/intensity 23const DRIFT_EASE = 0.06; // slower ease for the parallax drift 24const CORE_SEG = 70; // pulse core length (user units) 25const HALO_SEG = 190; // pulse halo length 26const SETTLE = 0.5; // pointer position considered converged (user units) 27// Parallax depth, px of drift at screen edges per layer. 28const DRIFT = { planks: 3, traces: 6, dots: 10 }; 29 30export default class extends Controller { 31 connect() { 32 // Defaults live before the early returns: disconnect() must be safe 33 // even when connect() bailed for reduced-motion / coarse pointers. 34 this.arcs = []; 35 this.dotEls = []; 36 37 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 38 if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; 39 40 const svg = this.element.querySelector('.home-hero-flow'); 41 if (!svg || typeof svg.getScreenCTM !== 'function') return; 42 this.svg = svg; 43 this.planks = this.element.querySelector('.home-hero-planks'); 44 this.traces = svg.querySelector('.home-hero-flow-traces'); 45 this.dots = svg.querySelector('.home-hero-flow-dots'); 46 this.spot = this.element.querySelector('.home-hero-spot'); 47 // Same order as the arcs (arc1..arc4) in the template. 48 this.dotEls = this.dots ? [...this.dots.querySelectorAll('circle')] : []; 49 50 // Pulse <use> elements grouped per arc, paired with the defs path. 51 const byHref = new Map(); 52 for (const use of svg.querySelectorAll('.home-hero-flow-pulse use')) { 53 const href = use.getAttribute('href'); 54 if (!byHref.has(href)) { 55 const path = svg.querySelector(href); 56 if (!path || typeof path.getTotalLength !== 'function') continue; 57 const len = path.getTotalLength(); 58 const pts = []; 59 for (let k = 0; k <= SAMPLES; k++) { 60 pts.push(path.getPointAtLength((k / SAMPLES) * len)); 61 } 62 byHref.set(href, { pts, len, uses: [], energy: 0, bestLen: 0, baseR: null }); 63 } 64 const arc = byHref.get(href); 65 if (arc) { 66 arc.uses.push({ 67 el: use, 68 seg: use.classList.contains('home-hero-flow-core') ? CORE_SEG : HALO_SEG, 69 }); 70 } 71 } 72 this.arcs = [...byHref.values()].filter(a => a.uses.length); 73 if (!this.arcs.length) return; 74 75 this.cur = null; // eased pointer position (user units) 76 this.tgt = null; // raw pointer position (client px) 77 this.drift = { x: 0, y: 0 }; 78 this.driftTgt = { x: 0, y: 0 }; 79 this.active = false; // pointer is somewhere on the page 80 this.visible = true; 81 this.raf = null; 82 83 this._onMove = e => { 84 this.tgt = { x: e.clientX, y: e.clientY }; 85 this.driftTgt = { 86 x: (e.clientX / window.innerWidth) * 2 - 1, 87 y: (e.clientY / window.innerHeight) * 2 - 1, 88 }; 89 this.active = true; 90 this.start(); 91 }; 92 this._onGone = () => { 93 this.active = false;
94 this.start(); // wake the loop so lit pulses decay where they are 95 }; 96 this._onOut = e => { if (!e.relatedTarget) this._onGone(); }; 97 window.addEventListener('pointermove', this._onMove, { passive: true }); 98 window.addEventListener('pointerout', this._onOut); 99 window.addEventListener('blur', this._onGone); 100 101 this.io = new IntersectionObserver(entries => { 102 this.visible = entries[0].isIntersecting; 103 if (this.visible) this.start(); else this.stop(); 104 }); 105 this.io.observe(this.element); 106 this.start(); 107 } 108 109 disconnect() { 110 window.removeEventListener('pointermove', this._onMove); 111 if (this._onOut) window.removeEventListener('pointerout', this._onOut); 112 window.removeEventListener('blur', this._onGone); 113 if (this.io) this.io.disconnect(); 114 this.stop(); 115 // Leave no inline styles behind on Turbo cache/back-forward. 116 for (const el of [this.planks, this.traces, this.dots, this.spot]) { 117 if (el) el.removeAttribute('style'); 118 } 119 for (const arc of this.arcs) { 120 for (const u of arc.uses) u.el.removeAttribute('style'); 121 } 122 for (const c of this.dotEls) c.removeAttribute('style'); 123 } 124 125 start() { 126 if (this.raf || !this.visible) return; 127 this.frame = this.frame || this.tick.bind(this); 128 this.raf = requestAnimationFrame(this.frame); 129 } 130 131 stop() { 132 if (this.raf) cancelAnimationFrame(this.raf); 133 this.raf = null; 134 } 135 136 tick() { 137 this.raf = requestAnimationFrame(this.frame); 138 139 const rect = this.element.getBoundingClientRect(); 140 // One CTM conversion per frame: raw pointer â arc user-space. 141 let has = this.active && !!this.tgt; 142 let moving = false; // any eased value still converging toward its target 143 if (has) { 144 const ctm = this.svg.getScreenCTM(); 145 if (ctm) { 146 const p = new DOMPoint(this.tgt.x, this.tgt.y).matrixTransform(ctm.inverse()); 147 if (!this.cur) this.cur = { x: p.x, y: p.y }; 148 const ex = p.x - this.cur.x; 149 const ey = p.y - this.cur.y; 150 if (ex * ex + ey * ey > SETTLE * SETTLE) moving = true; 151 this.cur.x += ex * EASE; 152 this.cur.y += ey * EASE; 153 } else { 154 has = false; 155 } 156 } 157 const mx = this.cur ? this.cur.x : 0; 158 const my = this.cur ? this.cur.y : 0; 159 160 this.arcs.forEach((arc, i) => { 161 let target = 0; 162 if (has) { 163 let bd = Infinity; 164 let bestLen = 0; 165 for (let s = 0; s < arc.pts.length; s++) { 166 const dx = arc.pts[s].x - mx; 167 const dy = arc.pts[s].y - my; 168 const d = dx * dx + dy * dy; 169 if (d < bd) { 170 bd = d; 171 bestLen = (s / SAMPLES) * arc.len; 172 } 173 } 174 arc.bestLen = bestLen; // remembered so a decaying pulse fades in place 175 const t = Math.max(0, 1 - Math.sqrt(bd) / NEAR); 176 target = t * t * (3 - 2 * t); // smoothstep 177 } 178 const gap = target - arc.energy; 179 if (Math.abs(gap) > 0.005) moving = true; 180 arc.energy += gap * EASE; 181 182 for (const { el, seg } of arc.uses) { 183 if (arc.energy < 0.01) { 184 el.style.opacity = '0'; 185 continue; 186 } 187 const start = ((arc.bestLen - seg / 2) % arc.len + arc.len) % arc.len; 188 el.style.strokeDasharray = `${seg} ${arc.len}`; 189 el.style.strokeDashoffset = `${-start}`; 190 el.style.opacity = String(arc.energy * (seg === CORE_SEG ? 0.5 : 0.14)); 191 } 192 193 const dot = this.dotEls[i]; 194 if (dot) { 195 const base = arc.baseR ?? (arc.baseR = parseFloat(dot.getAttribute('r')) || 5); 196 dot.style.r = `${(base * (1 + 0.35 * arc.energy)).toFixed(2)}px`; 197 dot.style.opacity = String(0.55 + 0.2 * arc.energy); 198 } 199 }); 200 201 // Parallax drift + floor light under the cursor. 202 const dgx = this.driftTgt.x - this.drift.x; 203 const dgy = this.driftTgt.y - this.drift.y; 204 if (dgx * dgx + dgy * dgy > 0.005 * 0.005) moving = true; 205 this.drift.x += dgx * DRIFT_EASE; 206 this.drift.y += dgy * DRIFT_EASE; 207 const dx = this.drift.x; 208 const dy = this.drift.y; 209 if (this.planks) { 210 this.planks.style.transform = `translate3d(${(dx * DRIFT.planks).toFixed(2)}px, ${(dy * DRIFT.planks).toFixed(2)}
210px, 0)`; 211 } 212 if (this.traces) { 213 this.traces.style.transform = `translate3d(${(dx * DRIFT.traces).toFixed(2)}px, ${(dy * DRIFT.traces).toFixed(2)}px, 0)`; 214 } 215 if (this.dots) { 216 this.dots.style.transform = `translate3d(${(dx * DRIFT.dots).toFixed(2)}px, ${(dy * DRIFT.dots).toFixed(2)}px, 0)`; 217 } 218 if (this.spot) { 219 this.spot.style.setProperty('--spot-x', `${(((this.tgt ? this.tgt.x : rect.width / 2) - rect.left) / rect.width * 100).toFixed(2)}%`); 220 this.spot.style.setProperty('--spot-y', `${(((this.tgt ? this.tgt.y : rect.height / 2) - rect.top) / rect.height * 100).toFixed(2)}%`); 221 this.spot.style.opacity = this.active ? '1' : '0'; 222 } 223 224 // Everything eased to its target: park the loop until the next 225 // pointermove (or pointer-gone) wakes it again. 226 if (!moving) this.stop(); 227 } 228}
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.