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.
 
 
 

1113 lines
61 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 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';
// import file parsers from './src/fileParsers.js';'
// --- p5.js Sketch Definitions ---
let sketch = function (p) {
let plotScaleX, plotScaleY, staticBackgroundBuffer, snrLegendBuffer, snrColors, clusterColors;
// ADD COLOR DEFINITIONS FOR STATIONARY/MOVING OBJECTS
let stationaryColor, movingColor;
p.setup = function () {
let canvas = p.createCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight);
canvas.parent('canvas-container');
staticBackgroundBuffer = p.createGraphics(p.width, p.height);
snrLegendBuffer = p.createGraphics(100, 450);
snrColors = { c1: p.color(0, 0, 255), c2: p.color(0, 255, 255), c3: p.color(0, 255, 0), c4: p.color(255, 255, 0), c5: p.color(255, 0, 0) };
clusterColors = [p.color(230, 25, 75), p.color(60, 180, 75), p.color(0, 130, 200), p.color(245, 130, 48), p.color(145, 30, 180), p.color(70, 240, 240), p.color(240, 50, 230), p.color(210, 245, 60), p.color(128, 0, 0), p.color(0, 128, 128)];
// INITIALIZE STATIONARY/MOVING COLORS
stationaryColor = p.color(218, 165, 32); // Golden ROD Yellow
movingColor = p.color(255, 0, 255); // Magenta
calculatePlotScales();
p.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr);
drawStaticRegionsToBuffer();
p.noLoop();
};
function calculatePlotScales() { const hPad = 0.05, vPad = 0.05, bOff = 0.05; const aW = p.width * (1 - 2 * hPad); const aH = p.height * (1 - bOff - vPad); plotScaleX = aW / (RADAR_X_MAX - RADAR_X_MIN); plotScaleY = aH / (RADAR_Y_MAX - RADAR_Y_MIN); }
p.draw = function () {
if (document.documentElement.classList.contains('dark')) {
p.background(55, 65, 81);
} else {
p.background(255);
}
if (!appState.vizData) return;
p.image(staticBackgroundBuffer, 0, 0);
p.push();
p.translate(p.width / 2, p.height * 0.95);
p.scale(1, -1);
calculatePlotScales();
drawAxes();
if (toggleTracks.checked) {
drawTrajectories();
drawTrackMarkers();
}
const frameData = appState.vizData.radarFrames[appState.currentFrame];
if (frameData) drawPointCloud(frameData.pointCloud);
p.pop();
if (appState.isCloseUpMode) {
handleCloseUpDisplay();
}
if (toggleSnrColor.checked) p.image(snrLegendBuffer, 10, p.height - snrLegendBuffer.height - 10);
};
function drawStaticRegionsToBuffer() { const b = staticBackgroundBuffer; b.clear(); b.push(); b.translate(b.width / 2, b.height * 0.95); b.scale(1, -1); const hPad = 0.05, vPad = 0.05, bOff = 0.05; const bAW = b.width * (1 - 2 * hPad); const bAH = b.height * (1 - bOff - vPad); const bPSX = bAW / (RADAR_X_MAX - RADAR_X_MIN); const bPSY = bAH / (RADAR_Y_MAX - RADAR_Y_MIN); b.stroke(100, 100, 100, 150); b.strokeWeight(1); b.drawingContext.setLineDash([8, 8]); const a1 = p.radians(30), a2 = p.radians(150); const len = 70; b.line(0, 0, len * p.cos(a1) * bPSX, len * p.sin(a1) * bPSY); b.line(0, 0, len * p.cos(a2) * bPSX, len * p.sin(a2) * bPSY); b.drawingContext.setLineDash([]); b.pop(); }
function drawAxes() {
p.push();
const axisColor = document.documentElement.classList.contains('dark') ? p.color(100) : p.color(220);
const mainAxisColor = document.documentElement.classList.contains('dark') ? p.color(150) : p.color(180);
const textColor = document.documentElement.classList.contains('dark') ? p.color(200) : p.color(150);
p.stroke(axisColor);
p.strokeWeight(1);
for (let y = 5; y <= RADAR_Y_MAX; y += 5) p.line(RADAR_X_MIN * plotScaleX, y * plotScaleY, RADAR_X_MAX * plotScaleX, y * plotScaleY);
for (let x = -15; x <= 15; x += 5) { if (x === 0) continue; p.line(x * plotScaleX, RADAR_Y_MIN * plotScaleY, x * plotScaleX, RADAR_Y_MAX * plotScaleY); }
p.stroke(mainAxisColor);
p.line(RADAR_X_MIN * plotScaleX, 0, RADAR_X_MAX * plotScaleX, 0);
p.line(0, RADAR_Y_MIN * plotScaleY, 0, RADAR_Y_MAX * plotScaleY);
p.fill(textColor);
p.noStroke();
p.textSize(10);
for (let y = 5; y <= RADAR_Y_MAX; y += 5) { p.push(); p.translate(5, y * plotScaleY); p.scale(1, -1); p.text(y, 0, 4); p.pop(); }
for (let x = -15; x <= 15; x += 5) { if (x === 0) continue; p.push(); p.translate(x * plotScaleX, -10); p.scale(1, -1); p.textAlign(p.CENTER); p.text(x, 0, 0); p.pop(); }
p.pop();
}
function drawPointCloud(points) {
p.strokeWeight(4);
const useSnr = toggleSnrColor.checked;
const useCluster = toggleClusterColor.checked;
const useInlier = toggleInlierColor.checked;
const useFrameNorm = toggleFrameNorm.checked;
let minSnr = appState.globalMinSnr, maxSnr = appState.globalMaxSnr;
if (useSnr && useFrameNorm && points.length > 0) {
const snrVals = points.map(p => p.snr).filter(snr => snr !== null);
if (snrVals.length > 1) {
minSnr = Math.min(...snrVals);
maxSnr = Math.max(...snrVals);
} else if (snrVals.length === 1) {
minSnr = snrVals[0] - 1;
maxSnr = snrVals[0] + 1;
}
}
if (useSnr) p.drawSnrLegendToBuffer(minSnr, maxSnr);
for (const pt of points) {
if (pt && pt.x !== null && pt.y !== null) {
if (useCluster && pt.clusterNumber !== null) {
if (pt.clusterNumber > 0) {
p.stroke(clusterColors[(pt.clusterNumber - 1) % clusterColors.length]);
} else {
p.stroke(128);
}
} else if (useInlier) {
if (pt.isOutlier === false) {
p.stroke(0, 255, 0);
} else if (pt.isOutlier === true) {
p.stroke(255, 0, 0);
} else {
p.stroke(128);
}
} else if (useSnr && pt.snr !== null) {
const amt = p.map(pt.snr, minSnr, maxSnr, 0, 1, true);
let c;
if (amt < 0.25) c = p.lerpColor(snrColors.c1, snrColors.c2, amt / 0.25);
else if (amt < 0.5) c = p.lerpColor(snrColors.c2, snrColors.c3, (amt - 0.25) / 0.25);
else if (amt < 0.75) c = p.lerpColor(snrColors.c3, snrColors.c4, (amt - 0.5) / 0.25);
else c = p.lerpColor(snrColors.c4, snrColors.c5, (amt - 0.75) / 0.25);
p.stroke(c);
} else {
p.stroke(0, 150, 255);
}
p.point(pt.x * plotScaleX, pt.y * plotScaleY);
}
}
}
function drawTrajectories() {
for (const track of appState.vizData.tracks) {
const logs = track.historyLog.filter(log => log.frameIdx <= appState.currentFrame + 1);
if (logs.length < 2) continue;
const lastLog = logs[logs.length - 1];
if (appState.currentFrame + 1 - lastLog.frameIdx > MAX_TRAJECTORY_LENGTH) continue;
// Determine the state from the most recent log entry
const isCurrentlyStationary = lastLog.isStationary;
// Set max trajectory length based on state
let maxLen = MAX_TRAJECTORY_LENGTH;
if (isCurrentlyStationary) {
maxLen = Math.floor(MAX_TRAJECTORY_LENGTH / 4);
}
let trajPts = logs.filter(log => log.correctedPosition && log.correctedPosition[0] !== null).map(log => log.correctedPosition);
if (trajPts.length > maxLen) {
trajPts = trajPts.slice(trajPts.length - maxLen);
}
p.push();
p.noFill();
// Apply different styles based on the stationary state
if (isCurrentlyStationary) {
// Style for STATIONARY tracks: thin, dashed, green
p.stroke(34, 139, 34, 220); // A darker, forest green
p.strokeWeight(1);
p.drawingContext.setLineDash([3, 3]); // Small dashes
} else {
// Style for MOVING tracks: default blue
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
p.stroke(10, 170, 255, 250);
} else {
p.stroke(0, 50, 255, 250);
}
p.strokeWeight(1.5);
// No dash for solid line
}
p.beginShape();
for (const pos of trajPts) p.vertex(pos[0] * plotScaleX, pos[1] * plotScaleY);
p.endShape();
// IMPORTANT: Reset the drawing context to avoid affecting other elements
p.drawingContext.setLineDash([]);
p.pop();
}
}
function drawTrackMarkers() {
const showDetails = toggleVelocity.checked;
const useStationary = toggleStationaryColor.checked;
const textColor = document.documentElement.classList.contains('dark') ? p.color(255) : p.color(0);
for (const track of appState.vizData.tracks) {
const log = track.historyLog.find(log => log.frameIdx === appState.currentFrame + 1);
if (log) {
const pos = (log.correctedPosition && log.correctedPosition[0] !== null) ? log.correctedPosition : log.predictedPosition;
if (pos && pos.length === 2 && pos[0] !== null && pos[1] !== null) {
// --- START OF CORRECTED LOGIC ---
const size = 5, x = pos[0] * plotScaleX, y = pos[1] * plotScaleY;
let velocityColor = p.color(255, 0, 255, 200); // Default velocity color
p.push();
p.strokeWeight(2);
// Check conditions and draw the correct marker
if (useStationary && log.isStationary === true) {
// 1. Stationary object with toggle ON
p.stroke(stationaryColor); // Use yellow
p.noFill();
p.rectMode(p.CENTER);
p.square(x, y, size * 1.5);
velocityColor = stationaryColor;
} else {
// 2. Moving object OR toggle OFF
let markerColor = p.color(0, 0, 255); // Default blue
if (useStationary && log.isStationary === false) {
markerColor = movingColor; // Magenta if toggle is on
velocityColor = movingColor;
}
p.stroke(markerColor);
p.line(x - size, y, x + size, y);
p.line(x, y - size, x, y + size);
}
p.pop();
// --- END OF CORRECTED LOGIC ---
if (showDetails && log.predictedVelocity && log.predictedVelocity[0] !== null) {
const [vx, vy] = log.predictedVelocity;
// Only draw velocity line if object is NOT stationary
if (log.isStationary === false) {
p.push();
p.stroke(velocityColor);
p.strokeWeight(2);
p.line(pos[0] * plotScaleX, pos[1] * plotScaleY, (pos[0] + vx) * plotScaleX, (pos[1] + vy) * plotScaleY);
p.pop();
}
const speed = (p.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1);
const ttc = (log.ttc !== null && isFinite(log.ttc) && log.ttc < 100) ? `TTC: ${log.ttc.toFixed(1)}s` : '';
const text = `ID: ${track.id} | ${speed} km/h\n${ttc}`;
p.push();
p.fill(textColor);
p.noStroke();
p.scale(1, -1);
p.textSize(12);
p.text(text, pos[0] * plotScaleX + 10, -pos[1] * plotScaleY);
p.pop();
}
}
}
}
}
function handleCloseUpDisplay() {
const frameData = appState.vizData.radarFrames[appState.currentFrame];
if (!frameData || !frameData.pointCloud) return;
const hoveredPoints = [];
const radius = 10;
for (const pt of frameData.pointCloud) {
if (pt.x === null || pt.y === null) continue;
const screenX = (pt.x * plotScaleX) + p.width / 2;
const screenY = p.height * 0.95 - (pt.y * plotScaleY);
const d = p.dist(p.mouseX, p.mouseY, screenX, screenY);
if (d < radius) {
hoveredPoints.push({ point: pt, screenX: screenX, screenY: screenY });
}
}
if (hoveredPoints.length > 0) {
hoveredPoints.sort((a, b) => a.screenY - b.screenY);
p.push();
p.textSize(12);
const lineHeight = 15;
const boxPadding = 8;
let boxWidth = 0;
const infoStrings = [];
for (const hovered of hoveredPoints) {
const pt = hovered.point;
const vel = pt.velocity !== null ? pt.velocity.toFixed(2) : 'N/A';
const snr = pt.snr !== null ? pt.snr.toFixed(1) : 'N/A';
const infoText = `X:${pt.x.toFixed(2)}, Y:${pt.y.toFixed(2)} | V:${vel}, SNR:${snr}`;
infoStrings.push(infoText);
boxWidth = Math.max(boxWidth, p.textWidth(infoText));
}
const boxHeight = (infoStrings.length * lineHeight) + (boxPadding * 2);
boxWidth += (boxPadding * 2);
const xOffset = 20;
let boxX = p.mouseX + xOffset;
let boxY = p.mouseY - (boxHeight / 2);
if (boxX + boxWidth > p.width) {
boxX = p.mouseX - boxWidth - xOffset;
}
boxY = p.constrain(boxY, 0, p.height - boxHeight);
const highlightColor = p.color(46, 204, 113);
for (let i = 0; i < hoveredPoints.length; i++) {
const hovered = hoveredPoints[i];
p.noFill();
p.stroke(highlightColor);
p.strokeWeight(2);
p.ellipse(hovered.screenX, hovered.screenY, 15, 15);
p.strokeWeight(1);
p.line(boxX + boxPadding, boxY + boxPadding + (i * lineHeight) + (lineHeight / 2), hovered.screenX, hovered.screenY);
}
const bgColor = document.documentElement.classList.contains('dark') ? p.color(20, 20, 30, 255) : p.color(245, 245, 245, 255);
p.fill(bgColor);
p.stroke(highlightColor);
p.strokeWeight(1);
p.rect(boxX, boxY, boxWidth, boxHeight, 4);
const textColor = document.documentElement.classList.contains('dark') ? p.color(230) : p.color(20);
p.fill(textColor);
p.noStroke();
p.textAlign(p.LEFT, p.TOP);
for (let i = 0; i < infoStrings.length; i++) {
p.text(infoStrings[i], boxX + boxPadding, boxY + boxPadding + (i * lineHeight));
}
p.pop();
}
}
p.drawSnrLegendToBuffer = function (minV, maxV) { const b = snrLegendBuffer; b.clear(); b.push(); const lx = 10, ly = 20, lw = 15, lh = 400; for (let i = 0; i < lh; i++) { const amt = b.map(i, 0, lh, 1, 0); let c; if (amt < 0.25) c = b.lerpColor(snrColors.c1, snrColors.c2, amt / 0.25); else if (amt < 0.5) c = b.lerpColor(snrColors.c2, snrColors.c3, (amt - 0.25) / 0.25); else if (amt < 0.75) c = b.lerpColor(snrColors.c3, snrColors.c4, (amt - 0.5) / 0.25); else c = b.lerpColor(snrColors.c4, snrColors.c5, (amt - 0.75) / 0.25); b.stroke(c); b.line(lx, ly + i, lx + lw, ly + i); } b.fill(0); b.noStroke(); b.textSize(10); b.textAlign(b.LEFT, b.CENTER); b.text(maxV.toFixed(1), lx + lw + 5, ly); b.text(minV.toFixed(1), lx + lw + 5, ly + lh); b.text("SNR", lx, ly - 10); b.pop(); };
p.windowResized = function () {
p.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight);
// Instead of resizing the buffer, we re-create it
staticBackgroundBuffer = p.createGraphics(p.width, p.height);
// And we must re-draw the static content to the new buffer
calculatePlotScales();
drawStaticRegionsToBuffer();
if (appState.vizData) p.redraw();
};
};
let speedGraphSketch = function (p) {
let staticBuffer, minSpeed, maxSpeed, videoDuration;
const pad = { top: 20, right: 130, bottom: 30, left: 50 };
// This function is now attached to the p5 instance, making it public
// It's responsible for drawing the static background and data lines
p.drawStaticGraphToBuffer = function (canSpeedData, radarData) {
const b = staticBuffer;
b.clear();
const isDark = document.documentElement.classList.contains('dark');
b.background(isDark ? [55, 65, 81] : 255);
const gridColor = isDark ? 100 : 200;
const textColor = isDark ? 200 : 100;
b.push();
b.stroke(gridColor);
b.strokeWeight(1);
b.line(pad.left, pad.top, pad.left, b.height - pad.bottom);
b.line(pad.left, b.height - pad.bottom, b.width - pad.right, b.height - pad.bottom);
b.textAlign(b.RIGHT, b.CENTER);
b.noStroke();
b.fill(textColor);
b.textSize(10);
for (let s = minSpeed; s <= maxSpeed; s += 10) {
const y = b.map(s, minSpeed, maxSpeed, b.height - pad.bottom, pad.top);
b.text(s, pad.left - 8, y);
if (s === 0) {
b.strokeWeight(1.5);
b.stroke(isDark ? 150 : 180);
} else {
b.strokeWeight(1);
b.stroke(isDark ? 80 : 230);
}
b.line(pad.left + 1, y, b.width - pad.right, y);
b.noStroke();
}
b.fill(textColor);
b.text("km/h", pad.left - 8, pad.top - 8);
b.textAlign(b.CENTER, b.TOP);
b.noStroke();
b.fill(isDark ? 180 : 150);
const tInt = Math.max(1, Math.floor(videoDuration / 10));
for (let t = 0; t <= videoDuration; t += tInt) { const x = b.map(t, 0, videoDuration, pad.left, b.width - pad.right); b.text(Math.round(t), x, b.height - pad.bottom + 5); }
b.fill(textColor);
b.text("Time (s)", b.width / 2, b.height - pad.bottom + 18);
b.pop();
if (canSpeedData && canSpeedData.length > 0) {
b.noFill();
b.stroke(0, 150, 255);
b.strokeWeight(1.5);
b.beginShape();
for (const d of canSpeedData) { const relTime = (d.time - appState.videoStartDate.getTime()) / 1000; if (relTime >= 0 && relTime <= videoDuration) { const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); const y = b.map(d.speed, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); b.vertex(x, y); } }
b.endShape();
}
if (radarData && radarData.radarFrames) {
b.stroke(0, 200, 100);
b.drawingContext.setLineDash([5, 5]);
b.beginShape();
for (const frame of radarData.radarFrames) {
const relTime = frame.timestamp / 1000;
if (relTime >= 0 && relTime <= videoDuration) {
const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right);
const egoSpeedKmh = frame.egoVelocity[1] * 3.6;
const y = b.map(egoSpeedKmh, minSpeed, maxSpeed, b.height - pad.bottom, pad.top);
b.vertex(x, y);
}
}
b.endShape();
b.drawingContext.setLineDash([]);
}
b.push();
b.strokeWeight(2);
b.noStroke();
b.fill(textColor);
b.textAlign(b.LEFT, b.CENTER);
b.stroke(0, 150, 255);
b.line(b.width - 120, pad.top + 10, b.width - 100, pad.top + 10);
b.noStroke();
b.text("CAN Speed", b.width - 95, pad.top + 10);
b.stroke(0, 200, 100);
b.drawingContext.setLineDash([3, 3]);
b.line(b.width - 120, pad.top + 30, b.width - 100, pad.top + 30);
b.drawingContext.setLineDash([]);
b.noStroke();
b.text("Ego Speed", b.width - 95, pad.top + 30);
b.pop();
};
p.setup = function () {
let canvas = p.createCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight);
canvas.parent('speed-graph-container');
staticBuffer = p.createGraphics(p.width, p.height);
p.noLoop();
};
p.setData = function (canSpeedData, radarData, duration) {
if ((!canSpeedData || canSpeedData.length === 0) && !radarData) return;
videoDuration = duration;
let speeds = [];
if (canSpeedData) {
speeds.push(...canSpeedData.map(d => parseFloat(d.speed)));
}
if (radarData && radarData.radarFrames) {
const egoSpeeds = radarData.radarFrames.map(frame => frame.egoVelocity[1] * 3.6);
speeds.push(...egoSpeeds);
}
minSpeed = speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0;
maxSpeed = speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10;
if (maxSpeed <= 0) maxSpeed = 10;
if (minSpeed >= 0) minSpeed = 0;
p.drawStaticGraphToBuffer(canSpeedData, radarData);
p.redraw();
};
p.draw = function () {
if (!videoDuration) return;
p.image(staticBuffer, 0, 0);
drawTimeIndicator();
};
function drawTimeIndicator() {
const currentTime = videoPlayer.currentTime;
const x = p.map(currentTime, 0, videoDuration, pad.left, p.width - pad.right);
p.stroke(255, 0, 0, 150);
p.strokeWeight(1.5);
p.line(x, pad.top, x, p.height - pad.bottom);
const videoAbsTimeMs = appState.videoStartDate.getTime() + (currentTime * 1000);
const canIndex = findLastCanIndexBefore(videoAbsTimeMs, appState.canData);
if (canIndex !== -1) {
const canMsg = appState.canData[canIndex];
const y = p.map(canMsg.speed, minSpeed, maxSpeed, p.height - pad.bottom, pad.top);
p.fill(255, 0, 0);
p.noStroke();
p.ellipse(x, y, 8, 8);
}
}
p.windowResized = function () {
p.resizeCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight);
// Instead of resizing the buffer, we re-create it
staticBuffer = p.createGraphics(p.width, p.height);
// And we must re-draw the static content to the new buffer
if ((appState.canData.length > 0 || appState.vizData) && videoDuration) {
p.drawStaticGraphToBuffer(appState.canData, appState.vizData);
}
p.redraw();
};
};
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(sketch);
}
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(sketch);
}
}
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
if (videoBlob) {
const fileURL = URL.createObjectURL(videoBlob);
setupVideoPlayer(fileURL);
videoPlayer.onloadedmetadata = processAllData; // Process data ONLY when video is ready
if (videoPlayer.readyState >= 1) {
processAllData(); // Or if it was ready immediately
}
} else {
// If there's no video, there's nothing to process
console.log("DEBUG: No video blob found. Awaiting user input.");
}
}).catch(error => {
console.error("DEBUG: Error during Promise.all data loading:", error);
});
});
});
</script>
</body>
</html>