This repository has no description
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);