Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
5.1 kB · 201 lines
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202const HUE = 276;const ACCENT_L = 0.563;const ACCENT_C = 0.219;const PALE_L = 0.86;const PALE_C = 0.035;const GLOW_LIFT = 0.07;const GLOW_REACH = 0.85;
const LEVELS = 5;const GLOW_STEPS = 64;const LEVEL_CLASSES = [ "bg-green-200", "bg-green-300", "bg-green-400", "bg-green-500",];
const BREATH_SCALE = 0.22;const BREATH_GLOW = 0.5;const GRAVITY_RADIUS = 130;const GRAVITY_PULL = 10;const ATTACK = 0.5;const RELEASE = 0.07;
const WIDE = "(min-width: 768px)";const REDUCE = "(prefers-reduced-motion: reduce)";
const COLORS = Array.from({ length: LEVELS }, (_, lv) => { const intensity = lv / (LEVELS - 1); return Array.from({ length: GLOW_STEPS }, (_, gs) => { const glow = gs / (GLOW_STEPS - 1); const e = intensity + (1 - intensity) * glow * GLOW_REACH; const L = PALE_L + (ACCENT_L - PALE_L) * e + GLOW_LIFT * glow; const C = PALE_C + (ACCENT_C - PALE_C) * e; return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${HUE})`; });});
const attach = (grid) => { const dots = Array.from(grid.children, (c) => c.firstElementChild).filter( Boolean, ); if (!dots.length) return () => {};
const levels = new Uint8Array(dots.length); const hollow = dots.map((dot, i) => { levels[i] = LEVEL_CLASSES.findIndex((c) => dot.classList.contains(c)) + 1; return dot.classList.contains("border"); });
dots.forEach((dot) => { dot.style.transition = "none"; });
if (matchMedia(REDUCE).matches) { dots.forEach((dot, i) => { if (!hollow[i]) dot.style.backgroundColor = COLORS[levels[i]][0]; }); return () => {}; }
const ac = new AbortController(); const opts = { signal: ac.signal }; const passive = { passive: true, signal: ac.signal }; const wide = matchMedia(WIDE);
const grav = new Float32Array(dots.length); const lastGlow = new Int16Array(dots.length).fill(-1); const lastTransform = new Array(dots.length).fill(""); const offsets = new Float32Array(dots.length * 2);
let cols = wide.matches ? 14 : 28; let rect = grid.getBoundingClientRect(); let mouseX = -9999; let mouseY = -9999; let raf = 0; let running = true;
dots.forEach((dot) => { dot.style.willChange = "transform, background-color"; });
const measure = () => { cols = wide.matches ? 14 : 28; rect = grid.getBoundingClientRect(); dots.forEach((dot, i) => { const r = dot.parentElement.getBoundingClientRect(); offsets[i * 2] = r.left + r.width / 2 - rect.left; offsets[i * 2 + 1] = r.top + r.height / 2 - rect.top; }); };
const track = () => { rect = grid.getBoundingClientRect(); };
measure();
wide.addEventListener("change", measure, opts); window.addEventListener("resize", measure, opts); window.addEventListener("scroll", track, passive); window.addEventListener( "pointermove", (e) => { mouseX = e.clientX; mouseY = e.clientY; }, passive, ); document.addEventListener( "pointerleave", () => { mouseX = -9999; mouseY = -9999; }, opts, );
const frame = (now) => { const t = now / 1000;
for (let i = 0; i < dots.length; i++) { const col = i % cols; const row = (i / cols) | 0;
const w1 = Math.sin(col * 0.38 + row * 0.3 + t * 0.5); const w2 = Math.sin(col * 0.18 - row * 0.45 + t * 0.32 + 2.1); const breath = (w1 + w2) / 4 + 0.5;
const dx = mouseX - (rect.left + offsets[i * 2]); const dy = mouseY - (rect.top + offsets[i * 2 + 1]); const dist = Math.hypot(dx, dy);
let target = Math.max(0, 1 - dist / GRAVITY_RADIUS); target = target * target * (3 - 2 * target); grav[i] += (target - grav[i]) * (target > grav[i] ? ATTACK : RELEASE); const g = grav[i];
const pull = g * GRAVITY_PULL; const px = dist > 0 ? (dx / dist) * pull : 0; const py = dist > 0 ? (dy / dist) * pull : 0;
const scale = 1 + breath * BREATH_SCALE + g * 0.55; const qx = Math.round(px * 4) / 4; const qy = Math.round(py * 4) / 4; const qs = Math.round(scale * 250) / 250;
const tf = `translate(${qx}px,${qy}px) scale(${qs})`; if (tf !== lastTransform[i]) { dots[i].style.transform = tf; lastTransform[i] = tf; }
if (hollow[i]) continue;
const glow = Math.min(1, breath * BREATH_GLOW + g * 0.65); const gs = Math.min(GLOW_STEPS - 1, (glow * GLOW_STEPS) | 0); if (gs !== lastGlow[i]) { dots[i].style.backgroundColor = COLORS[levels[i]][gs]; lastGlow[i] = gs; } }
raf = requestAnimationFrame(frame); };
const io = new IntersectionObserver((entries) => { const visible = entries.some((e) => e.isIntersecting); if (visible === running) return; running = visible; if (running) { measure(); raf = requestAnimationFrame(frame); } else { cancelAnimationFrame(raf); } }); io.observe(grid);
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); io.disconnect(); ac.abort(); };};
let teardown = null;let current = null;
const boot = () => { const grid = document.querySelector("[data-punchcard]"); if (grid === current) return; if (teardown) teardown(); current = grid; teardown = grid ? attach(grid) : null;};
boot();document.addEventListener("htmx:load", boot);