This repository has no description
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);