Replace remaining hard-coded colors across all CSS files with design tokens from tokens.css. Remove duplicate inline positioning from live.html panels (now in layout.css). Add replay session blob fetch for immediate 3D scene state on seek. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
904 lines
32 KiB
JavaScript
904 lines
32 KiB
JavaScript
/**
|
||
* 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
|
||
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();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
})();
|