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.