diff --git a/app.js b/app.js index 540375d..9e66543 100644 --- a/app.js +++ b/app.js @@ -1027,6 +1027,14 @@ function startWarp() { // === ANIMATION LOOP === const clock = new THREE.Clock(); +// Time-lapse state — populated by enterTimelapse() +let timelapseActive = false; +/** @type {Array<{ts:number, hash:string, message:string, author:string}>} */ +let timelapseCommits = []; +let timelapseStartReal = 0; +let timelapseStartVirtual = 0; +let timelapseNextIdx = 0; + /** * Main animation loop — called each frame via requestAnimationFrame. * @returns {void} @@ -1199,6 +1207,49 @@ function animate() { } } + // Time-lapse update — recompute heatmap and fire commit banners at virtual time + if (timelapseActive) { + const TIMELAPSE_REAL_DURATION = 30; + const TIMELAPSE_VIRTUAL_DURATION = 24 * 60 * 60 * 1000; + const realElapsed = elapsed - timelapseStartReal; + const progress = Math.min(realElapsed / TIMELAPSE_REAL_DURATION, 1.0); + const virtualNow = timelapseStartVirtual + progress * TIMELAPSE_VIRTUAL_DURATION; + + // Fire commit banners as virtual time reaches each commit + while (timelapseNextIdx < timelapseCommits.length && + timelapseCommits[timelapseNextIdx].ts <= virtualNow) { + const c = timelapseCommits[timelapseNextIdx]; + _timelapseFire(c); + timelapseNextIdx++; + } + + // Recompute heatmap for all commits up to virtual now + const _rawW = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + for (const commit of timelapseCommits) { + if (commit.ts > virtualNow) break; + const age = virtualNow - commit.ts; + if (age > HEATMAP_DECAY_MS) continue; + const w = 1 - age / HEATMAP_DECAY_MS; + for (const zone of HEATMAP_ZONES) { + if (zone.authorMatch.test(commit.author)) { _rawW[zone.name] += w; break; } + } + } + for (const zone of HEATMAP_ZONES) { + zoneIntensity[zone.name] = Math.min(_rawW[zone.name] / 8, 1.0); + } + drawHeatmap(); + + // Update HUD clock and progress bar + const _vd = new Date(virtualNow); + const _timeStr = _vd.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + const _clockEl = document.getElementById('timelapse-clock'); + const _barEl = document.getElementById('timelapse-progress-bar'); + if (_clockEl) _clockEl.textContent = _timeStr; + if (_barEl) _barEl.style.width = `${progress * 100}%`; + + if (progress >= 1.0) exitTimelapse(); + } + composer.render(); } @@ -2551,3 +2602,177 @@ async function fetchBlockHeight() { fetchBlockHeight(); setInterval(fetchBlockHeight, 60000); + +// === TIME-LAPSE MODE === +// Replay a day of Nexus activity in 30 seconds. +// Press 'L' to enter/exit. Fetches today's commits, redraws the heatmap in +// fast-forward, and fires commit banners as each commit's virtual timestamp +// is reached. + +const TIMELAPSE_REAL_DURATION = 30; // real seconds for a full 24-h replay +const TIMELAPSE_VIRTUAL_DURATION = 24 * 60 * 60 * 1000; // 24 h in ms + +/** @type {Array} */ +const timelapseBanners = []; + +/** + * Fires a brief floating banner for a single commit during time-lapse playback. + * @param {{ts:number, hash:string, message:string, author:string}} commit + */ +function _timelapseFire(commit) { + const texture = createCommitTexture(commit.hash, commit.message); + const mat = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(10, 1.25, 1); + + // Place near the relevant heatmap zone's direction + const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author)); + const angleDeg = zone ? zone.angleDeg : Math.random() * 360; + const angleRad = angleDeg * (Math.PI / 180); + sprite.position.set( + Math.cos(angleRad) * 5.5, + 0.5 + Math.random() * 2.5, + Math.sin(angleRad) * 5.5 + ); + + sprite.userData = { + spawnTime: clock.getElapsedTime(), + lifetime: 1.8, + }; + + scene.add(sprite); + timelapseBanners.push(sprite); +} + +/** + * Fetches commits from the last 24 h and returns them sorted oldest-first. + * @returns {Promise<{commits: Array, dayStart: number}>} + */ +async function loadTimelapseData() { + const now = Date.now(); + const dayStart = now - TIMELAPSE_VIRTUAL_DURATION; + let commits = []; + + try { + const res = await fetch( + 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=100', + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (res.ok) { + const data = await res.json(); + for (const c of data) { + const ts = new Date(c.commit?.author?.date || 0).getTime(); + if (ts >= dayStart) { + commits.push({ + ts, + hash: (c.sha || '').slice(0, 7), + message: (c.commit?.message || '').split('\n')[0], + author: c.commit?.author?.name || c.author?.login || '', + }); + } + } + } + } catch { /* use empty array */ } + + // If no recent commits, seed with stub entries spread across the day + if (commits.length === 0) { + const stubs = [ + { author: 'claude', message: 'feat: time-lapse replay mode' }, + { author: 'Timmy', message: 'chore: update sovereignty status' }, + { author: 'kimi', message: 'feat: portal system YAML registry' }, + { author: 'claude', message: 'fix: heatmap decay precision' }, + { author: 'Timmy', message: 'docs: update SOUL.md' }, + ]; + stubs.forEach((s, i) => { + commits.push({ + ts: dayStart + (i + 1) * (TIMELAPSE_VIRTUAL_DURATION / (stubs.length + 1)), + hash: 'demo' + i, + message: s.message, + author: s.author, + }); + }); + } + + commits.sort((a, b) => a.ts - b.ts); + return { commits, dayStart }; +} + +/** + * Enters time-lapse mode: fetches commits and starts the 30-second replay. + */ +async function enterTimelapse() { + if (timelapseActive) return; + + const { commits, dayStart } = await loadTimelapseData(); + timelapseCommits = commits; + timelapseStartReal = clock.getElapsedTime(); + timelapseStartVirtual = dayStart; + timelapseNextIdx = 0; + timelapseActive = true; + + // Reset zone intensities so heatmap builds from scratch + for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0; + drawHeatmap(); + + const indicator = document.getElementById('timelapse-indicator'); + if (indicator) indicator.classList.add('visible'); + + const barEl = document.getElementById('timelapse-progress-bar'); + if (barEl) barEl.style.width = '0%'; +} + +/** + * Exits time-lapse mode and restores the live heatmap. + */ +function exitTimelapse() { + if (!timelapseActive) return; + timelapseActive = false; + + // Clean up any leftover banner sprites + for (const s of timelapseBanners) scene.remove(s); + timelapseBanners.length = 0; + + const indicator = document.getElementById('timelapse-indicator'); + if (indicator) indicator.classList.remove('visible'); + + // Restore live heatmap + updateHeatmap(); +} + +// Animate timelapse banners (called from each animate() frame via the outer block) +// — these need processing even outside the timelapse-active gate so they can finish fading +setInterval(() => { + const elapsed = clock.getElapsedTime(); + for (let i = timelapseBanners.length - 1; i >= 0; i--) { + const s = timelapseBanners[i]; + const age = elapsed - s.userData.spawnTime; + const lt = s.userData.lifetime; + const FADE = 0.25; + let op; + if (age < FADE) { + op = age / FADE; + } else if (age < lt - FADE) { + op = 1; + } else if (age < lt) { + op = (lt - age) / FADE; + } else { + scene.remove(s); + timelapseBanners.splice(i, 1); + continue; + } + s.material.opacity = op; + } +}, 16); + +// Key binding: L for time-Lapse +document.addEventListener('keydown', (e) => { + if (e.key === 'l' || e.key === 'L') { + if (timelapseActive) exitTimelapse(); else enterTimelapse(); + } + if (e.key === 'Escape' && timelapseActive) exitTimelapse(); +}); diff --git a/index.html b/index.html index a6db193..b38273a 100644 --- a/index.html +++ b/index.html @@ -61,6 +61,14 @@ [Esc] or double-click to exit +
+
+ TIME-LAPSE + [L] exit  |  12:00 AM +
+
+
+