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 / kandake.js
8.1 kB 236 lines
1// neon-terrain.js — profile fx: renders the punchcard as a polycss-style 3D 2// terrain (perspective + rotateX/rotateZ + translateZ elevation, as in the 3// terrain demo) lit with twinkl "y2kringe" neon — cyan/magenta pulses sweeping 4// across the grid. One self-contained ES module, vanilla JS, no imports. 5// 6// CSS is loaded by injecting a <style> element via the DOM at runtime, so the 7// module stays a single file with no build step. 8 9const STYLE_ID = "npx-neon-terrain-style"; 10const GRID_CLASS = "npx-grid"; 11 12// twinkl y2kringe palette 13const VOID_BG = "#0a001a"; 14const HUE_CYAN = 185; 15const HUE_MAGENTA = 300; 16 17const WIDE_Q = "(min-width: 768px)"; 18const REDUCED_Q = "(prefers-reduced-motion: reduce)"; 19const COLS_NARROW = 28; 20const COLS_WIDE = 14; 21 22const MAX_LIFT = 22; // px of translateZ at full energy 23const WAVE_SPEED = 1.7; // rad/s of the diagonal pulse 24const SHIMMER_SPEED = 0.6; 25const ROT_SPEED = 9; // deg/s turntable rotation of the whole terrain 26const TILT = 55; // deg rotateX of the board 27const BOARD_SCALE = 0.72; 28const QUANT = 48; // energy quantization levels — skip redundant style writes 29 30const clamp01 = (n) => Math.max(0, Math.min(1, n)); 31 32const css = ` 33[data-punchcard].${GRID_CLASS} { 34 transform-style: preserve-3d; 35 background-color: ${VOID_BG}; 36 background-image: 37 radial-gradient(1px 1px at 10% 20%, rgb(255 255 255 / 0.5) 0%, transparent 100%), 38 radial-gradient(1px 1px at 30% 80%, rgb(255 255 255 / 0.4) 0%, transparent 100%), 39 radial-gradient(1.5px 1.5px at 65% 85%, rgb(255 255 255 / 0.45) 0%, transparent 100%), 40 radial-gradient(1px 1px at 85% 15%, rgb(255 255 255 / 0.4) 0%, transparent 100%), 41 radial-gradient(1.5px 1.5px at 15% 55%, rgb(255 255 255 / 0.5) 0%, transparent 100%); 42 background-size: 200px 200px; 43 border-radius: 12px; 44 box-shadow: 0 0 24px rgb(255 0 255 / 0.25), inset 0 0 40px rgb(0 255 255 / 0.06); 45} 46[data-punchcard].${GRID_CLASS} > * { 47 transform-style: preserve-3d; 48 position: relative; 49} 50/* ground shadow under each lifted dot — cheap depth cue at floor level */ 51[data-punchcard].${GRID_CLASS} > *::after { 52 content: ""; 53 position: absolute; 54 inset: 20%; 55 border-radius: 50%; 56 background: rgb(255 0 255 / 0.14); 57 filter: blur(1.5px); 58 transform: translateZ(0.5px); 59 pointer-events: none; 60} 61[data-punchcard].${GRID_CLASS} > * > :first-child { 62 transform-style: preserve-3d; 63 transition: none; 64 will-change: transform, background-color, box-shadow; 65} 66`; 67 68const injectStyle = () => { 69 let el = document.getElementById(STYLE_ID); 70 if (!el) { 71 el = document.createElement("style"); 72 el.id = STYLE_ID; 73 el.textContent = css; 74 document.head.appendChild(el); 75 } 76 return el; 77}; 78 79// Luminance of the dot's current color -> base terrain height, so activity 80// level (darker/lighter contribution dots) becomes elevation. 81const luminance = (el) => { 82 const m = getComputedStyle(el).backgroundColor.match(/[\d.]+/g); 83 if (!m || m.length < 3) return 0; 84 const [r, g, b] = m.map(Number); 85 return clamp01((0.2126 * r + 0.7152 * g + 0.0722 * b) / 255); 86}; 87 88const collectDots = (grid) => 89 Array.from(grid.children, (c) => c.firstElementChild).filter(Boolean); 90 91const clearDot = (el) => { 92 el.style.backgroundColor = ""; 93 el.style.transform = ""; 94 el.style.boxShadow = ""; 95 el.style.transition = ""; 96 el.style.transformOrigin = ""; 97 el.style.willChange = ""; 98}; 99 100const paintDot = (el, energy, hueMix) => { 101 const hue = HUE_CYAN + (HUE_MAGENTA - HUE_CYAN) * hueMix; 102 const light = 55 + energy * 20; 103 const color = `hsl(${hue.toFixed(1)} 100% ${light.toFixed(1)}%)`; 104 const z = (energy * MAX_LIFT).toFixed(2); 105 const s = (1 + energy * 1.2).toFixed(3); 106 el.style.backgroundColor = color; 107 el.style.transform = `translateZ(${z}px) scale(${s})`; 108 el.style.boxShadow = 109 `0 0 ${(2 + energy * 8).toFixed(1)}px ${color}, ` + 110 `0 0 ${(6 + energy * 16).toFixed(1)}px hsl(${hue.toFixed(1)} 100% 50% / 0.55)`; 111}; 112 113const start = (grid) => { 114 const ac = new AbortController(); 115 const opts = { signal: ac.signal }; 116 const wideMq = matchMedia(WIDE_Q); 117 const reduced = matchMedia(REDUCED_Q).matches; 118 119 const styleEl = injectStyle(); 120 grid.classList.add(GRID_CLASS); 121 122 const boardTransform = (deg) => 123 `perspective(1200px) rotateX(${TILT}deg) rotateZ(${deg.toFixed(2)}deg) scale(${BOARD_SCALE})`; 124 grid.style.transform = boardTransform(45); 125 126 let dots = collectDots(grid); 127 // Inline overrides beat any Tailwind transition/duration classes on the dots. 128 dots.forEach((el) => { 129 el.style.transition = "none"; 130 el.style.transformOrigin = "center"; 131 el.style.willChange = "transform, background-color, box-shadow"; 132 }); 133 // Base heights are read from the pre-fx dot colors, before we repaint them. 134 // In dark mode empty dots are darker than active ones; in light mode it is 135 // inverted, so normalize against the dominant (most common) luminance. 136 let base = dots.map(luminance); 137 { 138 const counts = new Map(); 139 base.forEach((l) => { 140 const k = Math.round(l * 20); 141 counts.set(k, (counts.get(k) || 0) + 1); 142 }); 143 const idle = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] / 20 ?? 0; 144 base = base.map((l) => clamp01(Math.abs(l - idle) * 1.6)); 145 } 146 147 let cols = wideMq.matches ? COLS_WIDE : COLS_NARROW; 148 let lastQ = new Int16Array(dots.length).fill(-1); 149 let lastHue = new Float32Array(dots.length).fill(-1); 150 151 const relayout = () => { 152 cols = wideMq.matches ? COLS_WIDE : COLS_NARROW; 153 lastQ.fill(-1); 154 }; 155 wideMq.addEventListener("change", relayout, opts); 156 157 const frame = (t) => { 158 // Turntable: rotate the entire terrain around its Z axis. 159 grid.style.transform = boardTransform(45 + t * ROT_SPEED); 160 for (let i = 0; i < dots.length; i++) { 161 const col = i % cols; 162 const row = (i / cols) | 0; 163 const wave = 0.5 + 0.5 * Math.sin(t * WAVE_SPEED - (col + row) * 0.35); 164 const shimmer = 0.5 + 0.5 * Math.sin(t * SHIMMER_SPEED + col * 0.5 - row * 0.3); 165 const energy = clamp01(0.12 + base[i] * 0.5 + wave * 0.45); 166 const q = Math.round(energy * QUANT); 167 const hueQ = Math.round(shimmer * 24) / 24; 168 if (q === lastQ[i] && hueQ === lastHue[i]) continue; 169 lastQ[i] = q; 170 lastHue[i] = hueQ; 171 paintDot(dots[i], q / QUANT, hueQ); 172 } 173 }; 174 175 let raf = 0; 176 let visible = true; 177 178 const loop = (ms) => { 179 frame(ms / 1000); 180 if (visible) raf = requestAnimationFrame(loop); 181 }; 182 183 if (reduced) { 184 // Static 3D render: elevation from activity, fixed neon tint, no motion. 185 dots.forEach((el, i) => { 186 const col = i % cols; 187 const row = (i / cols) | 0; 188 paintDot(el, clamp01(0.15 + base[i] * 0.85), ((col + row) % 8) / 8); 189 }); 190 wideMq.addEventListener( 191 "change", 192 () => dots.forEach((el, i) => paintDot(el, clamp01(0.15 + base[i] * 0.85), (((i % cols) + ((i / cols) | 0)) % 8) / 8)), 193 opts, 194 ); 195 } else { 196 const io = new IntersectionObserver((entries) => { 197 const vis = entries.some((e) => e.isIntersecting); 198 if (vis === visible) return; 199 visible = vis; 200 if (visible) raf = requestAnimationFrame(loop); 201 else cancelAnimationFrame(raf); 202 }); 203 io.observe(grid); 204 raf = requestAnimationFrame(loop); 205 ac.signal.addEventListener("abort", () => { 206 cancelAnimationFrame(raf); 207 io.disconnect(); 208 }); 209 } 210 211 return () => { 212 ac.abort(); 213 grid.classList.remove(GRID_CLASS); 214 grid.style.transform = ""; 215 dots.forEach(clearDot); 216 styleEl.remove(); 217 }; 218}; 219 220let cleanup = null; 221let current = null; 222const init = () => { 223 const grid = document.querySelector("[data-punchcard]"); 224 if (grid === current) return; 225 if (cleanup) cleanup(); 226 current = grid; 227 cleanup = grid ? start(grid) : null; 228}; 229// Guard against import before the DOM is parsed — without this, a script tag 230// missing `defer` finds no punchcard and plain page loads never fire htmx:load. 231if (document.readyState === "loading") { 232 document.addEventListener("DOMContentLoaded", init, { once: true }); 233} else { 234 init(); 235} 236document.addEventListener("htmx:load", init);