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