diff --git a/app.js b/app.js index 7765897..9289308 100644 --- a/app.js +++ b/app.js @@ -1444,6 +1444,28 @@ function animate() { updateLightningArcs(); } + // Time-lapse replay tick + if (timelapseActive) { + const realElapsed = elapsed - timelapseRealStart; + timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0); + const span = timelapseWindow.endMs - timelapseWindow.startMs; + const virtualMs = timelapseWindow.startMs + span * timelapseProgress; + + // Fire commit events for commits we've reached in virtual time + while ( + timelapseNextCommitIdx < timelapseCommits.length && + timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs + ) { + fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]); + timelapseNextCommitIdx++; + } + + updateTimelapseHeatmap(virtualMs); + updateTimelapseHUD(timelapseProgress, virtualMs); + + if (timelapseProgress >= 1.0) stopTimelapse(); + } + composer.render(); } @@ -3169,6 +3191,171 @@ function showTimmySpeech(text) { timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; } +// === TIME-LAPSE MODE === +// Press 'L' (or click ⏩ in the HUD) to replay a day of Nexus commit activity +// compressed into 30 real seconds. A HUD clock scrubs 00:00 → 23:59 while the +// heatmap and shockwave effects fire in sync with each commit. + +const TIMELAPSE_DURATION_S = 30; // real seconds = one full virtual day + +let timelapseActive = false; +let timelapseRealStart = 0; // clock.getElapsedTime() when replay began +let timelapseProgress = 0; // 0..1 + +/** @type {Array<{ts: number, author: string, message: string, hash: string}>} */ +let timelapseCommits = []; + +/** Virtual day window: midnight-to-now of today. */ +let timelapseWindow = { startMs: 0, endMs: 0 }; + +/** Index of the next commit not yet fired. */ +let timelapseNextCommitIdx = 0; + +const timelapseIndicator = document.getElementById('timelapse-indicator'); +const timelapseClock = document.getElementById('timelapse-clock'); +const timelapseBarEl = document.getElementById('timelapse-bar'); +const timelapseBtnEl = document.getElementById('timelapse-btn'); + +/** + * Loads today's commits from the Gitea API for replay. + */ +async function loadTimelapseData() { + 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) throw new Error('fetch failed'); + const data = await res.json(); + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + + timelapseCommits = data + .map(c => ({ + ts: new Date(c.commit?.author?.date || 0).getTime(), + author: c.commit?.author?.name || c.author?.login || 'unknown', + message: (c.commit?.message || '').split('\n')[0], + hash: (c.sha || '').slice(0, 7), + })) + .filter(c => c.ts >= midnight.getTime()) + .sort((a, b) => a.ts - b.ts); + } catch { + timelapseCommits = []; + } + + // Always replay midnight-to-now so the clock reads as a natural day + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() }; +} + +/** + * Fires the visual event for a single replayed commit. + * @param {{ ts: number, author: string, message: string, hash: string }} commit + */ +function fireTimelapseCommit(commit) { + // Spike the matching agent zone briefly + const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author)); + if (zone) { + zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4); + } + // Shockwave from the commit landing + triggerShockwave(); +} + +/** + * Recalculates heatmap zone intensities from commits within a trailing window + * ending at virtualMs. Uses a 90-virtual-minute half-life so recent commits + * stay lit while older ones fade. + * @param {number} virtualMs + */ +function updateTimelapseHeatmap(virtualMs) { + const WINDOW_MS = 90 * 60 * 1000; // 90 virtual minutes + const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); + + for (const commit of timelapseCommits) { + if (commit.ts > virtualMs) break; // array is sorted + const age = virtualMs - commit.ts; + if (age > WINDOW_MS) continue; + const weight = 1 - age / WINDOW_MS; + for (const zone of HEATMAP_ZONES) { + if (zone.authorMatch.test(commit.author)) { + rawWeights[zone.name] += weight; + break; + } + } + } + + const MAX_WEIGHT = 4; + for (const zone of HEATMAP_ZONES) { + zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); + } + drawHeatmap(); +} + +/** + * Updates the time-lapse HUD clock and progress bar. + * @param {number} progress 0..1 + * @param {number} virtualMs + */ +function updateTimelapseHUD(progress, virtualMs) { + if (timelapseClock) { + const d = new Date(virtualMs); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + timelapseClock.textContent = `${hh}:${mm}`; + } + if (timelapseBarEl) { + timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`; + } +} + +/** + * Starts time-lapse mode: fetches data, resets state, shows HUD. + */ +async function startTimelapse() { + if (timelapseActive) return; + await loadTimelapseData(); + timelapseActive = true; + timelapseRealStart = clock.getElapsedTime(); + timelapseProgress = 0; + timelapseNextCommitIdx = 0; + + // Clear heatmap to zero — driven entirely by replay + for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0; + drawHeatmap(); + + if (timelapseIndicator) timelapseIndicator.classList.add('visible'); + if (timelapseBtnEl) timelapseBtnEl.classList.add('active'); +} + +/** + * Stops time-lapse mode and restores the live heatmap. + */ +function stopTimelapse() { + if (!timelapseActive) return; + timelapseActive = false; + if (timelapseIndicator) timelapseIndicator.classList.remove('visible'); + if (timelapseBtnEl) timelapseBtnEl.classList.remove('active'); + // Restore normal heatmap + updateHeatmap(); +} + +// Key binding: L to toggle, Esc to stop +document.addEventListener('keydown', (e) => { + if (e.key === 'l' || e.key === 'L') { + if (timelapseActive) stopTimelapse(); else startTimelapse(); + } + if (e.key === 'Escape' && timelapseActive) stopTimelapse(); +}); + +// HUD button +if (timelapseBtnEl) { + timelapseBtnEl.addEventListener('click', () => { + if (timelapseActive) stopTimelapse(); else startTimelapse(); + }); +} + // === BITCOIN BLOCK HEIGHT === // Polls blockstream.info every 60 s for the current tip block height. // Shows a flash animation when the block number increments. diff --git a/index.html b/index.html index 7625932..f4d3aba 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,9 @@ + @@ -67,6 +70,14 @@ Lempster NH + +
+ ⏩ TIME-LAPSE + 00:00 +
+ [L] or [Esc] to stop +
+