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
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>
|