PageSourceSearch

https://scoutbasketball.com/assets/controllers/hero_flow_controller-uHLvx07.js

js scoutbasketball.com collected 2026-10-02 21:40:36 UTC 10,050 bytes, 228 lines download raw bytes

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.