[claude] Time-lapse mode — replay a day of Nexus activity in 30 seconds (#245) #350
187
app.js
187
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.
|
||||
|
||||
11
index.html
11
index.html
@@ -36,6 +36,9 @@
|
||||
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
|
||||
📥
|
||||
</button>
|
||||
<button id="timelapse-btn" class="chat-toggle-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today's activity in 30s [L]">
|
||||
⏩
|
||||
</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +70,14 @@
|
||||
<span id="weather-desc">Lempster NH</span>
|
||||
</div>
|
||||
|
||||
<!-- TIME-LAPSE MODE indicator -->
|
||||
<div id="timelapse-indicator" aria-live="polite" aria-label="Time-lapse mode active">
|
||||
<span class="timelapse-label">⏩ TIME-LAPSE</span>
|
||||
<span id="timelapse-clock">00:00</span>
|
||||
<div class="timelapse-track"><div id="timelapse-bar"></div></div>
|
||||
<span class="timelapse-hint">[L] or [Esc] to stop</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
|
||||
93
style.css
93
style.css
@@ -435,3 +435,96 @@ body.photo-mode #overview-indicator {
|
||||
margin-top: 28px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* === TIME-LAPSE MODE === */
|
||||
#timelapse-indicator {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #00ffcc;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
border: 1px solid #00ffcc;
|
||||
padding: 6px 14px 8px;
|
||||
background: rgba(0, 8, 24, 0.85);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#timelapse-indicator.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
animation: timelapse-glow 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes timelapse-glow {
|
||||
from { box-shadow: 0 0 6px rgba(0, 255, 204, 0.3); }
|
||||
to { box-shadow: 0 0 16px rgba(0, 255, 204, 0.75); }
|
||||
}
|
||||
|
||||
.timelapse-label {
|
||||
color: #00ffcc;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#timelapse-clock {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timelapse-track {
|
||||
width: 110px;
|
||||
height: 4px;
|
||||
background: rgba(0, 255, 204, 0.18);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#timelapse-bar {
|
||||
height: 100%;
|
||||
background: #00ffcc;
|
||||
border-radius: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.12s linear;
|
||||
}
|
||||
|
||||
.timelapse-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
#timelapse-btn {
|
||||
margin-left: 8px;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#timelapse-btn:hover {
|
||||
background-color: #00664433;
|
||||
color: #00ffcc;
|
||||
}
|
||||
|
||||
#timelapse-btn.active {
|
||||
background-color: rgba(0, 255, 204, 0.15);
|
||||
color: #00ffcc;
|
||||
border: 1px solid #00ffcc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user