spaxel/dashboard/js/replay.js
jedarden 21020e9fc9 feat(timeline): add tap-to-jump time-travel coordination
When timeline event is clicked in expert mode, emit jump_to_time command
with event timestamp. The time-travel player pauses live playback, seeks
CSI recording buffer to timestamp, and begins replay. Selected event
highlights in timeline and "Now replaying" chip appears in header.

Backend: POST /api/replay/jump-to-time creates replay session centered
on timestamp, replaces previous active session. Frontend: handleSeek()
in sidebar-timeline delegates to SpaxelReplay.jumpToTime() which calls
the API, shows replay control bar, and notifies Viz3D.

Tests: 7 Go test cases for jump-to-time endpoint, 8 JS test cases for
tap-to-jump interaction, event highlighting, and now-replaying chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:08:39 -04:00

826 lines
29 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 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
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-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-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 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();
}
console.log('[Replay] Jump-to-time session:', data.session_id);
return data;
});
}
// ============================================
// 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,
// 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();
}
})();