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 / luisstd.js
5.1 kB 201 lines
1const HUE = 276; 2const ACCENT_L = 0.563; 3const ACCENT_C = 0.219; 4const PALE_L = 0.86; 5const PALE_C = 0.035; 6const GLOW_LIFT = 0.07; 7const GLOW_REACH = 0.85; 8 9const LEVELS = 5; 10const GLOW_STEPS = 64; 11const LEVEL_CLASSES = [ 12 "bg-green-200", 13 "bg-green-300", 14 "bg-green-400", 15 "bg-green-500", 16]; 17 18const BREATH_SCALE = 0.22; 19const BREATH_GLOW = 0.5; 20const GRAVITY_RADIUS = 130; 21const GRAVITY_PULL = 10; 22const ATTACK = 0.5; 23const RELEASE = 0.07; 24 25const WIDE = "(min-width: 768px)"; 26const REDUCE = "(prefers-reduced-motion: reduce)"; 27 28const 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 39const 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 189let teardown = null; 190let current = null; 191 192const 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 200boot(); 201document.addEventListener("htmx:load", boot);