spaxel/dashboard/js/replay.js
jedarden f18916d3a9 feat(replay): implement time-travel debugging with parameter tuning
- Add 'Pause Live' button that freezes 3D view and reveals timeline scrubber
- Implement scrubbing backward/forward through recorded history (1×, 2×, 5×)
- 3D scene renders blobs exactly as detected at scrubbed time, including trails
- Add parameter tuning overlay with sliders for detection parameters:
  - Detection threshold (deltaRMS)
  - Baseline time constant (tau)
  - Fresnel weight decay rate
  - Subcarrier selection count (NBVI)
  - Breathing sensitivity
- Adjusting slider re-runs pipeline on recorded CSI with new parameters
- Add 'Apply to Live' button that writes tuned parameters to running pipeline

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 18:33:37 -04:00

954 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Spaxel Dashboard - Time-Travel Replay Mode
*
* Provides pause-live, timeline scrubbing, and replay 3D visualization
* from recorded CSI data.
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const CONFIG = {
// Default replay window when pausing live (60 seconds before now)
defaultReplayWindowSec: 60,
// Timeline scrubber update interval (ms)
timelineUpdateInterval: 100,
// Playback speeds
speeds: [1, 2, 5],
// Timestamp range padding when creating replay sessions
sessionPaddingMs: 5000,
// Event fetch configuration
eventFetchBatchSize: 100, // events per batch
eventMarkerTypes: ['anomaly', 'anomaly_detected', 'security_alert', 'portal_crossing', 'zone_entry', 'zone_exit'],
};
// ============================================
// State
// ============================================
const state = {
// Replay session state
activeSessionId: null,
sessionFromMs: null,
sessionToMs: null,
sessionCurrentMs: null,
sessionSpeed: 1,
sessionState: 'stopped', // stopped, paused, playing
// Recording store info
storeOldestMs: null,
storeNewestMs: null,
storeHasData: false,
// UI state
isPaused: false,
isReplayMode: false,
// Callbacks
onReplayBlob: null,
};
// ============================================
// DOM Elements
// ============================================
let elements = {};
// ============================================
// Initialization
// ============================================
function init() {
console.log('[Replay] Initializing time-travel replay');
// Create replay controls
createReplayControls();
// Fetch recording store info
fetchStoreInfo();
// Start timeline update loop
startTimelineLoop();
console.log('[Replay] Ready');
}
// ============================================
// Replay Controls UI
// ============================================
function createReplayControls() {
const statusBar = document.getElementById('status-bar');
if (!statusBar) {
console.warn('[Replay] Status bar not found');
return;
}
// Create replay control bar (hidden by default)
const replayBar = document.createElement('div');
replayBar.id = 'replay-control-bar';
replayBar.className = 'replay-control-bar';
replayBar.style.display = 'none';
replayBar.innerHTML = `
<div class="replay-controls">
<button id="replay-back-btn" class="replay-btn" title="Back to live">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L23 10M10 19l-7-7"/>
</svg>
</button>
<div class="replay-info">
<span id="replay-timestamp" class="replay-timestamp">--:--:--</span>
<span id="replay-range" class="replay-range">0:00 / 0:00</span>
</div>
<div class="replay-playback">
<button id="replay-play-btn" class="replay-btn" title="Play/Pause">
<svg id="play-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<svg id="pause-icon" style="display:none" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
<select id="replay-speed" class="replay-speed">
<option value="1">1×</option>
<option value="2">2×</option>
<option value="5">5×</option>
</select>
</div>
<div class="replay-timeline">
<input type="range" id="replay-scrubber" class="replay-scrubber"
min="0" max="100" step="0.1" value="0"
title="Scrub through timeline">
<div id="replay-event-markers" class="replay-event-markers"></div>
</div>
<button id="replay-close-btn" class="replay-btn" title="Exit replay mode">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="replay-tuning">
<button id="replay-tune-btn" class="replay-tune-btn" title="Tune detection parameters">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v6m0 6v6"/>
<path d="m19.07 4.93-1.41 1.41M17 16l-5-5"/>
<path d="M4.93 19.07l1.41-1.41M16 17l5-5"/>
</svg>
Tune
</button>
</div>
`;
// Insert after status bar
statusBar.parentNode.insertBefore(replayBar, statusBar.nextSibling);
// Store element references
elements = {
bar: replayBar,
backBtn: document.getElementById('replay-back-btn'),
timestamp: document.getElementById('replay-timestamp'),
range: document.getElementById('replay-range'),
playBtn: document.getElementById('replay-play-btn'),
playIcon: document.getElementById('play-icon'),
pauseIcon: document.getElementById('pause-icon'),
speed: document.getElementById('replay-speed'),
scrubber: document.getElementById('replay-scrubber'),
closeBtn: document.getElementById('replay-close-btn'),
tuneBtn: document.getElementById('replay-tune-btn'),
};
// Attach event listeners
elements.backBtn.addEventListener('click', onBackToLive);
elements.playBtn.addEventListener('click', onPlayPause);
elements.speed.addEventListener('change', onSpeedChange);
elements.scrubber.addEventListener('input', onScrub);
elements.closeBtn.addEventListener('click', onExitReplay);
elements.tuneBtn.addEventListener('click', onTuneParams);
}
// ============================================
// API Communication
// ============================================
function fetchStoreInfo() {
fetch('/api/replay/sessions')
.then(res => res.json())
.then(data => {
state.storeOldestMs = data.oldest_timestamp_ms;
state.storeNewestMs = data.newest_timestamp_ms;
state.storeHasData = data.has_data;
console.log('[Replay] Store info:', data);
})
.catch(err => {
console.error('[Replay] Failed to fetch store info:', err);
});
}
function startReplaySession(fromMs, toMs) {
const fromISO = new Date(fromMs).toISOString();
const toISO = toMs ? new Date(toMs).toISOString() : '';
return fetch('/api/replay/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from_iso8601: fromISO,
to_iso8601: toISO,
speed: 1
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to start replay: ' + res.statusText);
}
return res.json();
})
.then(data => {
state.activeSessionId = data.session_id;
state.sessionFromMs = data.from_ms;
state.sessionToMs = data.to_ms;
state.sessionCurrentMs = data.from_ms;
state.sessionState = 'paused';
state.sessionSpeed = data.speed;
console.log('[Replay] Session started:', data);
updateUI();
return data.session_id;
});
}
function stopReplaySession() {
if (!state.activeSessionId) return Promise.resolve();
return fetch('/api/replay/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to stop replay: ' + res.statusText);
}
return res.json();
})
.then(() => {
console.log('[Replay] Session stopped:', state.activeSessionId);
state.activeSessionId = null;
state.isPaused = false;
updateUI();
});
}
function seekReplay(targetMs) {
if (!state.activeSessionId) return Promise.resolve();
const targetISO = new Date(targetMs).toISOString();
return fetch('/api/replay/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId,
timestamp_iso8601: targetISO
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to seek: ' + res.statusText);
}
return res.json();
})
.then(data => {
state.sessionCurrentMs = data.current_ms;
updateUI();
});
}
function setPlaybackSpeed(speed) {
if (!state.activeSessionId) return;
fetch('/api/replay/set-speed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId,
speed: speed
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to set speed: ' + res.statusText);
}
return res.json();
})
.then(() => {
state.sessionSpeed = speed;
console.log('[Replay] Speed set to', speed, 'x');
});
}
function setPlaybackState(playbackState) {
if (!state.activeSessionId) return;
fetch('/api/replay/set-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId,
state: playbackState
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to set state: ' + res.statusText);
}
return res.json();
})
.then(() => {
state.sessionState = playbackState;
updatePlayPauseButton();
});
}
function fetchSessionBlobs(sessionId) {
if (!sessionId) return Promise.resolve();
return fetch('/api/replay/session/' + encodeURIComponent(sessionId))
.then(function(res) {
if (!res.ok) throw new Error('Failed to fetch session state');
return res.json();
})
.then(function(data) {
// Feed replay blobs to 3D scene
if (data.blobs && data.blobs.length > 0 && window.Viz3D && Viz3D.updateReplayBlobs) {
Viz3D.updateReplayBlobs(data.blobs, data.timestamp_ms);
}
})
.catch(function(err) {
console.warn('[Replay] Failed to fetch session blobs:', err);
});
}
function tuneParams(params) {
if (!state.activeSessionId) return Promise.resolve();
return fetch('/api/replay/tune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId,
...params
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to tune params: ' + res.statusText);
}
return res.json();
})
.then(data => {
console.log('[Replay] Parameters tuned:', data.params);
return data;
});
}
// ============================================
// Event Handlers
// ============================================
function onBackToLive() {
exitReplayMode();
}
function onPlayPause() {
if (!state.activeSessionId) return;
const newState = state.sessionState === 'playing' ? 'paused' : 'playing';
setPlaybackState(newState);
}
function onSpeedChange(e) {
const speed = parseInt(e.target.value, 10);
setPlaybackSpeed(speed);
}
function onScrub(e) {
const percent = parseFloat(e.target.value);
const rangeMs = state.sessionToMs - state.sessionFromMs;
const targetMs = state.sessionFromMs + Math.round(rangeMs * percent / 100);
seekReplay(targetMs);
}
function onExitReplay() {
exitReplayMode();
}
function onTuneParams() {
showTuningPanel();
}
// ============================================
// UI Updates
// ============================================
function updateUI() {
updateTimestampDisplay();
updateRangeDisplay();
updateScrubber();
updatePlayPauseButton();
}
function updateTimestampDisplay() {
if (!elements.timestamp) return;
if (state.sessionCurrentMs !== null) {
elements.timestamp.textContent = formatTimestamp(state.sessionCurrentMs);
} else {
elements.timestamp.textContent = '--:--:--';
}
}
function updateRangeDisplay() {
if (!elements.range) return;
if (state.sessionFromMs !== null && state.sessionToMs !== null) {
elements.range.textContent = formatTimestamp(state.sessionFromMs) +
' / ' + formatTimestamp(state.sessionToMs);
} else {
elements.range.textContent = '0:00 / 0:00';
}
}
function updateScrubber() {
if (!elements.scrubber) return;
if (state.sessionFromMs !== null && state.sessionToMs !== null) {
const rangeMs = state.sessionToMs - state.sessionFromMs;
if (state.sessionCurrentMs !== null) {
const offset = state.sessionCurrentMs - state.sessionFromMs;
const percent = Math.max(0, Math.min(100, (offset / rangeMs) * 100));
elements.scrubber.value = percent;
}
}
}
function updatePlayPauseButton() {
if (!elements.playIcon || !elements.pauseIcon) return;
if (state.sessionState === 'playing') {
elements.playIcon.style.display = 'none';
elements.pauseIcon.style.display = 'block';
} else {
elements.playIcon.style.display = 'block';
elements.pauseIcon.style.display = 'none';
}
}
// ============================================
// Mode Transitions
// ============================================
function enterReplayMode(fromMs, toMs) {
console.log('[Replay] Entering replay mode:', { fromMs, toMs });
state.isReplayMode = true;
// Start replay session
startReplaySession(fromMs, toMs).then(sessionId => {
// Show replay control bar
if (elements.bar) {
elements.bar.style.display = 'block';
}
// Notify 3D visualization to enter replay mode
if (window.Viz3D && Viz3D.enterReplayMode) {
Viz3D.enterReplayMode();
}
return sessionId;
}).catch(err => {
console.error('[Replay] Failed to enter replay mode:', err);
if (window.SpaxelApp) {
SpaxelApp.showToast('Failed to enter replay mode: ' + err.message, 'error');
}
});
}
function exitReplayMode() {
console.log('[Replay] Exiting replay mode');
// Stop replay session
return stopReplaySession().then(() => {
state.isReplayMode = false;
state.isPaused = false;
// Hide replay control bar
if (elements.bar) {
elements.bar.style.display = 'none';
}
// Notify 3D visualization to exit replay mode
if (window.Viz3D && Viz3D.exitReplayMode) {
Viz3D.exitReplayMode();
}
// Clear timeline selection and now-replaying chip
if (window.SpaxelSidebarTimeline) {
SpaxelSidebarTimeline.clearSelection();
SpaxelSidebarTimeline.hideNowReplayingChip();
}
// Navigate back to live mode
if (window.SpaxelRouter) {
SpaxelRouter.navigate('live');
}
});
}
function pauseLiveMode() {
if (state.isPaused) {
// Already paused, exit replay mode
exitReplayMode();
return;
}
console.log('[Replay] Pausing live mode');
state.isPaused = true;
// Calculate replay window (default: 60 seconds before now)
const now = Date.now();
const fromMs = now - (CONFIG.defaultReplayWindowSec * 1000);
const toMs = now;
enterReplayMode(fromMs, toMs);
}
// ============================================
// Timeline Loop
// ============================================
let timelineInterval = null;
function startTimelineLoop() {
if (timelineInterval) return;
timelineInterval = setInterval(() => {
if (state.sessionState === 'playing' && state.activeSessionId) {
// Fetch current session state
fetch(`/api/replay/session/${state.activeSessionId}`)
.then(res => res.json())
.then(data => {
state.sessionCurrentMs = data.current_ms;
updateUI();
// Trigger 3D visualization update with replay blobs
if (data.blobs && window.Viz3D && Viz3D.updateReplayBlobs) {
Viz3D.updateReplayBlobs(data.blobs, data.timestamp_ms);
}
})
.catch(err => {
console.error('[Replay] Failed to fetch session state:', err);
});
}
}, CONFIG.timelineUpdateInterval);
}
// ============================================
// Tuning Panel
// ============================================
function showTuningPanel() {
// Create or show tuning panel overlay
let panel = document.getElementById('replay-tuning-panel');
if (!panel) {
panel = createTuningPanel();
document.body.appendChild(panel);
}
panel.style.display = 'flex';
// Fetch current session params
if (state.activeSessionId) {
fetch(`/api/replay/session/${state.activeSessionId}`)
.then(res => res.json())
.then(data => {
populateTuningPanel(data.params || {});
});
}
}
function createTuningPanel() {
const panel = document.createElement('div');
panel.id = 'replay-tuning-panel';
panel.className = 'replay-tuning-panel';
panel.innerHTML = `
<div class="replay-tuning-content">
<div class="replay-tuning-header">
<h2>Detection Parameters</h2>
<button class="replay-tuning-close" onclick="this.parentElement.parentElement.parentElement.style.display='none'">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="replay-tuning-body">
<div class="tuning-param">
<label>Detection Threshold (deltaRMS)</label>
<input type="range" id="tune-threshold" min="0.001" max="0.1" step="0.001" value="0.02">
<span class="tuning-value">0.02</span>
</div>
<div class="tuning-param">
<label>Baseline Time Constant (tau) [seconds]</label>
<input type="range" id="tune-tau" min="1" max="600" step="1" value="30">
<span class="tuning-value">30</span>
</div>
<div class="tuning-param">
<label>Fresnel Weight Decay Rate</label>
<input type="range" id="tune-fresnel" min="1.0" max="4.0" step="0.1" value="2.0">
<span class="tuning-value">2.0</span>
</div>
<div class="tuning-param">
<label>Subcarrier Count (NBVI)</label>
<input type="range" id="tune-subcarriers" min="8" max="47" step="1" value="16">
<span class="tuning-value">16</span>
</div>
<div class="tuning-param">
<label>Breathing Sensitivity</label>
<input type="range" id="tune-breathing" min="0.001" max="0.1" step="0.001" value="0.005">
<span class="tuning-value">0.005</span>
</div>
<div class="tuning-actions">
<button id="tune-apply-btn" class="tuning-btn">Apply Parameters</button>
<button id="tune-apply-live-btn" class="tuning-btn tuning-btn-primary">Apply to Live</button>
<button id="tune-reset-btn" class="tuning-btn tuning-btn-secondary">Reset to Live</button>
</div>
</div>
</div>
`;
// Attach event listeners
panel.querySelector('#tune-apply-btn').addEventListener('click', applyTuningParams);
panel.querySelector('#tune-apply-live-btn').addEventListener('click', applyToLive);
panel.querySelector('#tune-reset-btn').addEventListener('click', resetTuningParams);
// Update value displays on slider change
panel.querySelectorAll('input[type="range"]').forEach(input => {
input.addEventListener('input', (e) => {
const valueSpan = e.target.parentElement.querySelector('.tuning-value');
if (valueSpan) {
valueSpan.textContent = e.target.value;
}
});
});
return panel;
}
function populateTuningPanel(params) {
const panel = document.getElementById('replay-tuning-panel');
if (!panel) return;
// Update sliders with current params
if (params.delta_rms_threshold !== undefined) {
const input = panel.querySelector('#tune-threshold');
if (input) {
input.value = params.delta_rms_threshold;
input.parentElement.querySelector('.tuning-value').textContent = params.delta_rms_threshold;
}
}
if (params.tau_s !== undefined) {
const input = panel.querySelector('#tune-tau');
if (input) {
input.value = params.tau_s;
input.parentElement.querySelector('.tuning-value').textContent = params.tau_s;
}
}
if (params.fresnel_decay !== undefined) {
const input = panel.querySelector('#tune-fresnel');
if (input) {
input.value = params.fresnel_decay;
input.parentElement.querySelector('.tuning-value').textContent = params.fresnel_decay;
}
}
if (params.n_subcarriers !== undefined) {
const input = panel.querySelector('#tune-subcarriers');
if (input) {
input.value = params.n_subcarriers;
input.parentElement.querySelector('.tuning-value').textContent = params.n_subcarriers;
}
}
if (params.breathing_sensitivity !== undefined) {
const input = panel.querySelector('#tune-breathing');
if (input) {
input.value = params.breathing_sensitivity;
input.parentElement.querySelector('.tuning-value').textContent = params.breathing_sensitivity;
}
}
}
function applyTuningParams() {
const panel = document.getElementById('replay-tuning-panel');
if (!panel) return;
const params = {
delta_rms_threshold: parseFloat(panel.querySelector('#tune-threshold').value),
tau_s: parseFloat(panel.querySelector('#tune-tau').value),
fresnel_decay: parseFloat(panel.querySelector('#tune-fresnel').value),
n_subcarriers: parseInt(panel.querySelector('#tune-subcarriers').value, 10),
breathing_sensitivity: parseFloat(panel.querySelector('#tune-breathing').value),
};
tuneParams(params).then(() => {
if (window.SpaxelApp) {
SpaxelApp.showToast('Parameters updated. Processing replay with new settings...', 'info');
}
// Hide panel after applying
panel.style.display = 'none';
}).catch(err => {
console.error('[Replay] Failed to tune parameters:', err);
if (window.SpaxelApp) {
SpaxelApp.showToast('Failed to update parameters: ' + err.message, 'error');
}
});
}
function applyToLive() {
const panel = document.getElementById('replay-tuning-panel');
if (!panel) return;
const params = {
delta_rms_threshold: parseFloat(panel.querySelector('#tune-threshold').value),
tau_s: parseFloat(panel.querySelector('#tune-tau').value),
fresnel_decay: parseFloat(panel.querySelector('#tune-fresnel').value),
n_subcarriers: parseInt(panel.querySelector('#tune-subcarriers').value, 10),
breathing_sensitivity: parseFloat(panel.querySelector('#tune-breathing').value),
};
if (!state.activeSessionId) {
if (window.SpaxelApp) {
SpaxelApp.showToast('No active replay session. Cannot apply to live.', 'error');
}
return;
}
fetch('/api/replay/apply-live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.activeSessionId
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to apply to live: ' + res.statusText);
}
return res.json();
})
.then(data => {
console.log('[Replay] Applied to live:', data);
if (window.SpaxelApp) {
SpaxelApp.showToast('Parameters applied to live pipeline', 'success');
}
// Hide panel after applying
panel.style.display = 'none';
})
.catch(err => {
console.error('[Replay] Failed to apply to live:', err);
if (window.SpaxelApp) {
SpaxelApp.showToast('Failed to apply to live: ' + err.message, 'error');
}
});
}
function resetTuningParams() {
// Reset to live default values
const params = {
delta_rms_threshold: 0.02,
tau_s: 30,
fresnel_decay: 2.0,
n_subcarriers: 16,
breathing_sensitivity: 0.005,
};
tuneParams(params).then(() => {
populateTuningPanel(params);
if (window.SpaxelApp) {
SpaxelApp.showToast('Parameters reset to live defaults', 'info');
}
}).catch(err => {
console.error('[Replay] Failed to reset parameters:', err);
});
}
// ============================================
// Utilities
// ============================================
function formatTimestamp(ms) {
const date = new Date(ms);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
// ============================================
// Jump-to-Time (tap-to-jump from timeline)
// ============================================
function jumpToTime(timestampMs, windowMs) {
if (!windowMs) windowMs = 5000; // ±5 seconds default
console.log('[Replay] Jump to time:', timestampMs, 'window:', windowMs);
state.isReplayMode = true;
return fetch('/api/replay/jump-to-time', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timestamp_ms: timestampMs,
window_ms: windowMs
})
})
.then(res => {
if (!res.ok) throw new Error('Failed to jump to time: ' + res.statusText);
return res.json();
})
.then(data => {
state.activeSessionId = data.session_id;
state.sessionFromMs = data.from_ms;
state.sessionToMs = data.to_ms;
state.sessionCurrentMs = data.timestamp_ms;
state.sessionState = data.state || 'paused';
state.sessionSpeed = 1;
// Show replay control bar
if (elements.bar) {
elements.bar.style.display = 'block';
}
updateUI();
// Notify 3D visualization to enter replay mode
if (window.Viz3D && Viz3D.enterReplayMode) {
Viz3D.enterReplayMode();
}
// Fetch session state (includes blobs at the seeked timestamp)
// so the 3D scene immediately shows the correct state
return fetchSessionBlobs(data.session_id).then(function() {
console.log('[Replay] Jump-to-time session:', data.session_id);
return data;
});
});
}
// ============================================
// Cross-Module Callback (from timeline click)
// ============================================
function onJumpToTime(sessionId, timestampMs) {
console.log('[Replay] onJumpToTime callback:', sessionId, timestampMs);
state.activeSessionId = sessionId;
state.sessionCurrentMs = timestampMs;
state.sessionState = 'paused';
state.sessionSpeed = 1;
state.isReplayMode = true;
// Fetch full session details from the API
fetch('/api/replay/session/' + encodeURIComponent(sessionId))
.then(function(res) {
if (!res.ok) throw new Error('Failed to fetch session');
return res.json();
})
.then(function(data) {
state.sessionFromMs = data.from_ms;
state.sessionToMs = data.to_ms;
state.sessionCurrentMs = data.current_ms || timestampMs;
state.sessionState = data.state || 'paused';
state.sessionSpeed = data.speed || 1;
// Show replay control bar
if (elements.bar) {
elements.bar.style.display = 'block';
}
updateUI();
// Notify 3D visualization to enter replay mode
if (window.Viz3D && Viz3D.enterReplayMode) {
Viz3D.enterReplayMode();
}
// Feed replay blobs to 3D scene if available
if (data.blobs && data.blobs.length > 0 && window.Viz3D && Viz3D.updateReplayBlobs) {
Viz3D.updateReplayBlobs(data.blobs, data.timestamp_ms);
}
})
.catch(function(err) {
console.error('[Replay] onJumpToTime session fetch failed:', err);
// Still show the bar with partial state
if (elements.bar) {
elements.bar.style.display = 'block';
}
updateUI();
});
}
// ============================================
// Public API
// ============================================
window.SpaxelReplay = {
init: init,
// Pause live mode and enter replay
pauseLive: pauseLiveMode,
// Exit replay mode and return to live
exitReplay: exitReplayMode,
// Jump to a specific timestamp (for tap-to-jump from timeline)
jumpToTime: jumpToTime,
// Cross-module callback: sync replay state after timeline-initiated jump
onJumpToTime: onJumpToTime,
// Check if currently in replay mode
isReplayMode: () => state.isReplayMode,
// Check if currently paused
isPaused: () => state.isPaused,
// Get current replay session info
getSession: () => ({
id: state.activeSessionId,
fromMs: state.sessionFromMs,
toMs: state.sessionToMs,
currentMs: state.sessionCurrentMs,
speed: state.sessionSpeed,
state: state.sessionState,
}),
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();