This repository has no description
12 kB
349 lines
1// Draggable 3D punchcard, rendered on a canvas. We measure the real commit
2// dots once (position, size, colour, and an activity-based depth), hide the
3// original grid, and redraw the dots ourselves as a tilted 3D plane you can
4// grab and spin. Doing the projection in JS + canvas keeps 365 dots cheap where
5// per-dot CSS transforms did not. Self-contained, vanilla ES module.
6
7const REDUCED = matchMedia("(prefers-reduced-motion: reduce)");
8const DEG = Math.PI / 180;
9
10const PERSPECTIVE = 1e6; // effectively infinite: near-orthographic, so the grid
11 // looks identical to the flat default until you rotate
12const DEPTH = 46; // px — pillar height for the most active day
13const SHAFT_SHADE = 0.72; // shaft/base sit a touch darker than the lit top cap
14const HUE_SHIFT = 5; // max degrees the hue drifts (toward yellow/blue) at full yaw
15const GROUND_RING = 0.18; // outline thickness of ground dots, as a fraction of radius
16const GHOST_COLOR = "rgba(128,128,128,0.5)"; // fallback outline for empty days
17const DRAG_SENS = 0.45; // degrees of rotation per pixel dragged
18const REST_TILT_X = 0; // resting pitch the grid springs back to
19const REST_TILT_Y = 0; // resting yaw the grid springs back to
20const STIFFNESS = 0.08; // spring pull back toward the resting tilt
21const DAMPING = 0.82; // spring velocity decay per frame
22const MAX_ANGLE = 88; // clamp every rotation axis to just under 90°
23
24const clampAngle = (v) => Math.max(-MAX_ANGLE, Math.min(MAX_ANGLE, v));
25
26let grid = null;
27let canvas = null;
28let ctx = null;
29let dpr = 1;
30let cx = 0; // grid centre, in CSS px
31let cy = 0;
32let dots = []; // { x, y, h, r, rgb } — x/y centred on the grid centre
33let ghosts = []; // { x, y, r, color } — empty days, drawn as flat ground outlines
34let rotX = REST_TILT_X;
35let rotY = REST_TILT_Y;
36let velX = 0;
37let velY = 0;
38let dragging = false;
39let lastX = 0;
40let lastY = 0;
41let raf = 0;
42
43// Parse a dot's fill to [r,g,b]; null if fully transparent (empty day).
44function parseColor(str) {
45 const m = str.match(/[\d.]+/g);
46 if (!m) return null;
47 const [r, g, b, a = 1] = m.map(Number);
48 return a === 0 ? null : [r, g, b];
49}
50
51// Chroma is a theme-agnostic proxy for activity: grey empty days sit flat,
52// saturated active days pop toward the viewer.
53function activity([r, g, b]) {
54 return (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
55}
56
57// Rotate an [r,g,b]'s hue by `deg` degrees, preserving saturation/lightness.
58// deg 0 returns the colour untouched, and greys (no hue) are left as-is.
59function shiftHue([r, g, b], deg) {
60 if (!deg) return [r, g, b];
61 const rn = r / 255, gn = g / 255, bn = b / 255;
62 const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
63 const c = max - min;
64 if (c === 0) return [r, g, b];
65 const l = (max + min) / 2;
66 const s = l > 0.5 ? c / (2 - max - min) : c / (max + min);
67 let h;
68 if (max === rn) h = (gn - bn) / c + (gn < bn ? 6 : 0);
69 else if (max === gn) h = (bn - rn) / c + 2;
70 else h = (rn - gn) / c + 4;
71 h = (((h * 60 + deg) % 360) + 360) % 360 / 360;
72 const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
73 const p = 2 * l - q;
74 const chan = (t) => {
75 if (t < 0) t += 1;
76 if (t > 1) t -= 1;
77 if (t < 1 / 6) return p + (q - p) * 6 * t;
78 if (t < 1 / 2) return q;
79 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
80 return p;
81 };
82 return [
83 Math.round(chan(h + 1 / 3) * 255),
84 Math.round(chan(h) * 255),
85 Math.round(chan(h - 1 / 3) * 255),
86 ];
87}
88
89// Snapshot dot geometry/colour off the live DOM and size the canvas to match.
90function measure() {
91 const gr = grid.getBoundingClientRect();
92 cx = gr.width / 2;
93 cy = gr.height / 2;
94
95 ghosts = [];
96 dots = Array.from(grid.children)
97 .map((cell) => {
98 const el = cell.firstElementChild;
99 if (!el || el === canvas) return null;
100 const cs = getComputedStyle(el);
101 const rect = el.getBoundingClientRect();
102 const x = rect.left - gr.left + rect.width / 2 - cx;
103 const y = rect.top - gr.top + rect.height / 2 - cy;
104 const rad = Math.min(rect.width, rect.height) / 2;
105 const rgb = parseColor(cs.backgroundColor);
106 if (!rgb) {
107 // Empty day: keep it as a flat outline on the ground plane, matching
108 // the real dot's border colour and thickness.
109 const bc = parseColor(cs.borderTopColor);
110 const bw = parseFloat(cs.borderTopWidth) || 0;
111 ghosts.push({
112 x, y, r: rad,
113 color: bc ? `rgb(${bc[0]},${bc[1]},${bc[2]})` : GHOST_COLOR,
114 ring: bw > 0 ? Math.min(bw / rad, 0.9) : GROUND_RING,
115 });
116 return null;
117 }
118 return { x, y, h: activity(rgb) * DEPTH, r: rad, rgb };
119 })
120 .filter(Boolean);
121
122 dpr = window.devicePixelRatio || 1;
123 canvas.width = Math.round(gr.width * dpr);
124 canvas.height = Math.round(gr.height * dpr);
125 canvas.style.width = gr.width + "px";
126 canvas.style.height = gr.height + "px";
127 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
128}
129
130// Rotate a plane point (x, y, z) into camera space, then perspective-project
131// it to screen. Returns null if it lands behind the camera.
132function project(x, y, z, sinX, cosX, sinY, cosY) {
133 // rotateY then rotateX, matching CSS `rotateX(..) rotateY(..)`.
134 const x1 = x * cosY + z * sinY;
135 const z1 = -x * sinY + z * cosY;
136 const y2 = y * cosX - z1 * sinX;
137 const z2 = y * sinX + z1 * cosX;
138 const denom = PERSPECTIVE - z2;
139 if (denom <= 1) return null; // at or behind the camera
140 const s = PERSPECTIVE / denom;
141 return { sx: cx + x1 * s, sy: cy + y2 * s, s, z: z2 };
142}
143
144// Draw a dot's disc at height z as a filled ellipse — the true perspective
145// projection of a circle lying flat in the grid plane, so it foreshortens with
146// tilt instead of always facing the camera. `c` is the already-projected centre.
147function drawCap(c, d, z, color, trig) {
148 const u = project(d.x + d.r, d.y, z, trig[0], trig[1], trig[2], trig[3]);
149 const v = project(d.x, d.y + d.r, z, trig[0], trig[1], trig[2], trig[3]);
150 if (!u || !v) return;
151 ctx.fillStyle = color;
152 ctx.save();
153 // Map the unit circle through the two projected radius vectors.
154 ctx.transform(u.sx - c.sx, u.sy - c.sy, v.sx - c.sx, v.sy - c.sy, c.sx, c.sy);
155 ctx.beginPath();
156 ctx.arc(0, 0, 1, 0, Math.PI * 2);
157 ctx.fill();
158 ctx.restore();
159}
160
161// Draw a height-0 dot as a foreshortened ring lying flat on the ground plane.
162// `c` is the already-projected centre.
163function drawRing(c, x, y, r, color, ring, trig) {
164 const u = project(x + r, y, 0, trig[0], trig[1], trig[2], trig[3]);
165 const v = project(x, y + r, 0, trig[0], trig[1], trig[2], trig[3]);
166 if (!u || !v) return;
167 ctx.fillStyle = color;
168 ctx.save();
169 ctx.transform(u.sx - c.sx, u.sy - c.sy, v.sx - c.sx, v.sy - c.sy, c.sx, c.sy);
170 ctx.beginPath();
171 ctx.arc(0, 0, 1, 0, Math.PI * 2); // outer edge
172 ctx.arc(0, 0, 1 - ring, 0, Math.PI * 2, true); // inner edge (hole)
173 ctx.fill();
174 ctx.restore();
175}
176
177// Project each pillar (base at z=0, top at z=h) and draw back-to-front.
178function render() {
179 if (!ctx) return;
180 const rx = rotX * DEG;
181 const ry = rotY * DEG;
182 const trig = [Math.sin(rx), Math.cos(rx), Math.sin(ry), Math.cos(ry)];
183
184 const drawn = [];
185 for (let i = 0; i < dots.length; i++) {
186 const d = dots[i];
187 const b = project(d.x, d.y, 0, trig[0], trig[1], trig[2], trig[3]);
188 const t = project(d.x, d.y, d.h, trig[0], trig[1], trig[2], trig[3]);
189 if (!b || !t) continue;
190 drawn.push({ d, b, t, z: b.z }); // sort by base depth: far rows drawn first
191 }
192 for (let i = 0; i < ghosts.length; i++) {
193 const g = ghosts[i];
194 const c = project(g.x, g.y, 0, trig[0], trig[1], trig[2], trig[3]);
195 if (!c) continue;
196 drawn.push({ g, c, z: c.z });
197 }
198 drawn.sort((a, b) => a.z - b.z);
199
200 // Hue drifts toward yellow/blue with yaw; exactly zero (green) when centred.
201 const hueDelta = (rotY / MAX_ANGLE) * HUE_SHIFT;
202
203 ctx.clearRect(0, 0, cx * 2, cy * 2);
204 for (let i = 0; i < drawn.length; i++) {
205 if (drawn[i].g) {
206 const { g, c } = drawn[i];
207 drawRing(c, g.x, g.y, g.r, g.color, g.ring, trig);
208 continue;
209 }
210 const { d, b, t } = drawn[i];
211 const [cr, cg, cb] = shiftHue(d.rgb, hueDelta);
212 const topColor = `rgb(${cr},${cg},${cb})`;
213 const shaftColor = `rgb(${Math.round(cr * SHAFT_SHADE)},${Math.round(cg * SHAFT_SHADE)},${Math.round(cb * SHAFT_SHADE)})`;
214 // Base cap first (farthest), in the pillar-body colour.
215 drawCap(b, d, 0, shaftColor, trig);
216 // Shaft: a tapered quad from the base circle to the top circle.
217 const ax = t.sx - b.sx, ay = t.sy - b.sy;
218 const len = Math.hypot(ax, ay) || 1;
219 const nx = -ay / len, ny = ax / len; // screen-space perpendicular
220 const rB = d.r * b.s, rT = d.r * t.s;
221 ctx.fillStyle = shaftColor;
222 ctx.beginPath();
223 ctx.moveTo(b.sx + nx * rB, b.sy + ny * rB);
224 ctx.lineTo(t.sx + nx * rT, t.sy + ny * rT);
225 ctx.lineTo(t.sx - nx * rT, t.sy - ny * rT);
226 ctx.lineTo(b.sx - nx * rB, b.sy - ny * rB);
227 ctx.closePath();
228 ctx.fill();
229 // Top cap last (nearest), as the lit colour.
230 drawCap(t, d, d.h, topColor, trig);
231 }
232}
233
234// Damped spring back to the resting tilt, seeded with the drag's leftover
235// velocity so it eases home with a little overshoot.
236function recenter() {
237 raf = 0;
238 if (dragging) return;
239 velX = (velX + (REST_TILT_X - rotX) * STIFFNESS) * DAMPING;
240 velY = (velY + (REST_TILT_Y - rotY) * STIFFNESS) * DAMPING;
241 rotX += velX;
242 rotY += velY;
243 render();
244 const settled =
245 Math.abs(velX) < 0.02 && Math.abs(velY) < 0.02 &&
246 Math.abs(rotX - REST_TILT_X) < 0.05 && Math.abs(rotY - REST_TILT_Y) < 0.05;
247 if (settled) {
248 rotX = REST_TILT_X;
249 rotY = REST_TILT_Y;
250 render();
251 } else {
252 raf = requestAnimationFrame(recenter);
253 }
254}
255
256function onDown(e) {
257 dragging = true;
258 velX = velY = 0;
259 if (raf) cancelAnimationFrame(raf), (raf = 0);
260 lastX = e.clientX;
261 lastY = e.clientY;
262 canvas.style.cursor = "grabbing";
263 if (e.pointerId != null) canvas.setPointerCapture(e.pointerId);
264 e.preventDefault();
265}
266
267function onMove(e) {
268 if (!dragging) return;
269 const dx = e.clientX - lastX;
270 const dy = e.clientY - lastY;
271 lastX = e.clientX;
272 lastY = e.clientY;
273 rotY = clampAngle(rotY + dx * DRAG_SENS);
274 rotX = clampAngle(rotX - dy * DRAG_SENS);
275 velX = -dy * DRAG_SENS; // remember last motion to fling on release
276 velY = dx * DRAG_SENS;
277 render();
278}
279
280function onUp(e) {
281 if (!dragging) return;
282 dragging = false;
283 canvas.style.cursor = "grab";
284 if (e && e.pointerId != null && canvas.hasPointerCapture(e.pointerId)) {
285 canvas.releasePointerCapture(e.pointerId);
286 }
287 // Spring back to the resting tilt, carrying the leftover drag velocity.
288 raf = requestAnimationFrame(recenter);
289}
290
291function mount() {
292 const next = document.querySelector("[data-punchcard]");
293 if (next === grid && grid) return; // already wired to this element
294 if (raf) cancelAnimationFrame(raf), (raf = 0);
295 grid = next;
296 if (!grid) return;
297
298 if (getComputedStyle(grid).position === "static") {
299 grid.style.position = "relative";
300 }
301
302 if (!canvas || canvas.parentElement !== grid) {
303 canvas = document.createElement("canvas");
304 canvas.dataset.punchcardFx = "";
305 Object.assign(canvas.style, {
306 position: "absolute",
307 left: "0",
308 top: "0",
309 touchAction: "none",
310 });
311 ctx = canvas.getContext("2d");
312 grid.appendChild(canvas);
313 }
314
315 measure();
316
317 // Hide the originals but keep them occupying space, so the grid keeps its
318 // size and our canvas has the same footprint.
319 for (const cell of grid.children) {
320 if (cell !== canvas) cell.style.visibility = "hidden";
321 }
322
323 rotX = REST_TILT_X;
324 rotY = REST_TILT_Y;
325 velX = velY = 0;
326 render();
327
328 // Reduced motion: render the static tilted relief but wire no dragging.
329 if (REDUCED.matches) {
330 canvas.style.cursor = "";
331 return;
332 }
333 canvas.style.cursor = "grab";
334 canvas.addEventListener("pointerdown", onDown);
335 canvas.addEventListener("pointermove", onMove);
336 canvas.addEventListener("pointerup", onUp);
337 canvas.addEventListener("pointercancel", onUp);
338 canvas.addEventListener("lostpointercapture", onUp);
339}
340
341function remeasure() {
342 if (!grid || !canvas) return;
343 measure();
344 render();
345}
346
347window.addEventListener("resize", remeasure);
348document.addEventListener("htmx:load", mount);
349mount();