PageSourceSearch

https://caldariprimeponyclub.com/src/ui/SparkTrail.mjs

js caldariprimeponyclub.com collected 2026-10-07 19:38:16 UTC 10,484 bytes, 388 lines download raw bytes

1const SIDES = 170;
2
3const ABOVE = 40;
4
5const BELOW = 260;
6
7const MAX = 320;
8
9const HEAT = [
10    { at: 0, rgb: [ 255, 250, 235 ] },
11    { at: .25, rgb: [ 255, 226, 138 ] },
12    { at: .6, rgb: [ 255, 150, 40 ] },
13    { at: 1, rgb: [ 180, 48, 8 ] }
14];
15
16function Heat(age)
17{
18    for (let at = 1; at < HEAT.length; at++)
19    {
20        if (age > HEAT[at].at) continue;
21
22        const from = HEAT[at - 1];
23        const to = HEAT[at];
24        const t = (age - from.at) / (to.at - from.at || 1);
25
26        return [
27            Math.round(from.rgb[0] + (to.rgb[0] - from.rgb[0]) * t),
28            Math.round(from.rgb[1] + (to.rgb[1] - from.rgb[1]) * t),
29            Math.round(from.rgb[2] + (to.rgb[2] - from.rgb[2]) * t)
30        ];
31    }
32
33    return HEAT.at(-1).rgb;
34}
35
36function Still()
37{
38    return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
39}
40
41export class SparkTrail
42{
43    #canvas = null;
44    #context = null;
45    #sparks = [];
46    #frame = null;
47    #last = 0;
48    #box = null;
49
50    #floorAt = null;
51
52    constructor({ element, floor = null, live = null, idle = true, host = null, zIndex = 99999 } = {})
53    {
54        this.element = element;
55        this.host = host;
56        this.zIndex = zIndex;
57        this.floor = typeof floor === "function" ? floor : () => null;
58
59        this.live = typeof live === "function" ? live : () => true;
60
61        if (!element) return;
62
63        if (idle) this.#Wait();
64
65        element.addEventListener("mousemove", event => this.Trail(event));
66        element.addEventListener("click", () => this.Burst());
67    }
68
69    Trail(event)
70    {
71        if (Still() || !this.live()) return false;
72
73        const box = this.#Wake();
74
75        if (!box) return false;
76
77        if (Math.random() < .45) return true;
78
79        const count = 1 + Math.floor(Math.random() * 2);
80
81        for (let n = 0; n < count; n++)
82        {
83            this.#Spark(
84                event.clientX - box.left + (Math.random() - .5) * 6,
85                event.clientY - box.top + (Math.random() - .5) * 6
86            );
87        }
88
89        return true;
90    }
91
92    Burst()
93    {
94        if (Still() || !this.live()) return false;
95
96        const box = this.#Wake();
97
98        if (!box) return false;
99
100        const rect = this.element.getBoundingClientRect();
101        const left = rect.left - box.left;
102        const top = rect.top - box.top;
103
104        const count = 70 + Math.floor(Math.random() * 50);
105
106        for (let n = 0; n < count; n++)
107        {
108            this.#Spark(
109                left + Math.random() * rect.width,
110                top + rect.height * (.25 + Math.random() * .5),
111                { speed: 90 + Math.random() * 260, life: .5 + Math.random() * .7 }
112            );
113        }
114
115        return true;
116    }
117
118    #Spark(x, y, { speed = 40 + Math.random() * 190, life = .35 + Math.random() * .55 } = {})
119    {
120        if (this.#sparks.length >= MAX) return null;
121
122        const angle = (Math.random() * Math.PI * 1.5) - Math.PI * .25;
123
124        const molten = Math.random() < .025;
125
126        const spark = {
127            x,
128            y,
129            px: x,
130            py: y,
131            vx: Math.cos(angle) * speed * (molten ? .45 : 1),
132            vy: Math.sin(angle) * speed * (molten ? .45 : 1) - 60,
133            life: molten ? life * 1.8 : life,
134            age: 0,
135
136            gravity: molten ? 620 + Math.random() * 200 : 720 + Math.random() * 420,
137            drag: 1.5 + Math.random() * 1.9,
138
139            heat: Math.random() * .22,
140
141            flickerRate: 18 + Math.random() * 30,
142            flickerAt: Math.random() * Math.PI * 2,
143
144            splits: molten ? 2 + Math.floor(Math.random() * 3) : (Math.random() < .16 ? 1 : 0),
145            width: molten ? 2.2 + Math.random() : .7 + Math.random() * 1.3,
146
147            bounces: Math.random() < .25 ? 2 : 1
148        };
149
150        this.#sparks.push(spark);
151
152        return spark;
153    }
154
155    Flick()
156    {
157        if (Still() || !this.live()) return false;
158
159        const box = this.#Wake();
160
161        if (!box) return false;
162
163        const rect = this.element.getBoundingClientRect();
164        const at = [ rect.left - box.left + Math.random() * rect.width, rect.top - box.top + rect.height * (.3 + Math.random() * .4) ];
165
166        for (let n = 0; n < 2 + Math.floor(Math.random() * 2); n++)
167        {
168            this.#Spark(at[0] + (Math.random() - .5) * 8, at[1], { speed: 30 + Math.random() * 120 });
169        }
170
171        return true;
172    }
173
174    #Wait()
175    {
176        const gap = 7000 + Math.random() * 19000;
177
178        setTimeout(() =>
179        {
180
181            const visible = document.visibilityState === "visible"
182                && this.element?.offsetParent !== null
183                && this.element?.getBoundingClientRect().width > 0;
184
185            if (visible) this.Flick();
186
187            this.#Wait();
188        }, gap);
189
190        return true;
191    }
192
193    #Wake()
194    {
195        const rect = this.element.getBoundingClientRect();
196
197        if (!rect.width) return null;
198
199        if (!this.#canvas)
200        {
201            const canvas = document.createElement("canvas");
202
203            canvas.className = "spark-trail";
204
205            canvas.style.cssText = `position:fixed;pointer-events:none;z-index:${this.zIndex}`;
206            (this.host ?? document.body).appendChild(canvas);
207
208            this.#canvas = canvas;
209            this.#context = canvas.getContext("2d");
210        }
211
212        this.#Place(rect);
213
214        if (this.#frame === null)
215        {
216            this.#last = performance.now();
217            this.#frame = requestAnimationFrame(now => this.#Tick(now));
218        }
219
220        return this.#box;
221    }
222
223    #Place(rect)
224    {
225
226        const landing = this.floor();
227        const top = rect.top - ABOVE;
228        const bottom = Number.isFinite(landing)
229            ? Math.max(landing + 26, rect.bottom + 40)
230            : rect.bottom + BELOW;
231
232        this.#floorAt = Number.isFinite(landing) ? landing - top : null;
233
234        const box = {
235            left: rect.left - SIDES,
236            top,
237            width: rect.width + SIDES * 2,
238            height: bottom - top
239        };
240
241        if (!this.#box || this.#box.width !== box.width || this.#box.height !== box.height)
242        {
243            const scale = Math.min(globalThis.devicePixelRatio || 1, 2);
244
245            this.#canvas.width = Math.ceil(box.width * scale);
246            this.#canvas.height = Math.ceil(box.height * scale);
247            this.#canvas.style.width = `${box.width}px`;
248            this.#canvas.style.height = `${box.height}px`;
249            this.#context.setTransform(scale, 0, 0, scale, 0, 0);
250        }
251
252        if (this.#box)
253        {
254            const dx = this.#box.left - box.left;
255            const dy = this.#box.top - box.top;
256
257            if (dx || dy)
258            {
259                for (const spark of this.#sparks)
260                {
261                    spark.x += dx;
262                    spark.px += dx;
263                    spark.y += dy;
264                    spark.py += dy;
265                }
266            }
267        }
268
269        this.#canvas.style.left = `${box.left}px`;
270        this.#canvas.style.top = `${box.top}px`;
271        this.#box = box;
272
273        return box;
274    }
275
276    #Tick(now)
277    {
278
279        const dt = Math.min((now - this.#last) / 1000, .05);
280
281        this.#last = now;
282        this.#frame = null;
283
284        const rect = this.element.getBoundingClientRect();
285
286        if (rect.width) this.#Place(rect);
287
288        const context = this.#context;
289        const alive = [];
290
291        context.clearRect(0, 0, this.#box.width, this.#box.height);
292
293        context.globalCompositeOperation = "lighter";
294        context.lineCap = "round";
295
296        for (const spark of this.#sparks)
297        {
298            spark.age += dt;
299
300            const age = spark.age / spark.life;
301
302            if (age >= 1) continue;
303
304            spark.px = spark.x;
305            spark.py = spark.y;
306
307            spark.vy += spark.gravity * dt;
308            spark.vx *= 1 - spark.drag * dt;
309            spark.vy *= 1 - .6 * dt;
310            spark.x += spark.vx * dt;
311            spark.y += spark.vy * dt;
312
313            if (spark.bounces && this.#floorAt !== null && spark.y >= this.#floorAt)
314            {
315                spark.y = this.#floorAt;
316                spark.vy = -Math.abs(spark.vy) * (.18 + Math.random() * .22);
317                spark.vx *= .45 + Math.random() * .3;
318                spark.bounces -= 1;
319
320                spark.life = Math.min(spark.life, spark.age + .12 + Math.random() * .16);
321
322                if (Math.abs(spark.vy) > 24)
323                {
324                    for (let n = 0; n < 1 + Math.floor(Math.random() * 2); n++)
325                    {
326                        const chip = this.#Spark(spark.x, spark.y - 1, {
327                            speed: 20 + Math.random() * 60,
328                            life: .1 + Math.random() * .16
329                        });
330
331                        if (chip) chip.vy = -Math.abs(chip.vy) * .35;
332                    }
333                }
334            }
335
336            if (spark.splits && age > .35 + Math.random() * .35)
337            {
338                const pieces = spark.splits;
339
340                spark.splits = 0;
341
342                for (let n = 0; n < pieces + 1; n++)
343                {
344                    this.#Spark(spark.x, spark.y, { speed: 25 + Math.random() * 90, life: .15 + Math.random() * .28 });
345                }
346            }
347
348            const [ r, g, b ] = Heat(Math.min(age + spark.heat, 1));
349
350            const flicker = .82 + .18 * Math.sin(spark.flickerAt + spark.age * spark.flickerRate);
351
352            context.beginPath();
353            context.moveTo(spark.px, spark.py);
354            context.lineTo(spark.x, spark.y);
355
356            if (age < .5)
357            {
358                context.strokeStyle = `rgba(${r}, ${g}, ${b}, ${.22 * flicker * (1 - age * 2)})`;
359                context.lineWidth = spark.width * 4;
360                context.stroke();
361            }
362
363            context.strokeStyle = `rgba(${r}, ${g}, ${b}, ${flicker * (1 - age) ** .5})`;
364            context.lineWidth = spark.width * 1.25 * (1 - age * .45);
365            context.stroke();
366
367            alive.push(spark);
368        }
369
370        this.#sparks = alive;
371
372        if (alive.length)
373        {
374            this.#frame = requestAnimationFrame(next => this.#Tick(next));
375
376            return true;
377        }
378
379        this.#canvas.remove();
380        this.#canvas = null;
381        this.#context = null;
382        this.#box = null;
383
384        return false;
385    }
386}
387
388export default SparkTrail;

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.