From d26e761479cb072c698db83b208e090e21b301b7 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 23:04:30 -0400 Subject: [PATCH] feat: commit heatmap on Nexus floor Re-implements the commit activity heatmap from reference/v2-modular directly into app.js (single-file convention). A canvas-texture overlay is projected onto the floor showing per-author activity zones (Claude, Timmy, Kimi, Perplexity). Commits fetched from Gitea API every 5 min; activity decays over 24 h. Opacity pulses gently in the animation loop. Fixes #469 Co-Authored-By: Claude (Opus 4.6) --- app.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/app.js b/app.js index 9a03e40..3562e7e 100644 --- a/app.js +++ b/app.js @@ -45,6 +45,21 @@ let chatOpen = true; let loadProgress = 0; let performanceTier = 'high'; +// ═══ COMMIT HEATMAP ═══ +let heatmapMesh = null; +let heatmapMat = null; +let heatmapTexture = null; +const _heatmapCanvas = document.createElement('canvas'); +_heatmapCanvas.width = 512; +_heatmapCanvas.height = 512; +const HEATMAP_ZONES = [ + { name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 }, + { name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 }, + { name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 }, + { name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 }, +]; +const _heatZoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + // ═══ NAVIGATION SYSTEM ═══ const NAV_MODES = ['walk', 'orbit', 'fly']; let navModeIdx = 0; @@ -92,6 +107,7 @@ async function init() { createLighting(); updateLoad(40); createFloor(); + createCommitHeatmap(); updateLoad(50); createBatcaveTerminal(); updateLoad(60); @@ -332,6 +348,94 @@ function createFloor() { scene.add(ring); } +// ═══ COMMIT HEATMAP FUNCTIONS ═══ +function createCommitHeatmap() { + heatmapTexture = new THREE.CanvasTexture(_heatmapCanvas); + heatmapMat = new THREE.MeshBasicMaterial({ + map: heatmapTexture, + transparent: true, + opacity: 0.9, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + heatmapMesh = new THREE.Mesh(new THREE.CircleGeometry(24, 64), heatmapMat); + heatmapMesh.rotation.x = -Math.PI / 2; + heatmapMesh.position.y = 0.005; + scene.add(heatmapMesh); + // Kick off first fetch; subsequent updates every 5 min + updateHeatmap(); + setInterval(updateHeatmap, 5 * 60 * 1000); +} + +function drawHeatmap() { + const ctx = _heatmapCanvas.getContext('2d'); + const cx = 256, cy = 256, r = 246; + const SPAN = Math.PI / 2; + ctx.clearRect(0, 0, 512, 512); + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.clip(); + for (const zone of HEATMAP_ZONES) { + const intensity = _heatZoneIntensity[zone.name] || 0; + if (intensity < 0.01) continue; + const [rr, gg, bb] = zone.color; + const baseRad = zone.angleDeg * (Math.PI / 180); + const gx = cx + Math.cos(baseRad) * r * 0.55; + const gy = cy + Math.sin(baseRad) * r * 0.55; + const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75); + grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`); + grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`); + grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, r, baseRad - SPAN / 2, baseRad + SPAN / 2); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + if (intensity > 0.05) { + ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`; + ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(zone.name, cx + Math.cos(baseRad) * r * 0.62, cy + Math.sin(baseRad) * r * 0.62); + } + } + ctx.restore(); + if (heatmapTexture) heatmapTexture.needsUpdate = true; +} + +async function updateHeatmap() { + let commits = []; + try { + const res = await fetch( + 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50', + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (res.ok) commits = await res.json(); + } catch { /* network error — use zero baseline */ } + + const DECAY_MS = 24 * 60 * 60 * 1000; + const now = Date.now(); + const raw = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + for (const commit of commits) { + const author = commit.commit?.author?.name || commit.author?.login || ''; + const ts = new Date(commit.commit?.author?.date || 0).getTime(); + const age = now - ts; + if (age > DECAY_MS) continue; + const weight = 1 - age / DECAY_MS; + for (const zone of HEATMAP_ZONES) { + if (zone.authorMatch.test(author)) { raw[zone.name] += weight; break; } + } + } + const MAX_W = 8; + for (const zone of HEATMAP_ZONES) { + _heatZoneIntensity[zone.name] = Math.min(raw[zone.name] / MAX_W, 1.0); + } + drawHeatmap(); +} + // ═══ BATCAVE TERMINAL ═══ function createBatcaveTerminal() { const terminalGroup = new THREE.Group(); @@ -1408,6 +1512,9 @@ function gameLoop() { const sky = scene.getObjectByName('skybox'); if (sky) sky.material.uniforms.uTime.value = elapsed; + // Pulse heatmap opacity + if (heatmapMat) heatmapMat.opacity = 0.75 + Math.sin(elapsed * 0.6) * 0.2; + batcaveTerminals.forEach(t => { if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed; }); -- 2.43.0