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 / chancey.dev.js
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}