Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
19 kB · 565 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566const punchcard = document.querySelector("[data-punchcard]");
if (punchcard) { const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); const wideScreen = matchMedia("(min-width: 768px)");
const purple = "#b57edc"; const white = "#fff"; const idleBackground = `linear-gradient(90deg, ${purple} 0 50%, ${white} 50% 100%)`;
const activityCache = new WeakMap();
let dots = []; let frame = 0; let hovered = false; let pointerX = 0.5; let pointerY = 0.5; let coinX = NaN; let coinY = NaN; let velocityX = 0; let velocityY = 0; let coinMix = 0; let evaporateStart = null; let evaporateFrom = 0; let clickSpinStart = -Infinity; let clickSpinDirection = 1; let coalesceStart = -Infinity; let explosionStart = -Infinity; let explosionPower = 1; let explosionX = 0; let explosionY = 0; let joltStart = -Infinity; let joltX = 0; let joltY = 0; let joltDirection = 1; let suppressGatherUntil = 0; let waitForReenterAfterExplosion = false; let lastTime = 0; let layout = { cols: 28, rows: 1 };
function columnCount() { return wideScreen.matches ? 14 : 28; }
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function ease(t) { return t * t * (3 - 2 * t); }
function smoothstep(edge0, edge1, value) { const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); return t * t * (3 - 2 * t); }
function ring(distance, front, width, energy = 1) { return (1 - smoothstep(0, width, Math.abs(distance - front))) * energy; }
function heldSpin(raw, hold) { return raw - Math.sin(raw * 2) * hold * 0.5; }
function occasionalSpin(now, activity, phase) { const active = 0.16; const period = lerp(9800, 5600, activity); const cycle = ((now + phase * 1400) % period) / period;
if (cycle >= active) return Math.PI * 2;
return ease(cycle / active) * Math.PI * 2; }
function numberFromLabel(text) { const match = text?.match( /(\d+(?:\.\d+)?)\s+(?:commit|commits|contribution|contributions|change|changes)/i, );
return match ? Number(match[1]) : null; }
function explicitActivity(dot, wrapper) { for (const element of [dot, wrapper]) { for (const key of ["count", "commits", "contributions", "value", "level", "intensity"]) { const value = element.dataset?.[key]; if (value !== undefined && value !== "" && !Number.isNaN(Number(value))) { return Number(value); } }
const label = `${element.getAttribute("aria-label") || ""} ${element.getAttribute("title") || ""}`; const labelValue = numberFromLabel(label); if (labelValue !== null) return labelValue;
const className = typeof element.className === "string" ? element.className : ""; const classValue = className.match(/(?:level|count|activity|intensity)-?(\d+)/i); if (classValue) return Number(classValue[1]); }
return null; }
function colorActivity(color) { const match = color.match(/rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\)/i); if (!match) return 0;
const r = Number(match[1]); const g = Number(match[2]); const b = Number(match[3]); const a = match[4] === undefined ? 1 : Number(match[4]); if (a <= 0) return 0;
const max = Math.max(r, g, b); const min = Math.min(r, g, b); const saturation = max === 0 ? 0 : (max - min) / max; const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; const greenBias = clamp((g - Math.max(r, b)) / 160, 0, 1);
return clamp((saturation * 0.45 + greenBias * 0.55) * (0.55 + (1 - luminance) * 0.65), 0, 1); }
function readActivity(dot, wrapper) { if (activityCache.has(dot)) return activityCache.get(dot);
const signal = { explicit: explicitActivity(dot, wrapper), color: colorActivity(getComputedStyle(dot).backgroundColor), };
activityCache.set(dot, signal); return signal; }
function refreshLayout() { layout.cols = columnCount(); layout.rows = Math.ceil(dots.length / layout.cols) || 1; }
function setup() { const cols = columnCount();
const items = Array.from(punchcard.children) .map((wrapper, index) => { const dot = wrapper.firstElementChild; if (!dot) return null;
return { dot, wrapper, signal: readActivity(dot, wrapper), col: index % cols, row: Math.floor(index / cols), phase: index * 0.43, }; }) .filter(Boolean);
const maxExplicit = Math.max(0, ...items.map((item) => item.signal.explicit || 0)); const maxColor = Math.max(0.001, ...items.map((item) => item.signal.color || 0));
dots = items.map((item) => { const activity = item.signal.explicit !== null ? maxExplicit > 0 ? Math.log1p(item.signal.explicit) / Math.log1p(maxExplicit) : 0 : item.signal.color > 0.015 ? item.signal.color / maxColor : 0;
item.wrapper.style.perspective = "90px";
item.dot.style.transition = "none"; item.dot.style.borderRadius = "50%"; item.dot.style.transformOrigin = "50% 50%"; item.dot.style.backfaceVisibility = "visible"; item.dot.style.willChange = "transform, opacity, background, box-shadow, filter"; item.dot.style.background = idleBackground;
return { ...item, activity: clamp(activity, 0, 1), }; });
refreshLayout();
if (!Number.isFinite(coinX)) coinX = (layout.cols - 1) / 2; if (!Number.isFinite(coinY)) coinY = (layout.rows - 1) / 2; }
function radius() { return Math.max(2.8, Math.min(layout.cols * 0.3, layout.rows * 0.24)); }
function bounds() { return { minX: 0, maxX: layout.cols - 1, minY: 0, maxY: layout.rows - 1, }; }
function setPointer(event) { const rect = punchcard.getBoundingClientRect();
pointerX = clamp((event.clientX - rect.left) / rect.width, 0, 1); pointerY = clamp((event.clientY - rect.top) / rect.height, 0, 1); }
function evaporateCoin() { evaporateStart = performance.now(); evaporateFrom = Math.max(coinMix, 0.28);
const speed = Math.hypot(velocityX, velocityY); if (speed < 2.8) { const angle = speed > 0.2 ? Math.atan2(velocityY, velocityX) : -0.72; velocityX = Math.cos(angle) * 3.5; velocityY = Math.sin(angle) * 3.5; } }
function triggerCoalesce(now = performance.now()) { coalesceStart = now - 90; evaporateStart = null; coinMix = Math.max(coinMix, 0.78); }
function triggerSpin(now = performance.now()) { clickSpinStart = now; clickSpinDirection *= -1; evaporateStart = null; coinMix = Math.max(coinMix, 0.62); }
function triggerExplosion(now = performance.now(), power = 1) { const { minX, maxX, minY, maxY } = bounds();
explosionStart = now; explosionPower = power; explosionX = Number.isFinite(coinX) ? coinX : clamp(pointerX * (layout.cols - 1), minX, maxX); explosionY = Number.isFinite(coinY) ? coinY : clamp(pointerY * (layout.rows - 1), minY, maxY); suppressGatherUntil = now + 1050 + power * 260; waitForReenterAfterExplosion = hovered; if (hovered) coalesceStart = Infinity; evaporateStart = null; evaporateFrom = Math.max(coinMix, 0.95); coinMix = evaporateFrom;
const angle = Math.atan2(velocityY || -0.45, velocityX || 0.9); velocityX = Math.cos(angle) * (4.8 + power * 0.7); velocityY = Math.sin(angle) * (4.8 + power * 0.7); }
function triggerJolt(now = performance.now()) { const { minX, maxX, minY, maxY } = bounds();
joltStart = now; joltX = clamp(pointerX * (layout.cols - 1), minX, maxX); joltY = clamp(pointerY * (layout.rows - 1), minY, maxY); joltDirection *= -1; }
function resumeCoalesceAfterReenter(now = performance.now()) { if (!waitForReenterAfterExplosion) return false;
waitForReenterAfterExplosion = false; coalesceStart = Math.max(now, suppressGatherUntil); if (now >= suppressGatherUntil) coinMix = Math.max(coinMix, 0.78); return true; }
function paintStill() { for (const { dot, activity } of dots) { dot.style.background = idleBackground; dot.style.opacity = `${0.45 + activity * 0.55}`; dot.style.transform = `scale(${0.72 + activity * 0.42})`; dot.style.boxShadow = "none"; dot.style.filter = "none"; } }
function animate(now) { const dt = lastTime ? clamp((now - lastTime) / 1000, 0.001, 0.04) : 0.016; lastTime = now;
const r = radius(); const { minX, maxX, minY, maxY } = bounds(); const targetX = clamp(pointerX * (layout.cols - 1), minX, maxX); const targetY = clamp(pointerY * (layout.rows - 1), minY, maxY); const explosionDuration = 1050 + explosionPower * 250; const explosionT = clamp((now - explosionStart) / explosionDuration, 0, 1); const exploding = now - explosionStart >= 0 && explosionT < 1; const joltDuration = 620; const joltT = clamp((now - joltStart) / joltDuration, 0, 1); const jolting = now - joltStart >= 0 && joltT < 1; const evaporateDuration = 1650; const evaporateT = evaporateStart !== null ? clamp((now - evaporateStart) / evaporateDuration, 0, 1) : 1; const evaporating = evaporateStart !== null && evaporateT < 1; const effectiveHovered = hovered && !exploding && !waitForReenterAfterExplosion && now >= suppressGatherUntil;
const previousX = coinX; const previousY = coinY;
if (effectiveHovered) { evaporateStart = null;
const follow = 1 - Math.exp(-9.5 * dt); coinX += (targetX - coinX) * follow; coinY += (targetY - coinY) * follow;
velocityX = (coinX - previousX) / dt; velocityY = (coinY - previousY) / dt;
coinMix += (1 - coinMix) * (1 - Math.exp(-18 * dt)); } else { if (exploding) { coinMix = evaporateFrom * (1 - smoothstep(0.06, 0.74, explosionT)); } else if (waitForReenterAfterExplosion) { coinMix = 0; evaporateStart = null; } else if (evaporating) { const dissolve = smoothstep(0.04, 0.96, evaporateT); coinMix = evaporateFrom * (1 - dissolve); if (evaporateT >= 1) evaporateStart = null; } else if (evaporateStart !== null) { coinMix = 0; evaporateStart = null; } else { coinMix += (0 - coinMix) * (1 - Math.exp(-3.5 * dt)); }
if (coinMix > 0.01) { coinX += velocityX * dt; coinY += velocityY * dt;
if (coinX < minX || coinX > maxX) { coinX = clamp(coinX, minX, maxX); velocityX *= -0.9; }
if (coinY < minY || coinY > maxY) { coinY = clamp(coinY, minY, maxY); velocityY *= -0.9; }
const driftDamping = Math.exp(-0.22 * dt); velocityX *= driftDamping; velocityY *= driftDamping; } }
coinX = clamp(coinX, minX, maxX); coinY = clamp(coinY, minY, maxY);
const mix = ease(coinMix); const rippleEnergy = Math.sin(mix * Math.PI); const gridReach = Math.hypot(layout.cols, layout.rows) + r; const coalesceDuration = 520; const coalesceT = clamp((now - coalesceStart) / coalesceDuration, 0, 1); const coalescing = effectiveHovered && now - coalesceStart >= 0 && coalesceT < 1; const coalesceEnergy = coalescing ? Math.pow(1 - coalesceT, 0.38) : 0;
const hopCycle = (now / 1580) % 1; const hopArc = Math.sin(hopCycle * Math.PI); const hopLift = Math.pow(hopArc, 0.86) * Math.min(1.15, r * 0.24) * mix;
const centerX = coinX; const centerY = clamp(coinY - hopLift, minY, maxY);
const clickSpinT = clamp((now - clickSpinStart) / 820, 0, 1); const clickSpinActive = now - clickSpinStart >= 0 && clickSpinT < 1; const clickSpinEase = 1 - Math.pow(1 - clickSpinT, 3); const clickSpinPop = clickSpinActive ? Math.sin(clickSpinT * Math.PI) : 0; const clickSpin = clickSpinActive ? clickSpinDirection * Math.PI * 6 * clickSpinEase : 0;
const rawSpin = hopCycle * Math.PI * 2 + clickSpin; const spin = heldSpin(rawSpin, lerp(0.64, 0.18, clickSpinPop)); const face = Math.abs(Math.cos(spin)); const faceHold = Math.pow(face, 0.38); const edgeFlash = 1 - face; const widthScale = 0.2 + faceHold * 0.8; const heightScale = 1 + edgeFlash * 0.06; const flipped = Math.cos(spin) < 0; const coinBrightness = 0.94 + faceHold * 0.13 + edgeFlash * 0.12 + clickSpinPop * 0.16; const explosionReach = (gridReach + r) * (0.92 + explosionPower * 0.12); const explosionFront = explosionT * explosionReach - r * 0.35; const explosionEnergy = exploding ? Math.pow(1 - explosionT, 0.55) * explosionPower : 0; const joltReach = layout.cols + layout.rows; const joltFront = joltT * joltReach - 1; const joltEnergy = jolting ? Math.pow(1 - joltT, 0.65) : 0; const evaporateFront = evaporateT * gridReach - r * 0.2; const evaporateEnergy = evaporating ? Math.pow(1 - evaporateT, 0.42) : 0; const coalesceFront = (1 - coalesceT) * gridReach;
for (const item of dots) { const { dot, col, row, phase, activity } = item;
const idleSpin = heldSpin(occasionalSpin(now, activity, phase), 0.72); const idleFace = Math.pow(Math.abs(Math.cos(idleSpin)), 0.42); const idleScale = 0.66 + activity * 0.48 + idleFace * (0.04 + activity * 0.05); const idleOpacity = 0.42 + activity * 0.58; const idleGlow = (1 - idleFace) * (0.07 + activity * 0.22);
const localX = (col - centerX) / widthScale; const localY = (row - centerY) / heightScale; const distance = Math.hypot(localX, localY); const coinShape = 1 - smoothstep(r - 0.65, r + 0.35, distance); const coinMass = mix * coinShape;
const fieldDistance = Math.hypot(col - centerX, row - centerY); const ripple = Math.sin(fieldDistance * 1.15 - now * 0.0065) * rippleEnergy; const transferFront = mix * gridReach; const coalesceRing = coalescing ? ring(fieldDistance, coalesceFront, 2.5, coalesceEnergy) : 0; const coalesceAbsorb = coalescing ? smoothstep(coalesceFront - 1.8, coalesceFront + 1.8, fieldDistance) : 0; const fieldAbsorb = coalescing ? coalesceAbsorb : mix > 0.94 ? 1 : 1 - smoothstep(transferFront - 2.2, transferFront + 2.2, fieldDistance); const transfer = clamp(mix * (coinShape + (1 - coinShape) * fieldAbsorb), 0, 1); const idleMass = clamp(1 - transfer, 0, 1); const totalMass = idleMass + coinMass;
const explosionDistance = Math.hypot(col - explosionX, row - explosionY); const explosionRing = exploding ? ring(explosionDistance, explosionFront, 2.4, explosionEnergy) : 0; const joltDistance = joltDirection > 0 ? col - joltX + (row - joltY) * 0.45 : joltX - col + (row - joltY) * 0.45; const joltRing = jolting ? ring(joltDistance, joltFront, 1.1, joltEnergy) : 0; const explosionAfterglow = exploding ? (1 - smoothstep(explosionFront - 3.4, explosionFront + 0.2, explosionDistance)) * explosionEnergy : 0; const evaporateRing = evaporating ? ring(fieldDistance, evaporateFront, 2.1, evaporateEnergy * coinShape) : 0; const evaporateSpark = evaporating ? Math.max(0, Math.sin(phase * 11.3 + evaporateT * 34)) * evaporateEnergy * coinShape : 0;
const edge = distance / r; const rim = edge > 0.78; const leftHalf = flipped ? localX > 0 : localX < 0; const onSeam = Math.abs(localX) < 0.38 && edge < 0.88;
const coinBackground = onSeam ? `linear-gradient(90deg, ${purple}, ${white})` : leftHalf ? purple : white;
const explosionBackground = Math.sin(phase + explosionT * 22) > 0 ? purple : white; const coinScale = (rim ? 1.5 : 1.28) + clickSpinPop * (rim ? 0.16 : 0.1); const idleWeightedScale = idleScale + ripple * idleMass * 0.035; const scale = totalMass > 0.001 ? (idleWeightedScale * idleMass + coinScale * coinMass) / totalMass : idleWeightedScale; const spinAmount = idleSpin; const burstRing = Math.max(explosionRing, coalesceRing, evaporateRing, joltRing); const burstScale = explosionRing * (0.42 + activity * 0.22 + clickSpinPop * 0.12) + joltRing * 0.14 + coalesceRing * (0.3 + activity * 0.18) + evaporateRing * 0.38 + evaporateSpark * 0.22; const burstOpacity = explosionRing * 0.95 + explosionAfterglow * 0.2 + joltRing * 0.36 + coalesceRing * 0.75 + evaporateRing * 0.7 + evaporateSpark * 0.38;
dot.style.background = burstRing > Math.max(coinMass, idleMass) * 0.28 ? explosionBackground : coinMass > idleMass * 0.72 ? coinBackground : idleBackground; dot.style.opacity = `${clamp(idleOpacity * idleMass + coinMass + burstOpacity, 0, 1)}`; dot.style.transform = `rotateY(${spinAmount}rad) scale(${scale + burstScale})`; 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})`; dot.style.boxShadow = burstRing > 0.16 ? `0 0 ${8 + burstRing * 14}px rgba(181, 126, 220, ${0.22 + burstRing * 0.32})` : coinMass > 0.2 ? rim ? "0 0 8px rgba(181, 126, 220, 0.3)" : "0 0 4px rgba(181, 126, 220, 0.18)" : `0 0 ${idleGlow * 8}px rgba(181, 126, 220, ${idleGlow})`; }
frame = requestAnimationFrame(animate); }
function start() { if (frame) cancelAnimationFrame(frame);
frame = 0; lastTime = 0; setup();
if (reduceMotion.matches) { paintStill(); } else { frame = requestAnimationFrame(animate); } }
punchcard.addEventListener("pointerenter", (event) => { const wasHovered = hovered; hovered = true; setPointer(event); const resumed = !wasHovered && resumeCoalesceAfterReenter(); if (!wasHovered && !resumed) triggerCoalesce(); });
punchcard.addEventListener("pointermove", setPointer);
punchcard.addEventListener("pointerleave", () => { hovered = false; evaporateCoin(); });
punchcard.addEventListener("click", (event) => { const now = performance.now();
setPointer(event); if (waitForReenterAfterExplosion) { triggerJolt(now); return; }
if (event.detail % 3 === 0) { triggerSpin(now); triggerExplosion(now, 1.75); } else { triggerExplosion(now, 1); } });
start();
wideScreen.addEventListener("change", start); reduceMotion.addEventListener("change", start); addEventListener("resize", refreshLayout);}