Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/pages/profile-fx: add custom punchcard effect

authored by

Luis Steidle and committed by
Tangled
(Jul 14, 2026, 3:54 PM +0300) 770a8233 75a83258

+201
+201
appview/pages/profile-fx/luisstd.js
··· 1 + const HUE = 276; 2 + const ACCENT_L = 0.563; 3 + const ACCENT_C = 0.219; 4 + const PALE_L = 0.86; 5 + const PALE_C = 0.035; 6 + const GLOW_LIFT = 0.07; 7 + const GLOW_REACH = 0.85; 8 + 9 + const LEVELS = 5; 10 + const GLOW_STEPS = 64; 11 + const LEVEL_CLASSES = [ 12 + "bg-green-200", 13 + "bg-green-300", 14 + "bg-green-400", 15 + "bg-green-500", 16 + ]; 17 + 18 + const BREATH_SCALE = 0.22; 19 + const BREATH_GLOW = 0.5; 20 + const GRAVITY_RADIUS = 130; 21 + const GRAVITY_PULL = 10; 22 + const ATTACK = 0.5; 23 + const RELEASE = 0.07; 24 + 25 + const WIDE = "(min-width: 768px)"; 26 + const REDUCE = "(prefers-reduced-motion: reduce)"; 27 + 28 + const COLORS = Array.from({ length: LEVELS }, (_, lv) => { 29 + const intensity = lv / (LEVELS - 1); 30 + return Array.from({ length: GLOW_STEPS }, (_, gs) => { 31 + const glow = gs / (GLOW_STEPS - 1); 32 + const e = intensity + (1 - intensity) * glow * GLOW_REACH; 33 + const L = PALE_L + (ACCENT_L - PALE_L) * e + GLOW_LIFT * glow; 34 + const C = PALE_C + (ACCENT_C - PALE_C) * e; 35 + return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${HUE})`; 36 + }); 37 + }); 38 + 39 + const attach = (grid) => { 40 + const dots = Array.from(grid.children, (c) => c.firstElementChild).filter( 41 + Boolean, 42 + ); 43 + if (!dots.length) return () => {}; 44 + 45 + const levels = new Uint8Array(dots.length); 46 + const hollow = dots.map((dot, i) => { 47 + levels[i] = LEVEL_CLASSES.findIndex((c) => dot.classList.contains(c)) + 1; 48 + return dot.classList.contains("border"); 49 + }); 50 + 51 + dots.forEach((dot) => { 52 + dot.style.transition = "none"; 53 + }); 54 + 55 + if (matchMedia(REDUCE).matches) { 56 + dots.forEach((dot, i) => { 57 + if (!hollow[i]) dot.style.backgroundColor = COLORS[levels[i]][0]; 58 + }); 59 + return () => {}; 60 + } 61 + 62 + const ac = new AbortController(); 63 + const opts = { signal: ac.signal }; 64 + const passive = { passive: true, signal: ac.signal }; 65 + const wide = matchMedia(WIDE); 66 + 67 + const grav = new Float32Array(dots.length); 68 + const lastGlow = new Int16Array(dots.length).fill(-1); 69 + const lastTransform = new Array(dots.length).fill(""); 70 + const offsets = new Float32Array(dots.length * 2); 71 + 72 + let cols = wide.matches ? 14 : 28; 73 + let rect = grid.getBoundingClientRect(); 74 + let mouseX = -9999; 75 + let mouseY = -9999; 76 + let raf = 0; 77 + let running = true; 78 + 79 + dots.forEach((dot) => { 80 + dot.style.willChange = "transform, background-color"; 81 + }); 82 + 83 + const measure = () => { 84 + cols = wide.matches ? 14 : 28; 85 + rect = grid.getBoundingClientRect(); 86 + dots.forEach((dot, i) => { 87 + const r = dot.parentElement.getBoundingClientRect(); 88 + offsets[i * 2] = r.left + r.width / 2 - rect.left; 89 + offsets[i * 2 + 1] = r.top + r.height / 2 - rect.top; 90 + }); 91 + }; 92 + 93 + const track = () => { 94 + rect = grid.getBoundingClientRect(); 95 + }; 96 + 97 + measure(); 98 + 99 + wide.addEventListener("change", measure, opts); 100 + window.addEventListener("resize", measure, opts); 101 + window.addEventListener("scroll", track, passive); 102 + window.addEventListener( 103 + "pointermove", 104 + (e) => { 105 + mouseX = e.clientX; 106 + mouseY = e.clientY; 107 + }, 108 + passive, 109 + ); 110 + document.addEventListener( 111 + "pointerleave", 112 + () => { 113 + mouseX = -9999; 114 + mouseY = -9999; 115 + }, 116 + opts, 117 + ); 118 + 119 + const frame = (now) => { 120 + const t = now / 1000; 121 + 122 + for (let i = 0; i < dots.length; i++) { 123 + const col = i % cols; 124 + const row = (i / cols) | 0; 125 + 126 + const w1 = Math.sin(col * 0.38 + row * 0.3 + t * 0.5); 127 + const w2 = Math.sin(col * 0.18 - row * 0.45 + t * 0.32 + 2.1); 128 + const breath = (w1 + w2) / 4 + 0.5; 129 + 130 + const dx = mouseX - (rect.left + offsets[i * 2]); 131 + const dy = mouseY - (rect.top + offsets[i * 2 + 1]); 132 + const dist = Math.hypot(dx, dy); 133 + 134 + let target = Math.max(0, 1 - dist / GRAVITY_RADIUS); 135 + target = target * target * (3 - 2 * target); 136 + grav[i] += (target - grav[i]) * (target > grav[i] ? ATTACK : RELEASE); 137 + const g = grav[i]; 138 + 139 + const pull = g * GRAVITY_PULL; 140 + const px = dist > 0 ? (dx / dist) * pull : 0; 141 + const py = dist > 0 ? (dy / dist) * pull : 0; 142 + 143 + const scale = 1 + breath * BREATH_SCALE + g * 0.55; 144 + const qx = Math.round(px * 4) / 4; 145 + const qy = Math.round(py * 4) / 4; 146 + const qs = Math.round(scale * 250) / 250; 147 + 148 + const tf = `translate(${qx}px,${qy}px) scale(${qs})`; 149 + if (tf !== lastTransform[i]) { 150 + dots[i].style.transform = tf; 151 + lastTransform[i] = tf; 152 + } 153 + 154 + if (hollow[i]) continue; 155 + 156 + const glow = Math.min(1, breath * BREATH_GLOW + g * 0.65); 157 + const gs = Math.min(GLOW_STEPS - 1, (glow * GLOW_STEPS) | 0); 158 + if (gs !== lastGlow[i]) { 159 + dots[i].style.backgroundColor = COLORS[levels[i]][gs]; 160 + lastGlow[i] = gs; 161 + } 162 + } 163 + 164 + raf = requestAnimationFrame(frame); 165 + }; 166 + 167 + const io = new IntersectionObserver((entries) => { 168 + const visible = entries.some((e) => e.isIntersecting); 169 + if (visible === running) return; 170 + running = visible; 171 + if (running) { 172 + measure(); 173 + raf = requestAnimationFrame(frame); 174 + } else { 175 + cancelAnimationFrame(raf); 176 + } 177 + }); 178 + io.observe(grid); 179 + 180 + raf = requestAnimationFrame(frame); 181 + 182 + return () => { 183 + cancelAnimationFrame(raf); 184 + io.disconnect(); 185 + ac.abort(); 186 + }; 187 + }; 188 + 189 + let teardown = null; 190 + let current = null; 191 + 192 + const boot = () => { 193 + const grid = document.querySelector("[data-punchcard]"); 194 + if (grid === current) return; 195 + if (teardown) teardown(); 196 + current = grid; 197 + teardown = grid ? attach(grid) : null; 198 + }; 199 + 200 + boot(); 201 + document.addEventListener("htmx:load", boot);