Visualizer work
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

631 lines
36 KiB

<!DOCTYPE html>
<!-- DARK MODE: The 'dark' class will be toggled here by JavaScript -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radar and Video Visualizer - Timestamp Synchronized</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- DARK MODE: Step 1 - Configure Tailwind to use the 'class' strategy for dark mode -->
<script>
tailwind.config = {
darkMode: 'class',
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Roboto+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
.font-mono {
font-family: 'Roboto Mono', monospace;
}
.p5Canvas {
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
video {
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: #2563eb;
cursor: pointer;
border-radius: 50%;
margin-top: -8px;
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
background: #2563eb;
cursor: pointer;
border-radius: 50%;
}
.shadow-up {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1), 0 -2px 4px -2px rgb(0 0 0 / 0.1);
}
#modal-overlay {
transition: opacity 0.2s ease-in-out;
}
#modal-content {
transition: transform 0.2s ease-in-out;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 flex flex-col min-h-screen">
<header class="bg-white dark:bg-gray-800 shadow-md p-4 z-10 relative">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Radar and Video Visualizer</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">High-precision, timestamp-synchronized playback.</p>
<!-- DARK MODE: Step 2 - Add the toggle button -->
<div class="absolute top-4 right-4">
<button id="theme-toggle" type="button"
class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm-.707 12.122l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414-1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
</button>
</div>
</header>
<main class="flex-grow container mx-auto p-4 flex flex-col lg:flex-row gap-6">
<div class="lg:w-1/2 flex flex-col gap-4">
<div class="relative">
<div id="canvas-container"
class="w-full h-[60vh] bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-inner flex items-center justify-center">
<p id="canvas-placeholder" class="text-gray-500 dark:text-gray-400 text-lg">Load JSON data to start
visualization</p>
</div>
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center justify-center gap-4">
<div id="ego-speed-display"
class="bg-black bg-opacity-60 text-white text-sm px-3 py-1.5 rounded-md hidden font-mono"></div>
<div id="can-speed-display"
class="bg-black bg-opacity-60 text-white text-sm px-3 py-1.5 rounded-md hidden font-mono"></div>
</div>
</div>
<div id="feature-toggles"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border dark:border-gray-700 flex flex-col items-center gap-4 hidden">
<div class="flex flex-wrap justify-center gap-x-6 gap-y-2">
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-snr-color"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Color by
SNR</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-cluster-color"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Color by
Cluster</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-inlier-color"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Color by
Inlier</label>
<!-- ADD THIS LINE -->
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-stationary-color"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Color by
Stationary</label>
<!-- END OF ADDED LINE -->
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-velocity" class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
checked> Show Object Details</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-tracks" class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
checked> Show Tracks</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-ego-speed"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500" checked> Show Ego
Speed</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-frame-norm"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Per-Frame
SNR</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-debug-overlay"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> Show Debug
Info</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input type="checkbox"
id="toggle-close-up"
class="form-checkbox h-4 w-4 text-blue-600 rounded focus:ring-blue-500"> CLOSE-UP</label>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2"><label for="snr-min-input" class="text-sm font-medium">Min
SNR:</label><input type="number" id="snr-min-input" step="0.1"
class="w-20 p-2 border border-gray-300 dark:bg-gray-600 dark:border-gray-500 dark:text-white rounded-lg text-sm">
</div>
<div class="flex items-center gap-2"><label for="snr-max-input" class="text-sm font-medium">Max
SNR:</label><input type="number" id="snr-max-input" step="0.1"
class="w-20 p-2 border border-gray-300 dark:bg-gray-600 dark:border-gray-500 dark:text-white rounded-lg text-sm">
</div>
<button id="apply-snr-btn"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors text-sm font-medium">Apply</button>
</div>
</div>
</div>
<div class="lg:w-1/2 flex flex-col gap-4">
<div class="w-full h-[45vh] bg-black rounded-lg shadow-inner flex items-center justify-center relative">
<video id="video-player" class="w-full h-full object-contain hidden" muted playsinline></video>
<p id="video-placeholder" class="text-gray-500 dark:text-gray-400 text-lg">Load a video file</p>
<div id="debug-overlay"
class="absolute top-0 left-0 bg-black bg-opacity-60 text-white p-2 font-mono text-xs hidden w-full">
</div>
</div>
<div id="speed-graph-container"
class="w-full h-[27vh] bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-inner flex items-center justify-center">
<p id="speed-graph-placeholder" class="text-gray-500 dark:text-gray-400 text-lg">Load CAN log to see
speed graph</p>
</div>
</div>
</main>
<footer class="bg-white dark:bg-gray-800 shadow-up w-full p-4 mt-auto sticky bottom-0 z-20">
<div class="mb-4">
<input type="range" id="timeline-slider" min="0" max="0" value="0"
class="w-full h-2 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer">
</div>
<div class="flex flex-wrap items-center justify-center md:justify-between gap-4">
<div class="flex items-center gap-4 justify-center">
<button id="load-json-btn"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">Load
JSON</button>
<button id="load-video-btn"
class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">Load
Video</button>
<button id="load-can-btn"
class="bg-yellow-500 text-white px-4 py-2 rounded-lg hover:bg-yellow-600 transition-colors text-sm font-medium">Load
CAN Log</button>
<button id="clear-cache-btn"
class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">Clear
Cache</button>
<div class="flex items-center gap-2">
<label for="offset-input" class="text-sm font-medium"> Offset (ms):<br><small>(+ve values if
radar<br> lags behind video)</small></label>
<input type="number" id="offset-input" value="0"
class="w-20 p-2 border border-gray-300 dark:bg-gray-600 dark:border-gray-500 dark:text-white rounded-lg text-sm">
<span id="auto-offset-indicator" class="text-green-600 font-semibold text-sm hidden">(auto)</span>
</div>
</div>
<div class="flex items-center justify-center gap-4">
<button id="play-pause-btn"
class="px-5 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 hover:bg-gray-300 font-semibold w-20">Play</button>
<button id="stop-btn"
class="px-5 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 hover:bg-gray-300 font-semibold">Stop</button>
<div class="text-center">
<span id="frame-counter" class="font-mono text-lg">Frame: 0 / 0</span>
</div>
</div>
<div class="flex items-center justify-center gap-2">
<label for="speed-slider" class="text-sm font-medium">Speed:</label>
<input type="range" id="speed-slider" min="0.1" max="2" value="1" step="0.1"
class="w-32 h-2 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer">
<span id="speed-display" class="font-mono text-sm w-12 text-center">1.0x</span>
</div>
</div>
</footer>
<div id="modal-container" class="fixed inset-0 z-50 flex items-center justify-center hidden">
<div id="modal-overlay" class="absolute inset-0 bg-black bg-opacity-50"></div>
<div id="modal-content"
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md z-10 transform scale-95">
<p id="modal-text" class="text-gray-700 dark:text-gray-300 mb-4"></p>
<div id="modal-buttons" class="flex justify-end gap-4"><button id="modal-cancel-btn"
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 hover:bg-gray-300 font-semibold">Cancel</button><button
id="modal-ok-btn"
class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 font-semibold">OK</button>
</div>
</div>
</div>
<input type="file" id="json-file-input" class="hidden" accept=".json"><input type="file" id="video-file-input"
class="hidden" accept="video/*"><input type="file" id="can-file-input" class="hidden" accept=".log, .txt">
<script type="module">
// ===========================================================================================================
// REFACTOR PLAN: This monolithic script will be broken down into
// the following modules in the '/src' directory:
//
// - constants.js: Shared constants (VIDEO_FPS, RADAR_X_MAX)
// - utils.js: Pure helper functions (findRadarFrameIndexForTime)
// - state.js: Central application state management
// - dom.js: DOM element references and UI updaters
// - modal.js: Modal dialog logic
// - theme.js: Dark/Light mode theme switcher
// - db.js: IndexedDB caching logic
// - fileParsers.js: JSON and CAN log parsing logic
// - p5/radarSketch.js: The main p5.js radar visualization
// - p5/speedGraph.js: The p5.js speed graph visualization
// - sync.js: Playback and synchronization loop
// - main.js: The main application entry point that wires everything
// ===========================================================================================================
// import radar sketch from './src/p5/radarSketch.js';
import {
radarSketch
} from './src/p5/radarSketch.js';
// import speed graph sketch from './src/p5/speedGraphSketch.js';
import {
speedGraphSketch
} from './src/p5/speedGraphSketch.js';
// import JSON parser, can log procesor from './src/fileParsers.js';
import {
processCanLog, parseVisualizationJson
} from './src/fileParsers.js';
// import constants from './constants.js';
import {
MAX_TRAJECTORY_LENGTH,
VIDEO_FPS,
RADAR_X_MIN,
RADAR_X_MAX,
RADAR_Y_MIN,
RADAR_Y_MAX
} from './src/constants.js';
// import utils and helpers from './src/utils.js';
import {
findRadarFrameIndexForTime,
findLastCanIndexBefore,
extractTimestampInfo,
parseTimestamp
} from './src/utils.js';
// import state machine from './src/state.js';
import {
appState
} from './src/state.js';
// import DOM elements and UI updaters from './src/dom.js';
import {
//---DOM Elements---//
canvasContainer, canvasPlaceholder, videoPlayer, videoPlaceholder,
loadJsonBtn, loadVideoBtn, loadCanBtn, jsonFileInput, videoFileInput,
canFileInput, playPauseBtn, stopBtn, timelineSlider, frameCounter,
offsetInput, speedSlider, speedDisplay, featureToggles, toggleSnrColor,
toggleClusterColor, toggleInlierColor, toggleStationaryColor, toggleVelocity,
toggleTracks, toggleEgoSpeed, toggleFrameNorm, toggleDebugOverlay, egoSpeedDisplay,
canSpeedDisplay, debugOverlay, snrMinInput, snrMaxInput, applySnrBtn, autoOffsetIndicator,
clearCacheBtn, speedGraphContainer, speedGraphPlaceholder, toggleCloseUp,
//---UI Updaters---//
updateFrame, resetVisualization, updateCanDisplay, updateDebugOverlay
} from './src/dom.js';
// import modal dialog logic from './src/modal.js';
import {
showModal
} from './src/modal.js';
// import initialize theme from './src/theme.js';
import {
initializeTheme
} from './src/theme.js';
// import caching logic from './src/db.js';
import {
initDB, saveFileToDB, loadFileFromDB
} from './src/db.js';
function setupVideoPlayer(fileURL) { videoPlayer.src = fileURL; videoPlayer.classList.remove('hidden'); videoPlaceholder.classList.add('hidden'); videoPlayer.playbackRate = parseFloat(speedSlider.value); }
loadJsonBtn.addEventListener('click', () => jsonFileInput.click()); loadVideoBtn.addEventListener('click', () => videoFileInput.click()); loadCanBtn.addEventListener('click', () => canFileInput.click());
clearCacheBtn.addEventListener('click', async () => { const confirmed = await showModal("Clear all cached data and reload?", true); if (confirmed) { indexedDB.deleteDatabase('visualizerDB'); localStorage.clear(); window.location.reload(); } });
jsonFileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
appState.jsonFilename = file.name;
localStorage.setItem('jsonFilename', appState.jsonFilename);
calculateAndSetOffset(); // This function now correctly sets appState variables
const reader = new FileReader();
reader.onload = (e) => {
const jsonString = e.target.result;
saveFileToDB('json', jsonString);
// 1. Give the raw ingredients to our new JSON "chef"
const result = parseVisualizationJson(jsonString, appState.radarStartTimeMs, appState.videoStartDate);
// 2. Check the result
if (result.error) {
showModal(result.error);
return;
}
// 3. Update the application's central state with the prepared data
appState.vizData = result.data;
appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr;
// 4. Now, the "waiter" updates the UI
snrMinInput.value = appState.globalMinSnr.toFixed(1);
snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
resetVisualization(); // This UI function is in dom.js
canvasPlaceholder.style.display = 'none';
featureToggles.classList.remove('hidden');
if (!appState.p5_instance) {
appState.p5_instance = new p5(radarSketch);
}
if (appState.speedGraphInstance) {
appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration);
} else {
// Redraw p5 instance with new data
appState.p5_instance.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr);
appState.p5_instance.redraw();
}
};
reader.readAsText(file);
});
videoFileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
appState.videoFilename = file.name;
localStorage.setItem('videoFilename', appState.videoFilename);
saveFileToDB('video', file);
// This is the key moment: we now have a video start date.
calculateAndSetOffset();
// Now, check if we have pending data that needs this date.
if (appState.rawCanLogText) {
const result = processCanLog(appState.rawCanLogText, appState.videoStartDate);
if (!result.error) {
appState.canData = result.data;
appState.rawCanLogText = null;
}
}
// NEW: Re-process vizData if it was loaded before the video.
if (appState.vizData) {
console.log("DEBUG: Video loaded after JSON. Re-calculating timestamps.");
appState.vizData.radarFrames.forEach(frame => {
frame.timestampMs = (appState.radarStartTimeMs + frame.timestamp) - appState.videoStartDate.getTime();
});
resetVisualization(); // Reset UI to reflect new timestamps
}
const fileURL = URL.createObjectURL(file);
setupVideoPlayer(fileURL);
// When the video is ready, update the speed graph
videoPlayer.onloadedmetadata = () => {
if (appState.speedGraphInstance) {
appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration);
}
};
});
canFileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
appState.canLogFilename = file.name;
localStorage.setItem('canLogFilename', appState.canLogFilename);
const reader = new FileReader();
reader.onload = (e) => {
const logContent = e.target.result;
saveFileToDB('canLogText', logContent);
// 1. Give the raw ingredients to the chef (our parser)
const result = processCanLog(logContent, appState.videoStartDate);
// 2. Check what the chef gave back
if (result.error) {
// If there was an error, show it and save the raw text for later.
showModal(result.error);
appState.rawCanLogText = result.rawCanLogText;
return;
}
// 3. If successful, update the application's central state
appState.canData = result.data;
appState.rawCanLogText = null;
// 4. Now, the waiter updates the UI based on the new state
if (appState.canData.length > 0 || appState.vizData) {
speedGraphPlaceholder.classList.add('hidden');
if (!appState.speedGraphInstance) {
// We need to pass the speedGraphSketch function definition here
appState.speedGraphInstance = new p5(speedGraphSketch);
}
if (videoPlayer.duration) {
appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration);
}
} else {
showModal(`No CAN messages with ID 0x30F found.`);
}
};
reader.readAsText(file);
});
offsetInput.addEventListener('input', () => { autoOffsetIndicator.classList.add('hidden'); localStorage.setItem('visualizerOffset', offsetInput.value); });
applySnrBtn.addEventListener('click', () => { const newMin = parseFloat(snrMinInput.value), newMax = parseFloat(snrMaxInput.value); if (isNaN(newMin) || isNaN(newMax) || newMin >= newMax) { showModal('Invalid SNR range.'); return; } appState.globalMinSnr = newMin; appState.globalMaxSnr = newMax; toggleFrameNorm.checked = false; if (appState.p5_instance) { appState.p5_instance.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); appState.p5_instance.redraw(); } });
playPauseBtn.addEventListener('click', () => { if (!appState.vizData && !videoPlayer.src) return; appState.isPlaying = !appState.isPlaying; playPauseBtn.textContent = appState.isPlaying ? 'Pause' : 'Play'; if (appState.isPlaying) { if (videoPlayer.src && videoPlayer.readyState > 1) { appState.masterClockStart = performance.now(); appState.mediaTimeStart = videoPlayer.currentTime; appState.lastSyncTime = appState.masterClockStart; videoPlayer.play(); } requestAnimationFrame(animationLoop); } else { if (videoPlayer.src) videoPlayer.pause(); } });
stopBtn.addEventListener('click', () => { videoPlayer.pause(); appState.isPlaying = false; playPauseBtn.textContent = 'Play'; if (appState.vizData) { updateFrame(0, true); } else if (videoPlayer.src) { videoPlayer.currentTime = 0; } if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); });
timelineSlider.addEventListener('input', (event) => { if (!appState.vizData) return; if (appState.isPlaying) { videoPlayer.pause(); appState.isPlaying = false; playPauseBtn.textContent = 'Play'; } const frame = parseInt(event.target.value, 10); updateFrame(frame, true); appState.mediaTimeStart = videoPlayer.currentTime; appState.masterClockStart = performance.now(); });
speedSlider.addEventListener('input', (event) => { const speed = parseFloat(event.target.value); videoPlayer.playbackRate = speed; speedDisplay.textContent = `${speed.toFixed(1)}x`; });
// ADD THE NEW TOGGLE TO THE ARRAY
const colorToggles = [toggleSnrColor, toggleClusterColor, toggleInlierColor, toggleStationaryColor];
colorToggles.forEach(t => { t.addEventListener('change', (e) => { if (e.target.checked) { colorToggles.forEach(o => { if (o !== e.target) o.checked = false; }); } if (appState.p5_instance) appState.p5_instance.redraw(); }); });
[toggleVelocity, toggleEgoSpeed, toggleFrameNorm, toggleTracks, toggleDebugOverlay].forEach(t => { t.addEventListener('change', () => { if (appState.p5_instance) { if (t === toggleFrameNorm && !toggleFrameNorm.checked) appState.p5_instance.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); appState.p5_instance.redraw(); } if (t === toggleDebugOverlay) updateDebugOverlay(videoPlayer.currentTime); }); });
toggleCloseUp.addEventListener('change', () => {
appState.isCloseUpMode = toggleCloseUp.checked;
if (appState.p5_instance) {
if (appState.isCloseUpMode) {
if (appState.isPlaying) {
playPauseBtn.click();
}
appState.p5_instance.loop();
} else {
appState.p5_instance.noLoop();
appState.p5_instance.redraw();
}
}
});
videoPlayer.addEventListener('ended', () => { appState.isPlaying = false; playPauseBtn.textContent = 'Play'; });
document.addEventListener('keydown', (event) => { if (!appState.vizData || ['ArrowRight', 'ArrowLeft'].indexOf(event.key) === -1) return; event.preventDefault(); if (appState.isPlaying) { appState.isPlaying = false; playPauseBtn.textContent = 'Play'; videoPlayer.pause(); } let newFrame = appState.currentFrame; if (event.key === 'ArrowRight') newFrame = Math.min(appState.vizData.radarFrames.length - 1, appState.currentFrame + 1); else if (event.key === 'ArrowLeft') newFrame = Math.max(0, appState.currentFrame - 1); if (newFrame !== appState.currentFrame) { updateFrame(newFrame, true); appState.mediaTimeStart = videoPlayer.currentTime; appState.masterClockStart = performance.now(); } });
function calculateAndSetOffset() { const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename); const videoTimestampInfo = extractTimestampInfo(appState.videoFilename); if (videoTimestampInfo) { appState.videoStartDate = parseTimestamp(videoTimestampInfo.timestampStr, videoTimestampInfo.format); if (appState.videoStartDate) console.log(`Video start date set to: ${appState.videoStartDate.toISOString()}`); } if (jsonTimestampInfo) { const jsonDate = parseTimestamp(jsonTimestampInfo.timestampStr, jsonTimestampInfo.format); if (jsonDate) { appState.radarStartTimeMs = jsonDate.getTime(); console.log(`Radar start date set to: ${jsonDate.toISOString()}`); if (appState.videoStartDate) { const offset = appState.radarStartTimeMs - appState.videoStartDate.getTime(); offsetInput.value = offset; localStorage.setItem('visualizerOffset', offset); autoOffsetIndicator.classList.remove('hidden'); console.log(`Auto-calculated offset: ${offset} ms`); } } } }
function animationLoop() {
if (!appState.isPlaying) return;
const playbackSpeed = parseFloat(speedSlider.value);
const elapsedRealTime = performance.now() - appState.masterClockStart;
const currentMediaTime = appState.mediaTimeStart + (elapsedRealTime / 1000) * playbackSpeed;
if (appState.vizData && appState.videoStartDate) {
const offsetMs = parseFloat(offsetInput.value) || 0;
const targetRadarTimeMs = (currentMediaTime * 1000) + offsetMs;
const targetFrame = findRadarFrameIndexForTime(targetRadarTimeMs, appState.vizData);
if (targetFrame !== appState.currentFrame) {
updateFrame(targetFrame, false);
}
}
const now = performance.now();
if (now - appState.lastSyncTime > 500) {
const videoTime = videoPlayer.currentTime;
const drift = Math.abs(currentMediaTime - videoTime);
if (drift > 0.15) {
console.warn(`Resyncing video. Drift was: ${drift.toFixed(3)}s`);
videoPlayer.currentTime = currentMediaTime;
}
appState.lastSyncTime = now;
}
if (currentMediaTime >= videoPlayer.duration) {
stopBtn.click();
return;
}
updateCanDisplay(currentMediaTime);
updateDebugOverlay(currentMediaTime);
if (appState.speedGraphInstance) appState.speedGraphInstance.redraw();
requestAnimationFrame(animationLoop);
}
// --- Application Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initializeTheme();
console.log("DEBUG: DOMContentLoaded fired. Starting session load.");
initDB(() => {
console.log("DEBUG: Database initialized.");
const savedOffset = localStorage.getItem('visualizerOffset');
if (savedOffset !== null) {
offsetInput.value = savedOffset;
}
appState.videoFilename = localStorage.getItem('videoFilename');
appState.jsonFilename = localStorage.getItem('jsonFilename');
appState.canLogFilename = localStorage.getItem('canLogFilename');
// This is important: it sets videoStartDate if a video filename is cached
calculateAndSetOffset();
const videoPromise = new Promise(resolve => loadFileFromDB('video', resolve));
const jsonPromise = new Promise(resolve => loadFileFromDB('json', resolve));
const canLogPromise = new Promise(resolve => loadFileFromDB('canLogText', resolve));
Promise.all([videoPromise, jsonPromise, canLogPromise]).then(([videoBlob, jsonString, canLogText]) => {
console.log("DEBUG: All data fetched from IndexedDB.");
const processAllData = () => {
console.log("DEBUG: Processing all loaded data.");
// 1. Process JSON (only if we have a video date)
if (jsonString && appState.videoStartDate) {
const result = parseVisualizationJson(jsonString, appState.radarStartTimeMs, appState.videoStartDate);
if (!result.error) {
appState.vizData = result.data;
appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr;
snrMinInput.value = appState.globalMinSnr.toFixed(1);
snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
} else {
showModal(result.error);
}
}
// 2. Process CAN log (only if we have a video date)
if (canLogText && appState.videoStartDate) {
const result = processCanLog(canLogText, appState.videoStartDate);
if (!result.error) {
appState.canData = result.data;
}
}
// 3. Update all UI elements now that data is processed
if (appState.vizData) {
resetVisualization();
canvasPlaceholder.style.display = 'none';
featureToggles.classList.remove('hidden');
if (!appState.p5_instance) {
appState.p5_instance = new p5(radarSketch);
}
}
if (appState.canData.length > 0 || appState.vizData) {
speedGraphPlaceholder.classList.add('hidden');
if (!appState.speedGraphInstance) {
appState.speedGraphInstance = new p5(speedGraphSketch);
}
appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration);
}
};
// This is the main controller
// --- THIS IS THE CORRECTED CODE ---
if (videoBlob) {
const fileURL = URL.createObjectURL(videoBlob);
setupVideoPlayer(fileURL);
// This ensures we ONLY process data once the video's duration is known.
videoPlayer.onloadedmetadata = processAllData;
} else {
// If there's no video, we can go ahead and process the other data.
processAllData();
}
}).catch(error => {
console.error("DEBUG: Error during Promise.all data loading:", error);
});
});
});
</script>
</body>
</html>