This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pages / profile-fx / mihaizaurus.at.js
25 kB 674 lines
1// Intersex-Inclusive Progress Pride palette, arranged as left-to-right bands. 2const prideColors = [ 3 "#E22016", "#F28917", "#F5E524", "#7BB82A", "#2C5B84", "#6D2380", 4 "#000000", "#945516", "#7BCCE5", "#F4AEC8", "#FFFFFF", "#FFD817", 5]; 6const particleShapes = ["triangle", "diamond", "dot", "hexagon"]; 7const shapePaths = { 8 triangle: "polygon(50% 0, 100% 100%, 0 100%)", 9 diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)", 10 hexagon: "polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%)", 11}; 12 13const wideScreen = matchMedia("(min-width: 768px)"); 14const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); 15let currentGrid = null; 16let stop = null; 17 18function animate(grid) { 19 const dots = Array.from(grid.children, (cell) => cell.firstElementChild).filter(Boolean); 20 if (!dots.length) return () => {}; 21 22 const originalStyles = dots.map((dot) => dot.getAttribute("style")); 23 const originalGridStyle = grid.getAttribute("style"); 24 const maxRadius = 5; 25 const accelerationX = new Float32Array(dots.length); 26 const accelerationY = new Float32Array(dots.length); 27 const effects = new Map(); 28 const fireworkTimers = new Set(); 29 const devourTimers = new Set(); 30 let audioContext = null; 31 const collisionNotes = [587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, 32 587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, 33 0, 494, 554, 587, 587, 659, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, 34 440, 880, 0, 880, 659, 0, 494, 494, 554, 587, 494, 587, 659, 0, 0, 554, 35 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, 659, 659, 659, 740, 659, 0, 36 587, 659, 740, 587, 659, 659, 659, 740, 659, 440, 0, 494, 554, 587, 494, 0, 37 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, 38 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 440, 659, 587, 39 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 40 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 0, 0, 494, 587, 41 494, 587, 659, 0, 0, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, 42 0, 880, 880, 659, 740, 659, 587, 0, 440, 494, 554, 587, 494, 0, 554, 494, 43 440, 0, 494, 494, 554, 587, 494, 440, 0, 0, 659, 659, 740, 659, 587, 587, 44 659, 740, 659, 659, 659, 740, 659, 440, 440, 0, 440, 494, 554, 587, 494, 0, 45 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, 46 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, 47 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, 48 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, 49 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, 50 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, 740, 659, 440, 494, 51 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 52 440, 659, 587, 0]; 53 let collisionNoteIndex = 0; 54 let particles = []; 55 let aliveCount = dots.length; 56 let fireworksShown = false; 57 let cols = 0; 58 let halfWidth = 1; 59 let halfHeight = 1; 60 let frame = 0; 61 let resizeFrame = 0; 62 let lastTime = 0; 63 let visible = true; 64 let mouse = { x: 0, y: 0, active: false }; 65 let singularity = null; 66 67 Object.assign(grid.style, { 68 backgroundColor: "rgba(7, 8, 24, 0.2)", 69 backgroundImage: "radial-gradient(ellipse at center, rgba(109, 35, 128, 0.34), transparent 68%)", 70 backgroundRepeat: "no-repeat", 71 boxShadow: "inset 0 0 20px rgba(109, 35, 128, 0.25)", 72 cursor: "none", 73 }); 74 75 const blackHole = document.createElement("div"); 76 const makeEye = () => { 77 const eye = document.createElement("div"); 78 const pupil = document.createElement("div"); 79 Object.assign(eye.style, { 80 position: "absolute", 81 top: "7px", 82 width: "10px", 83 height: "10px", 84 borderRadius: "50%", 85 background: "white", 86 overflow: "hidden", 87 }); 88 Object.assign(pupil.style, { 89 position: "absolute", 90 left: "3px", 91 top: "3px", 92 width: "5px", 93 height: "5px", 94 borderRadius: "50%", 95 background: "#111", 96 }); 97 eye.append(pupil); 98 return { eye, pupil }; 99 }; 100 const leftEye = makeEye(); 101 const rightEye = makeEye(); 102 const smile = document.createElement("div"); 103 leftEye.eye.style.left = "5px"; 104 rightEye.eye.style.right = "5px"; 105 Object.assign(smile.style, { 106 position: "absolute", 107 left: "8px", 108 top: "19px", 109 width: "16px", 110 height: "6px", 111 borderBottom: "2px solid white", 112 borderRadius: "0 0 60% 60%", 113 transform: "rotate(9deg)", 114 }); 115 Object.assign(blackHole.style, { 116 position: "fixed", 117 width: "32px", 118 height: "32px", 119 borderRadius: "50%", 120 background: "radial-gradient(circle at 42% 38%, #242424 0 9%, #050505 34%, #000 70%)", 121 boxShadow: "0 0 10px rgba(109, 35, 128, 0.55)", 122 pointerEvents: "none", 123 display: "none", 124 zIndex: "10002", 125 }); 126 blackHole.dataset.profileFx = "black-hole"; 127 blackHole.append(leftEye.eye, rightEye.eye, smile); 128 document.body.append(blackHole); 129 130 const paintBlackHole = (time) => { 131 const location = singularity || mouse; 132 if (!singularity && !mouse.active) { 133 blackHole.style.display = "none"; 134 return; 135 } 136 const wobbleX = Math.sin(time / 90) * 1.4; 137 const wobbleY = Math.cos(time / 120) * 1.2; 138 blackHole.style.display = "block"; 139 blackHole.style.left = `${location.screenX}px`; 140 blackHole.style.top = `${location.screenY}px`; 141 blackHole.style.transform = `translate(-50%, -50%) rotate(${(Math.sin(time / 280) * 4).toFixed(1)}deg)`; 142 leftEye.pupil.style.transform = `translate(${wobbleX.toFixed(1)}px, ${wobbleY.toFixed(1)}px)`; 143 rightEye.pupil.style.transform = `translate(${(-wobbleX * 0.7).toFixed(1)}px, ${(wobbleY * 0.8).toFixed(1)}px)`; 144 }; 145 146 const getAudioContext = () => (audioContext?.state === "running" ? audioContext : null); 147 148 const playTone = (frequency, duration, delay, volume) => { 149 const context = getAudioContext(); 150 if (!context) return; 151 const start = context.currentTime + delay; 152 const oscillator = context.createOscillator(); 153 const gain = context.createGain(); 154 oscillator.type = "sine"; 155 oscillator.frequency.setValueAtTime(frequency, start); 156 gain.gain.setValueAtTime(0.0001, start); 157 gain.gain.exponentialRampToValueAtTime(volume, start + 0.015); 158 gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); 159 oscillator.connect(gain).connect(context.destination); 160 oscillator.start(start); 161 oscillator.stop(start + duration + 0.02); 162 }; 163 164 const playPop = () => { 165 const frequency = collisionNotes[collisionNoteIndex]; 166 collisionNoteIndex = (collisionNoteIndex + 1) % collisionNotes.length; 167 // Zero or a negative value is a rest; advance the sequence without a tone. 168 if (!Number.isFinite(frequency) || frequency <= 0) return; 169 const context = getAudioContext(); 170 if (!context) return; 171 const start = context.currentTime; 172 const oscillator = context.createOscillator(); 173 const gain = context.createGain(); 174 oscillator.type = "sine"; 175 oscillator.frequency.setValueAtTime(frequency, start); 176 oscillator.frequency.exponentialRampToValueAtTime(frequency * 0.82, start + 0.12); 177 gain.gain.setValueAtTime(0.0001, start); 178 gain.gain.exponentialRampToValueAtTime(0.05, start + 0.01); 179 gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.14); 180 oscillator.connect(gain).connect(context.destination); 181 oscillator.start(start); 182 oscillator.stop(start + 0.16); 183 }; 184 185 const playTada = () => { 186 playTone(523.25, 0.24, 0, 0.045); 187 playTone(659.25, 0.24, 0.13, 0.045); 188 playTone(783.99, 0.55, 0.26, 0.06); 189 }; 190 191 const unlockAudio = () => { 192 if (!audioContext) { 193 const AudioContext = window.AudioContext || window.webkitAudioContext; 194 if (!AudioContext) return; 195 audioContext = new AudioContext(); 196 } 197 if (audioContext.state === "suspended") audioContext.resume().catch(() => {}); 198 }; 199 200 const moveMouse = (event) => { 201 if (singularity) return; 202 const gridRect = grid.getBoundingClientRect(); 203 mouse = { 204 x: event.clientX - gridRect.left - halfWidth, 205 y: event.clientY - gridRect.top - halfHeight, 206 screenX: event.clientX, 207 screenY: event.clientY, 208 active: true, 209 }; 210 paintBlackHole(performance.now()); 211 }; 212 213 const leaveMouse = () => { 214 if (!singularity) mouse = { ...mouse, active: false }; 215 paintBlackHole(performance.now()); 216 }; 217 218 const initialRadius = (dot) => { 219 if (dot.classList.contains("size-[4px]")) return 1; 220 if (dot.classList.contains("size-[7px]")) return 3.5; 221 if (dot.classList.contains("size-[6px]")) return 3; 222 return 2.5; 223 }; 224 225 const paintParticle = (particle) => { 226 const dot = dots[particle.index]; 227 const diameter = (particle.baseRadius * 2).toFixed(2); 228 const path = shapePaths[particle.shape]; 229 const color = dot.style.backgroundColor || prideColors[particle.colorIndex]; 230 const glow = (1.5 + Math.min(particle.radius, maxRadius) * 0.8).toFixed(1); 231 dot.style.width = `${diameter}px`; 232 dot.style.height = `${diameter}px`; 233 dot.style.minWidth = `${diameter}px`; 234 dot.style.minHeight = `${diameter}px`; 235 dot.style.maxWidth = "none"; 236 dot.style.maxHeight = "none"; 237 dot.style.aspectRatio = "1 / 1"; 238 dot.style.borderRadius = particle.shape === "dot" ? "50%" : "0"; 239 dot.style.clipPath = path || "none"; 240 dot.style.webkitClipPath = path || "none"; 241 dot.style.filter = `saturate(1.45) brightness(1.18) drop-shadow(0 0 ${glow}px ${color})`; 242 dot.style.flexShrink = "0"; 243 dot.style.opacity = particle.radius < 1.5 ? "0.45" : "1"; 244 dot.style.visibility = particle.alive ? "visible" : "hidden"; 245 }; 246 247 const showBlast = (particle) => { 248 const gridRect = grid.getBoundingClientRect(); 249 const color = dots[particle.index].style.backgroundColor; 250 const blast = document.createElement("div"); 251 const size = Math.max(8, particle.radius * 4); 252 253 Object.assign(blast.style, { 254 position: "fixed", 255 left: `${gridRect.left + gridRect.width / 2 + particle.x}px`, 256 top: `${gridRect.top + gridRect.height / 2 + particle.y}px`, 257 width: `${size}px`, 258 height: `${size}px`, 259 border: `1px solid ${color}`, 260 borderRadius: "9999px", 261 boxShadow: `0 0 8px ${color}`, 262 pointerEvents: "none", 263 transform: "translate(-50%, -50%) scale(0.3)", 264 transition: "transform 280ms ease-out, opacity 280ms ease-out", 265 opacity: "1", 266 zIndex: "9999", 267 }); 268 269 document.body.append(blast); 270 requestAnimationFrame(() => { 271 blast.style.transform = "translate(-50%, -50%) scale(2.8)"; 272 blast.style.opacity = "0"; 273 }); 274 const timer = setTimeout(() => { 275 effects.delete(blast); 276 blast.remove(); 277 }, 300); 278 effects.set(blast, timer); 279 }; 280 281 const showCongrats = (left, top) => { 282 const message = document.createElement("div"); 283 message.dataset.profileFx = "congrats"; 284 message.textContent = "Congrats!"; 285 Object.assign(message.style, { 286 position: "fixed", 287 left: `${left}px`, 288 top: `${top - 28}px`, 289 color: "white", 290 fontFamily: "ui-rounded, system-ui, sans-serif", 291 fontSize: "clamp(22px, 4vw, 38px)", 292 fontWeight: "800", 293 letterSpacing: "0.04em", 294 pointerEvents: "none", 295 textShadow: "0 0 8px #7BCCE5, 0 0 18px #6D2380", 296 transform: "translate(-50%, -50%) scale(0.5)", 297 transition: "transform 500ms cubic-bezier(.15,.8,.25,1), opacity 600ms ease-out", 298 opacity: "0", 299 zIndex: "10000", 300 }); 301 document.body.append(message); 302 requestAnimationFrame(() => { 303 message.style.transform = "translate(-50%, -50%) scale(1)"; 304 message.style.opacity = "1"; 305 }); 306 effects.set(message, []); 307 }; 308 309 const devourPage = (target) => { 310 const letters = []; 311 const textNodes = []; 312 const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { 313 acceptNode(node) { 314 const parent = node.parentElement; 315 if (!parent || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; 316 if (parent.closest("[data-punchcard], [data-profile-fx], script, style, noscript, textarea, select, option")) { 317 return NodeFilter.FILTER_REJECT; 318 } 319 return NodeFilter.FILTER_ACCEPT; 320 }, 321 }); 322 323 while (walker.nextNode()) { 324 const node = walker.currentNode; 325 const parent = node.parentElement; 326 const text = node.nodeValue; 327 const style = getComputedStyle(parent); 328 const start = letters.length; 329 330 for (let index = 0; index < text.length; index += 1) { 331 if (/\s/.test(text[index])) continue; 332 const range = document.createRange(); 333 range.setStart(node, index); 334 range.setEnd(node, index + 1); 335 const rect = range.getBoundingClientRect(); 336 if (!rect.width && !rect.height) continue; 337 letters.push({ 338 character: text[index], 339 left: rect.left, 340 top: rect.top, 341 width: rect.width, 342 height: rect.height, 343 font: style.font, 344 color: style.color, 345 order: letters.length, 346 }); 347 } 348 if (letters.length > start) textNodes.push({ node, delay: start * 18 }); 349 } 350 351 textNodes.forEach(({ node, delay }) => { 352 const timer = setTimeout(() => { 353 devourTimers.delete(timer); 354 node.nodeValue = ""; 355 }, delay); 356 devourTimers.add(timer); 357 }); 358 359 letters.forEach((letter) => { 360 const glyph = document.createElement("span"); 361 const delay = letter.order * 18; 362 glyph.dataset.profileFx = "devoured-letter"; 363 glyph.textContent = letter.character; 364 Object.assign(glyph.style, { 365 position: "fixed", 366 left: `${letter.left}px`, 367 top: `${letter.top}px`, 368 width: `${Math.max(1, letter.width)}px`, 369 height: `${Math.max(1, letter.height)}px`, 370 color: letter.color, 371 font: letter.font, 372 lineHeight: `${Math.max(1, letter.height)}px`, 373 pointerEvents: "none", 374 transformOrigin: "center", 375 transition: "transform 1.5s cubic-bezier(.1,.8,.2,1), opacity 1.5s ease-in", 376 zIndex: "10001", 377 }); 378 document.body.append(glyph); 379 380 const startTimer = setTimeout(() => { 381 devourTimers.delete(startTimer); 382 const dx = target.screenX - (letter.left + letter.width / 2); 383 const dy = target.screenY - (letter.top + letter.height / 2); 384 glyph.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) scale(0.08) rotate(${(Math.random() * 720 - 360).toFixed(0)}deg)`; 385 glyph.style.opacity = "0"; 386 }, delay + 80); 387 const removeTimer = setTimeout(() => { 388 devourTimers.delete(removeTimer); 389 effects.delete(glyph); 390 glyph.remove(); 391 }, delay + 1650); 392 devourTimers.add(startTimer); 393 devourTimers.add(removeTimer); 394 effects.set(glyph, [startTimer, removeTimer]); 395 }); 396 }; 397 398 const becomeSingularity = (particle) => { 399 const gridRect = grid.getBoundingClientRect(); 400 singularity = { 401 screenX: gridRect.left + gridRect.width / 2 + particle.x, 402 screenY: gridRect.top + gridRect.height / 2 + particle.y, 403 }; 404 mouse = { ...mouse, active: false }; 405 particle.vx = 0; 406 particle.vy = 0; 407 dots[particle.index].style.visibility = "hidden"; 408 paintBlackHole(performance.now()); 409 devourPage(singularity); 410 }; 411 412 const showFireworks = (particle) => { 413 const gridRect = grid.getBoundingClientRect(); 414 const left = gridRect.left + gridRect.width / 2 + particle.x; 415 const top = gridRect.top + gridRect.height / 2 + particle.y; 416 417 const launchWave = (wave) => { 418 const sparks = wave === 0 ? 28 : 18; 419 for (let index = 0; index < sparks; index += 1) { 420 const spark = document.createElement("div"); 421 const angle = (Math.PI * 2 * index) / sparks + (Math.random() - 0.5) * 0.25; 422 const distance = 38 + Math.random() * 85; 423 const color = prideColors[(index + wave * 3) % prideColors.length]; 424 Object.assign(spark.style, { 425 position: "fixed", 426 left: `${left}px`, 427 top: `${top}px`, 428 width: "4px", 429 height: "4px", 430 backgroundColor: color, 431 borderRadius: "50%", 432 boxShadow: `0 0 6px ${color}`, 433 pointerEvents: "none", 434 transform: "translate(-50%, -50%)", 435 transition: "transform 1.1s cubic-bezier(.15,.8,.25,1)", 436 zIndex: "9999", 437 }); 438 document.body.append(spark); 439 requestAnimationFrame(() => { 440 spark.style.transform = `translate(-50%, -50%) translate(${(Math.cos(angle) * distance).toFixed(1)}px, ${(Math.sin(angle) * distance).toFixed(1)}px)`; 441 }); 442 effects.set(spark, []); 443 } 444 }; 445 446 playTada(); 447 showCongrats(gridRect.left + gridRect.width / 2, gridRect.top + gridRect.height / 2); 448 for (let wave = 0; wave < 7; wave += 1) { 449 const timer = setTimeout(() => { 450 fireworkTimers.delete(timer); 451 launchWave(wave); 452 }, wave * 800); 453 fireworkTimers.add(timer); 454 } 455 }; 456 457 const reset = () => { 458 if (singularity) return; 459 // Set every base size before measuring. Absorption only uses transform scale, 460 // so changing a shape later cannot shift the grid beneath the physics mesh. 461 const seeds = dots.map((dot, index) => { 462 const radius = initialRadius(dot); 463 return { 464 index, 465 baseRadius: radius, 466 radius, 467 mass: (radius * radius) / 3, 468 colorIndex: Math.floor(((index % cols) * prideColors.length) / cols), 469 shape: particleShapes[Math.floor(Math.random() * particleShapes.length)], 470 alive: true, 471 }; 472 }); 473 seeds.forEach(paintParticle); 474 475 const gridRect = grid.getBoundingClientRect(); 476 halfWidth = Math.max(1, gridRect.width / 2); 477 halfHeight = Math.max(1, gridRect.height / 2); 478 particles = seeds.map((particle) => { 479 const cellRect = dots[particle.index].parentElement.getBoundingClientRect(); 480 const originX = cellRect.left - gridRect.left + cellRect.width / 2 - halfWidth; 481 const originY = cellRect.top - gridRect.top + cellRect.height / 2 - halfHeight; 482 const spin = 0.25 + (Math.random() - 0.5) * 0.08; 483 return { 484 ...particle, 485 originX, 486 originY, 487 x: originX, 488 y: originY, 489 vx: -originY * spin, 490 vy: originX * spin, 491 }; 492 }); 493 aliveCount = particles.length; 494 fireworksShown = false; 495 lastTime = 0; 496 }; 497 498 const layout = () => { 499 cols = wideScreen.matches ? 14 : 28; 500 dots.forEach((dot, index) => { 501 const column = index % cols; 502 dot.style.backgroundColor = prideColors[Math.floor((column * prideColors.length) / cols)]; 503 dot.style.transition = "none"; 504 dot.style.transformOrigin = "center"; 505 dot.style.willChange = "transform"; 506 }); 507 reset(); 508 }; 509 510 const absorb = (winner, loser) => { 511 const totalMass = winner.mass + loser.mass; 512 winner.x = (winner.x * winner.mass + loser.x * loser.mass) / totalMass; 513 winner.y = (winner.y * winner.mass + loser.y * loser.mass) / totalMass; 514 winner.vx = (winner.vx * winner.mass + loser.vx * loser.mass) / totalMass; 515 winner.vy = (winner.vy * winner.mass + loser.vy * loser.mass) / totalMass; 516 winner.radius = Math.min(maxRadius, Math.hypot(winner.radius, loser.radius)); 517 winner.mass = (winner.radius * winner.radius) / 3; 518 winner.colorIndex = (winner.colorIndex + loser.colorIndex + 1 + Math.floor(Math.random() * 3)) % prideColors.length; 519 winner.shape = particleShapes[Math.floor(Math.random() * particleShapes.length)]; 520 dots[winner.index].style.backgroundColor = prideColors[winner.colorIndex]; 521 loser.alive = false; 522 aliveCount -= 1; 523 paintParticle(winner); 524 paintParticle(loser); 525 showBlast(winner); 526 playPop(); 527 if (aliveCount === 1 && !fireworksShown) { 528 fireworksShown = true; 529 becomeSingularity(winner); 530 showFireworks(winner); 531 } 532 }; 533 534 const tick = (time) => { 535 frame = 0; 536 if (!visible) return; 537 538 const seconds = lastTime ? Math.min((time - lastTime) / 1000, 1 / 30) : 0; 539 lastTime = time; 540 paintBlackHole(time); 541 accelerationX.fill(0); 542 accelerationY.fill(0); 543 const attractionBoost = aliveCount < 5 ? 1 + ((5 - aliveCount) / 4) * 3 : 1; 544 545 for (let i = 0; i < particles.length; i += 1) { 546 const a = particles[i]; 547 if (!a.alive) continue; 548 549 if (mouse.active) { 550 const dx = mouse.x - a.x; 551 const dy = mouse.y - a.y; 552 const distanceSquared = dx * dx + dy * dy + 576; 553 const pull = 200000 / (distanceSquared * Math.sqrt(distanceSquared)); 554 accelerationX[i] += dx * pull; 555 accelerationY[i] += dy * pull; 556 } 557 558 for (let j = i + 1; j < particles.length; j += 1) { 559 const b = particles[j]; 560 if (!b.alive) continue; 561 562 const dx = b.x - a.x; 563 const dy = b.y - a.y; 564 const collisionDistance = a.radius + b.radius; 565 if (dx * dx + dy * dy < collisionDistance * collisionDistance) { 566 const winner = a.radius >= b.radius ? a : b; 567 const loser = winner === a ? b : a; 568 absorb(winner, loser); 569 if (loser === a) break; 570 continue; 571 } 572 573 // Every dot pulls on every other dot; larger combined dots have more mass. 574 const distanceSquared = dx * dx + dy * dy + 324; 575 const pull = (560 * attractionBoost) / (distanceSquared * Math.sqrt(distanceSquared)); 576 accelerationX[i] += dx * pull * b.mass; 577 accelerationY[i] += dy * pull * b.mass; 578 accelerationX[j] -= dx * pull * a.mass; 579 accelerationY[j] -= dy * pull * a.mass; 580 } 581 } 582 583 particles.forEach((particle, index) => { 584 if (!particle.alive) return; 585 586 const overflow = Math.max( 587 0, 588 (Math.abs(particle.x) - halfWidth) / halfWidth, 589 (Math.abs(particle.y) - halfHeight) / halfHeight, 590 ); 591 if (overflow > 0) { 592 // The farther it escapes the punchcard, the stronger its pull back to centre. 593 const pullBack = 0.12 * overflow + 0.9 * overflow * overflow; 594 particle.vx -= particle.x * pullBack * seconds; 595 particle.vy -= particle.y * pullBack * seconds; 596 } 597 598 particle.vx = (particle.vx + accelerationX[index] * seconds) * 0.995; 599 particle.vy = (particle.vy + accelerationY[index] * seconds) * 0.995; 600 particle.x += particle.vx * seconds; 601 particle.y += particle.vy * seconds; 602 const scale = particle.radius / particle.baseRadius; 603 dots[index].style.transform = `translate(${(particle.x - particle.originX).toFixed(2)}px, ${(particle.y - particle.originY).toFixed(2)}px) scale(${scale.toFixed(3)})`; 604 }); 605 606 frame = requestAnimationFrame(tick); 607 }; 608 609 const resize = () => { 610 if (resizeFrame) return; 611 resizeFrame = requestAnimationFrame(() => { 612 resizeFrame = 0; 613 reset(); 614 }); 615 }; 616 617 layout(); 618 wideScreen.addEventListener("change", layout); 619 window.addEventListener("resize", resize); 620 grid.addEventListener("pointerdown", unlockAudio); 621 grid.addEventListener("pointermove", moveMouse, { passive: true }); 622 grid.addEventListener("pointerleave", leaveMouse); 623 624 const observer = new IntersectionObserver((entries) => { 625 visible = entries.some((entry) => entry.isIntersecting); 626 if (visible && !frame) frame = requestAnimationFrame(tick); 627 else if (!visible && frame) { 628 cancelAnimationFrame(frame); 629 frame = 0; 630 } 631 }); 632 observer.observe(grid); 633 frame = requestAnimationFrame(tick); 634 635 return () => { 636 cancelAnimationFrame(frame); 637 observer.disconnect(); 638 cancelAnimationFrame(resizeFrame); 639 wideScreen.removeEventListener("change", layout); 640 window.removeEventListener("resize", resize); 641 grid.removeEventListener("pointerdown", unlockAudio); 642 grid.removeEventListener("pointermove", moveMouse); 643 grid.removeEventListener("pointerleave", leaveMouse); 644 blackHole.remove(); 645 fireworkTimers.forEach((timer) => clearTimeout(timer)); 646 devourTimers.forEach((timer) => clearTimeout(timer)); 647 effects.forEach((timers, effect) => { 648 (Array.isArray(timers) ? timers : [timers]).forEach((timer) => clearTimeout(timer)); 649 effect.remove(); 650 }); 651 if (originalGridStyle === null) grid.removeAttribute("style"); 652 else grid.setAttribute("style", originalGridStyle); 653 dots.forEach((dot, index) => { 654 if (originalStyles[index] === null) dot.removeAttribute("style"); 655 else dot.setAttribute("style", originalStyles[index]); 656 }); 657 }; 658} 659 660function mount() { 661 const grid = document.querySelector("[data-punchcard]"); 662 if (grid === currentGrid && stop && !reducedMotion.matches) return; 663 664 if (stop) stop(); 665 stop = null; 666 currentGrid = grid; 667 668 if (!grid || reducedMotion.matches) return; 669 stop = animate(grid); 670} 671 672mount(); 673document.addEventListener("htmx:load", mount); 674reducedMotion.addEventListener("change", mount);