This repository has no description
19 kB
565 lines
1const punchcard = document.querySelector("[data-punchcard]");
2
3if (punchcard) {
4 const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
5 const wideScreen = matchMedia("(min-width: 768px)");
6
7 const purple = "#b57edc";
8 const white = "#fff";
9 const idleBackground = `linear-gradient(90deg, ${purple} 0 50%, ${white} 50% 100%)`;
10
11 const activityCache = new WeakMap();
12
13 let dots = [];
14 let frame = 0;
15 let hovered = false;
16 let pointerX = 0.5;
17 let pointerY = 0.5;
18 let coinX = NaN;
19 let coinY = NaN;
20 let velocityX = 0;
21 let velocityY = 0;
22 let coinMix = 0;
23 let evaporateStart = null;
24 let evaporateFrom = 0;
25 let clickSpinStart = -Infinity;
26 let clickSpinDirection = 1;
27 let coalesceStart = -Infinity;
28 let explosionStart = -Infinity;
29 let explosionPower = 1;
30 let explosionX = 0;
31 let explosionY = 0;
32 let joltStart = -Infinity;
33 let joltX = 0;
34 let joltY = 0;
35 let joltDirection = 1;
36 let suppressGatherUntil = 0;
37 let waitForReenterAfterExplosion = false;
38 let lastTime = 0;
39 let layout = { cols: 28, rows: 1 };
40
41 function columnCount() {
42 return wideScreen.matches ? 14 : 28;
43 }
44
45 function clamp(value, min, max) {
46 return Math.min(max, Math.max(min, value));
47 }
48
49 function lerp(a, b, t) {
50 return a + (b - a) * t;
51 }
52
53 function ease(t) {
54 return t * t * (3 - 2 * t);
55 }
56
57 function smoothstep(edge0, edge1, value) {
58 const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
59 return t * t * (3 - 2 * t);
60 }
61
62 function ring(distance, front, width, energy = 1) {
63 return (1 - smoothstep(0, width, Math.abs(distance - front))) * energy;
64 }
65
66 function heldSpin(raw, hold) {
67 return raw - Math.sin(raw * 2) * hold * 0.5;
68 }
69
70 function occasionalSpin(now, activity, phase) {
71 const active = 0.16;
72 const period = lerp(9800, 5600, activity);
73 const cycle = ((now + phase * 1400) % period) / period;
74
75 if (cycle >= active) return Math.PI * 2;
76
77 return ease(cycle / active) * Math.PI * 2;
78 }
79
80 function numberFromLabel(text) {
81 const match = text?.match(
82 /(\d+(?:\.\d+)?)\s+(?:commit|commits|contribution|contributions|change|changes)/i,
83 );
84
85 return match ? Number(match[1]) : null;
86 }
87
88 function explicitActivity(dot, wrapper) {
89 for (const element of [dot, wrapper]) {
90 for (const key of ["count", "commits", "contributions", "value", "level", "intensity"]) {
91 const value = element.dataset?.[key];
92 if (value !== undefined && value !== "" && !Number.isNaN(Number(value))) {
93 return Number(value);
94 }
95 }
96
97 const label =
98 `${element.getAttribute("aria-label") || ""} ${element.getAttribute("title") || ""}`;
99 const labelValue = numberFromLabel(label);
100 if (labelValue !== null) return labelValue;
101
102 const className = typeof element.className === "string" ? element.className : "";
103 const classValue = className.match(/(?:level|count|activity|intensity)-?(\d+)/i);
104 if (classValue) return Number(classValue[1]);
105 }
106
107 return null;
108 }
109
110 function colorActivity(color) {
111 const match = color.match(/rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\)/i);
112 if (!match) return 0;
113
114 const r = Number(match[1]);
115 const g = Number(match[2]);
116 const b = Number(match[3]);
117 const a = match[4] === undefined ? 1 : Number(match[4]);
118 if (a <= 0) return 0;
119
120 const max = Math.max(r, g, b);
121 const min = Math.min(r, g, b);
122 const saturation = max === 0 ? 0 : (max - min) / max;
123 const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
124 const greenBias = clamp((g - Math.max(r, b)) / 160, 0, 1);
125
126 return clamp((saturation * 0.45 + greenBias * 0.55) * (0.55 + (1 - luminance) * 0.65), 0, 1);
127 }
128
129 function readActivity(dot, wrapper) {
130 if (activityCache.has(dot)) return activityCache.get(dot);
131
132 const signal = {
133 explicit: explicitActivity(dot, wrapper),
134 color: colorActivity(getComputedStyle(dot).backgroundColor),
135 };
136
137 activityCache.set(dot, signal);
138 return signal;
139 }
140
141 function refreshLayout() {
142 layout.cols = columnCount();
143 layout.rows = Math.ceil(dots.length / layout.cols) || 1;
144 }
145
146 function setup() {
147 const cols = columnCount();
148
149 const items = Array.from(punchcard.children)
150 .map((wrapper, index) => {
151 const dot = wrapper.firstElementChild;
152 if (!dot) return null;
153
154 return {
155 dot,
156 wrapper,
157 signal: readActivity(dot, wrapper),
158 col: index % cols,
159 row: Math.floor(index / cols),
160 phase: index * 0.43,
161 };
162 })
163 .filter(Boolean);
164
165 const maxExplicit = Math.max(0, ...items.map((item) => item.signal.explicit || 0));
166 const maxColor = Math.max(0.001, ...items.map((item) => item.signal.color || 0));
167
168 dots = items.map((item) => {
169 const activity =
170 item.signal.explicit !== null
171 ? maxExplicit > 0
172 ? Math.log1p(item.signal.explicit) / Math.log1p(maxExplicit)
173 : 0
174 : item.signal.color > 0.015
175 ? item.signal.color / maxColor
176 : 0;
177
178 item.wrapper.style.perspective = "90px";
179
180 item.dot.style.transition = "none";
181 item.dot.style.borderRadius = "50%";
182 item.dot.style.transformOrigin = "50% 50%";
183 item.dot.style.backfaceVisibility = "visible";
184 item.dot.style.willChange = "transform, opacity, background, box-shadow, filter";
185 item.dot.style.background = idleBackground;
186
187 return {
188 ...item,
189 activity: clamp(activity, 0, 1),
190 };
191 });
192
193 refreshLayout();
194
195 if (!Number.isFinite(coinX)) coinX = (layout.cols - 1) / 2;
196 if (!Number.isFinite(coinY)) coinY = (layout.rows - 1) / 2;
197 }
198
199 function radius() {
200 return Math.max(2.8, Math.min(layout.cols * 0.3, layout.rows * 0.24));
201 }
202
203 function bounds() {
204 return {
205 minX: 0,
206 maxX: layout.cols - 1,
207 minY: 0,
208 maxY: layout.rows - 1,
209 };
210 }
211
212 function setPointer(event) {
213 const rect = punchcard.getBoundingClientRect();
214
215 pointerX = clamp((event.clientX - rect.left) / rect.width, 0, 1);
216 pointerY = clamp((event.clientY - rect.top) / rect.height, 0, 1);
217 }
218
219 function evaporateCoin() {
220 evaporateStart = performance.now();
221 evaporateFrom = Math.max(coinMix, 0.28);
222
223 const speed = Math.hypot(velocityX, velocityY);
224 if (speed < 2.8) {
225 const angle = speed > 0.2 ? Math.atan2(velocityY, velocityX) : -0.72;
226 velocityX = Math.cos(angle) * 3.5;
227 velocityY = Math.sin(angle) * 3.5;
228 }
229 }
230
231 function triggerCoalesce(now = performance.now()) {
232 coalesceStart = now - 90;
233 evaporateStart = null;
234 coinMix = Math.max(coinMix, 0.78);
235 }
236
237 function triggerSpin(now = performance.now()) {
238 clickSpinStart = now;
239 clickSpinDirection *= -1;
240 evaporateStart = null;
241 coinMix = Math.max(coinMix, 0.62);
242 }
243
244 function triggerExplosion(now = performance.now(), power = 1) {
245 const { minX, maxX, minY, maxY } = bounds();
246
247 explosionStart = now;
248 explosionPower = power;
249 explosionX = Number.isFinite(coinX) ? coinX : clamp(pointerX * (layout.cols - 1), minX, maxX);
250 explosionY = Number.isFinite(coinY) ? coinY : clamp(pointerY * (layout.rows - 1), minY, maxY);
251 suppressGatherUntil = now + 1050 + power * 260;
252 waitForReenterAfterExplosion = hovered;
253 if (hovered) coalesceStart = Infinity;
254 evaporateStart = null;
255 evaporateFrom = Math.max(coinMix, 0.95);
256 coinMix = evaporateFrom;
257
258 const angle = Math.atan2(velocityY || -0.45, velocityX || 0.9);
259 velocityX = Math.cos(angle) * (4.8 + power * 0.7);
260 velocityY = Math.sin(angle) * (4.8 + power * 0.7);
261 }
262
263 function triggerJolt(now = performance.now()) {
264 const { minX, maxX, minY, maxY } = bounds();
265
266 joltStart = now;
267 joltX = clamp(pointerX * (layout.cols - 1), minX, maxX);
268 joltY = clamp(pointerY * (layout.rows - 1), minY, maxY);
269 joltDirection *= -1;
270 }
271
272 function resumeCoalesceAfterReenter(now = performance.now()) {
273 if (!waitForReenterAfterExplosion) return false;
274
275 waitForReenterAfterExplosion = false;
276 coalesceStart = Math.max(now, suppressGatherUntil);
277 if (now >= suppressGatherUntil) coinMix = Math.max(coinMix, 0.78);
278 return true;
279 }
280
281 function paintStill() {
282 for (const { dot, activity } of dots) {
283 dot.style.background = idleBackground;
284 dot.style.opacity = `${0.45 + activity * 0.55}`;
285 dot.style.transform = `scale(${0.72 + activity * 0.42})`;
286 dot.style.boxShadow = "none";
287 dot.style.filter = "none";
288 }
289 }
290
291 function animate(now) {
292 const dt = lastTime ? clamp((now - lastTime) / 1000, 0.001, 0.04) : 0.016;
293 lastTime = now;
294
295 const r = radius();
296 const { minX, maxX, minY, maxY } = bounds();
297 const targetX = clamp(pointerX * (layout.cols - 1), minX, maxX);
298 const targetY = clamp(pointerY * (layout.rows - 1), minY, maxY);
299 const explosionDuration = 1050 + explosionPower * 250;
300 const explosionT = clamp((now - explosionStart) / explosionDuration, 0, 1);
301 const exploding = now - explosionStart >= 0 && explosionT < 1;
302 const joltDuration = 620;
303 const joltT = clamp((now - joltStart) / joltDuration, 0, 1);
304 const jolting = now - joltStart >= 0 && joltT < 1;
305 const evaporateDuration = 1650;
306 const evaporateT =
307 evaporateStart !== null ? clamp((now - evaporateStart) / evaporateDuration, 0, 1) : 1;
308 const evaporating = evaporateStart !== null && evaporateT < 1;
309 const effectiveHovered =
310 hovered && !exploding && !waitForReenterAfterExplosion && now >= suppressGatherUntil;
311
312 const previousX = coinX;
313 const previousY = coinY;
314
315 if (effectiveHovered) {
316 evaporateStart = null;
317
318 const follow = 1 - Math.exp(-9.5 * dt);
319 coinX += (targetX - coinX) * follow;
320 coinY += (targetY - coinY) * follow;
321
322 velocityX = (coinX - previousX) / dt;
323 velocityY = (coinY - previousY) / dt;
324
325 coinMix += (1 - coinMix) * (1 - Math.exp(-18 * dt));
326 } else {
327 if (exploding) {
328 coinMix = evaporateFrom * (1 - smoothstep(0.06, 0.74, explosionT));
329 } else if (waitForReenterAfterExplosion) {
330 coinMix = 0;
331 evaporateStart = null;
332 } else if (evaporating) {
333 const dissolve = smoothstep(0.04, 0.96, evaporateT);
334 coinMix = evaporateFrom * (1 - dissolve);
335 if (evaporateT >= 1) evaporateStart = null;
336 } else if (evaporateStart !== null) {
337 coinMix = 0;
338 evaporateStart = null;
339 } else {
340 coinMix += (0 - coinMix) * (1 - Math.exp(-3.5 * dt));
341 }
342
343 if (coinMix > 0.01) {
344 coinX += velocityX * dt;
345 coinY += velocityY * dt;
346
347 if (coinX < minX || coinX > maxX) {
348 coinX = clamp(coinX, minX, maxX);
349 velocityX *= -0.9;
350 }
351
352 if (coinY < minY || coinY > maxY) {
353 coinY = clamp(coinY, minY, maxY);
354 velocityY *= -0.9;
355 }
356
357 const driftDamping = Math.exp(-0.22 * dt);
358 velocityX *= driftDamping;
359 velocityY *= driftDamping;
360 }
361 }
362
363 coinX = clamp(coinX, minX, maxX);
364 coinY = clamp(coinY, minY, maxY);
365
366 const mix = ease(coinMix);
367 const rippleEnergy = Math.sin(mix * Math.PI);
368 const gridReach = Math.hypot(layout.cols, layout.rows) + r;
369 const coalesceDuration = 520;
370 const coalesceT = clamp((now - coalesceStart) / coalesceDuration, 0, 1);
371 const coalescing = effectiveHovered && now - coalesceStart >= 0 && coalesceT < 1;
372 const coalesceEnergy = coalescing ? Math.pow(1 - coalesceT, 0.38) : 0;
373
374 const hopCycle = (now / 1580) % 1;
375 const hopArc = Math.sin(hopCycle * Math.PI);
376 const hopLift = Math.pow(hopArc, 0.86) * Math.min(1.15, r * 0.24) * mix;
377
378 const centerX = coinX;
379 const centerY = clamp(coinY - hopLift, minY, maxY);
380
381 const clickSpinT = clamp((now - clickSpinStart) / 820, 0, 1);
382 const clickSpinActive = now - clickSpinStart >= 0 && clickSpinT < 1;
383 const clickSpinEase = 1 - Math.pow(1 - clickSpinT, 3);
384 const clickSpinPop = clickSpinActive ? Math.sin(clickSpinT * Math.PI) : 0;
385 const clickSpin = clickSpinActive ? clickSpinDirection * Math.PI * 6 * clickSpinEase : 0;
386
387 const rawSpin = hopCycle * Math.PI * 2 + clickSpin;
388 const spin = heldSpin(rawSpin, lerp(0.64, 0.18, clickSpinPop));
389 const face = Math.abs(Math.cos(spin));
390 const faceHold = Math.pow(face, 0.38);
391 const edgeFlash = 1 - face;
392 const widthScale = 0.2 + faceHold * 0.8;
393 const heightScale = 1 + edgeFlash * 0.06;
394 const flipped = Math.cos(spin) < 0;
395 const coinBrightness = 0.94 + faceHold * 0.13 + edgeFlash * 0.12 + clickSpinPop * 0.16;
396 const explosionReach = (gridReach + r) * (0.92 + explosionPower * 0.12);
397 const explosionFront = explosionT * explosionReach - r * 0.35;
398 const explosionEnergy = exploding ? Math.pow(1 - explosionT, 0.55) * explosionPower : 0;
399 const joltReach = layout.cols + layout.rows;
400 const joltFront = joltT * joltReach - 1;
401 const joltEnergy = jolting ? Math.pow(1 - joltT, 0.65) : 0;
402 const evaporateFront = evaporateT * gridReach - r * 0.2;
403 const evaporateEnergy = evaporating ? Math.pow(1 - evaporateT, 0.42) : 0;
404 const coalesceFront = (1 - coalesceT) * gridReach;
405
406 for (const item of dots) {
407 const { dot, col, row, phase, activity } = item;
408
409 const idleSpin = heldSpin(occasionalSpin(now, activity, phase), 0.72);
410 const idleFace = Math.pow(Math.abs(Math.cos(idleSpin)), 0.42);
411 const idleScale = 0.66 + activity * 0.48 + idleFace * (0.04 + activity * 0.05);
412 const idleOpacity = 0.42 + activity * 0.58;
413 const idleGlow = (1 - idleFace) * (0.07 + activity * 0.22);
414
415 const localX = (col - centerX) / widthScale;
416 const localY = (row - centerY) / heightScale;
417 const distance = Math.hypot(localX, localY);
418 const coinShape = 1 - smoothstep(r - 0.65, r + 0.35, distance);
419 const coinMass = mix * coinShape;
420
421 const fieldDistance = Math.hypot(col - centerX, row - centerY);
422 const ripple = Math.sin(fieldDistance * 1.15 - now * 0.0065) * rippleEnergy;
423 const transferFront = mix * gridReach;
424 const coalesceRing = coalescing ? ring(fieldDistance, coalesceFront, 2.5, coalesceEnergy) : 0;
425 const coalesceAbsorb =
426 coalescing
427 ? smoothstep(coalesceFront - 1.8, coalesceFront + 1.8, fieldDistance)
428 : 0;
429 const fieldAbsorb =
430 coalescing
431 ? coalesceAbsorb
432 : mix > 0.94
433 ? 1
434 : 1 - smoothstep(transferFront - 2.2, transferFront + 2.2, fieldDistance);
435 const transfer = clamp(mix * (coinShape + (1 - coinShape) * fieldAbsorb), 0, 1);
436 const idleMass = clamp(1 - transfer, 0, 1);
437 const totalMass = idleMass + coinMass;
438
439 const explosionDistance = Math.hypot(col - explosionX, row - explosionY);
440 const explosionRing = exploding ? ring(explosionDistance, explosionFront, 2.4, explosionEnergy) : 0;
441 const joltDistance =
442 joltDirection > 0
443 ? col - joltX + (row - joltY) * 0.45
444 : joltX - col + (row - joltY) * 0.45;
445 const joltRing = jolting ? ring(joltDistance, joltFront, 1.1, joltEnergy) : 0;
446 const explosionAfterglow =
447 exploding
448 ? (1 - smoothstep(explosionFront - 3.4, explosionFront + 0.2, explosionDistance)) *
449 explosionEnergy
450 : 0;
451 const evaporateRing =
452 evaporating ? ring(fieldDistance, evaporateFront, 2.1, evaporateEnergy * coinShape) : 0;
453 const evaporateSpark =
454 evaporating
455 ? Math.max(0, Math.sin(phase * 11.3 + evaporateT * 34)) * evaporateEnergy * coinShape
456 : 0;
457
458 const edge = distance / r;
459 const rim = edge > 0.78;
460 const leftHalf = flipped ? localX > 0 : localX < 0;
461 const onSeam = Math.abs(localX) < 0.38 && edge < 0.88;
462
463 const coinBackground = onSeam
464 ? `linear-gradient(90deg, ${purple}, ${white})`
465 : leftHalf
466 ? purple
467 : white;
468
469 const explosionBackground = Math.sin(phase + explosionT * 22) > 0 ? purple : white;
470 const coinScale = (rim ? 1.5 : 1.28) + clickSpinPop * (rim ? 0.16 : 0.1);
471 const idleWeightedScale = idleScale + ripple * idleMass * 0.035;
472 const scale =
473 totalMass > 0.001
474 ? (idleWeightedScale * idleMass + coinScale * coinMass) / totalMass
475 : idleWeightedScale;
476 const spinAmount = idleSpin;
477 const burstRing = Math.max(explosionRing, coalesceRing, evaporateRing, joltRing);
478 const burstScale =
479 explosionRing * (0.42 + activity * 0.22 + clickSpinPop * 0.12) +
480 joltRing * 0.14 +
481 coalesceRing * (0.3 + activity * 0.18) +
482 evaporateRing * 0.38 +
483 evaporateSpark * 0.22;
484 const burstOpacity =
485 explosionRing * 0.95 +
486 explosionAfterglow * 0.2 +
487 joltRing * 0.36 +
488 coalesceRing * 0.75 +
489 evaporateRing * 0.7 +
490 evaporateSpark * 0.38;
491
492 dot.style.background =
493 burstRing > Math.max(coinMass, idleMass) * 0.28
494 ? explosionBackground
495 : coinMass > idleMass * 0.72
496 ? coinBackground
497 : idleBackground;
498 dot.style.opacity = `${clamp(idleOpacity * idleMass + coinMass + burstOpacity, 0, 1)}`;
499 dot.style.transform = `rotateY(${spinAmount}rad) scale(${scale + burstScale})`;
500 dot.style.filter = `brightness(${lerp(0.9 + idleFace * 0.13 + activity * 0.08, coinBrightness, coinMass) + burstRing * 0.45 + evaporateSpark * 0.25}) saturate(${lerp(1, 1.1, coinMass) + burstRing * 0.18})`;
501 dot.style.boxShadow =
502 burstRing > 0.16
503 ? `0 0 ${8 + burstRing * 14}px rgba(181, 126, 220, ${0.22 + burstRing * 0.32})`
504 : coinMass > 0.2
505 ? rim
506 ? "0 0 8px rgba(181, 126, 220, 0.3)"
507 : "0 0 4px rgba(181, 126, 220, 0.18)"
508 : `0 0 ${idleGlow * 8}px rgba(181, 126, 220, ${idleGlow})`;
509 }
510
511 frame = requestAnimationFrame(animate);
512 }
513
514 function start() {
515 if (frame) cancelAnimationFrame(frame);
516
517 frame = 0;
518 lastTime = 0;
519 setup();
520
521 if (reduceMotion.matches) {
522 paintStill();
523 } else {
524 frame = requestAnimationFrame(animate);
525 }
526 }
527
528 punchcard.addEventListener("pointerenter", (event) => {
529 const wasHovered = hovered;
530 hovered = true;
531 setPointer(event);
532 const resumed = !wasHovered && resumeCoalesceAfterReenter();
533 if (!wasHovered && !resumed) triggerCoalesce();
534 });
535
536 punchcard.addEventListener("pointermove", setPointer);
537
538 punchcard.addEventListener("pointerleave", () => {
539 hovered = false;
540 evaporateCoin();
541 });
542
543 punchcard.addEventListener("click", (event) => {
544 const now = performance.now();
545
546 setPointer(event);
547 if (waitForReenterAfterExplosion) {
548 triggerJolt(now);
549 return;
550 }
551
552 if (event.detail % 3 === 0) {
553 triggerSpin(now);
554 triggerExplosion(now, 1.75);
555 } else {
556 triggerExplosion(now, 1);
557 }
558 });
559
560 start();
561
562 wideScreen.addEventListener("change", start);
563 reduceMotion.addEventListener("change", start);
564 addEventListener("resize", refreshLayout);
565}