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.
948 lines
59 KiB
948 lines
59 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 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); staticBackgroundBuffer.resize(p.width, p.height); 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 };
|
|
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;
|
|
|
|
drawStaticGraphToBuffer(canSpeedData, radarData);
|
|
p.redraw();
|
|
};
|
|
function drawStaticGraphToBuffer(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.draw = function () {
|
|
if (document.documentElement.classList.contains('dark')) {
|
|
p.background(55, 65, 81);
|
|
} else {
|
|
p.background(255);
|
|
}
|
|
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); staticBuffer.resize(p.width, p.height); if ((appState.canData.length > 0 || appState.vizData) && videoDuration) { drawStaticGraphToBuffer(appState.canData, appState.vizData); } p.redraw(); };
|
|
};
|
|
|
|
function initializeVisualization(jsonString) {
|
|
try {
|
|
let cleanJsonString = jsonString.replace(/\b(Infinity|NaN|-Infinity)\b/gi, 'null');
|
|
appState.vizData = JSON.parse(cleanJsonString);
|
|
if (!appState.vizData.radarFrames || appState.vizData.radarFrames.length === 0) {
|
|
showModal('Error: The JSON file does not contain any radar frames.');
|
|
return;
|
|
}
|
|
const offsetMs = parseFloat(offsetInput.value) || 0;
|
|
appState.vizData.radarFrames.forEach(frame => {
|
|
frame.timestampMs = (appState.radarStartTimeMs + frame.timestamp) - appState.videoStartDate.getTime();
|
|
});
|
|
let snrValues = [], totalPoints = 0;
|
|
appState.vizData.radarFrames.forEach(frame => {
|
|
if (frame.pointCloud && frame.pointCloud.length > 0) {
|
|
totalPoints += frame.pointCloud.length;
|
|
frame.pointCloud.forEach(p => {
|
|
if (p.snr !== null) snrValues.push(p.snr);
|
|
});
|
|
}
|
|
});
|
|
if (totalPoints === 0) showModal('Warning: Loaded frames contain no point cloud data.');
|
|
appState.globalMinSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0;
|
|
appState.globalMaxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1;
|
|
snrMinInput.value = appState.globalMinSnr.toFixed(1);
|
|
snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
|
|
resetVisualization();
|
|
canvasPlaceholder.style.display = 'none';
|
|
featureToggles.classList.remove('hidden');
|
|
if (!appState.p5_instance) appState.p5_instance = new p5(sketch);
|
|
if (appState.speedGraphInstance && (appState.canData.length > 0 || appState.vizData)) {
|
|
appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration);
|
|
} else {
|
|
appState.p5_instance.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr);
|
|
appState.p5_instance.redraw();
|
|
}
|
|
} catch (error) {
|
|
showModal('Error parsing JSON file. Please check file format. Error: ' + error.message);
|
|
console.error("JSON Parsing Error:", error);
|
|
}
|
|
}
|
|
function setupVideoPlayer(fileURL) { videoPlayer.src = fileURL; videoPlayer.classList.remove('hidden'); videoPlaceholder.classList.add('hidden'); videoPlayer.playbackRate = parseFloat(speedSlider.value); }
|
|
function processCanLog(logContent) { if (!appState.videoStartDate) { showModal("Please load the video file first to synchronize the CAN log."); appState.rawCanLogText = logContent; return; } appState.canData = []; const lines = logContent.split('\n'); const logRegex = /(\d{2}):(\d{2}):(\d{2}):(\d{4})\s+Rx\s+\d+\s+0x([0-9a-fA-F]+)\s+s\s+\d+((?:\s+[0-9a-fA-F]{2})+)/; const canIdToDecode = '30F'; for (const line of lines) { const match = line.match(logRegex); if (match && match[5].toUpperCase() === canIdToDecode) { const [h, m, s, ms] = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4].substring(0, 3))]; const msgDate = new Date(appState.videoStartDate); msgDate.setUTCHours(h, m, s, ms); const dataBytes = match[6].trim().split(/\s+/).map(hex => parseInt(hex, 16)); if (dataBytes.length >= 2) { const rawVal = (dataBytes[0] << 3) | (dataBytes[1] >> 5); const speed = (rawVal * 0.1).toFixed(1); appState.canData.push({ time: msgDate.getTime(), speed: speed }); } } } appState.canData.sort((a, b) => a.time - b.time); appState.rawCanLogText = null; console.log(`Processed ${appState.canData.length} CAN messages for ID ${canIdToDecode}.`); if (appState.canData.length > 0 || appState.vizData) { speedGraphPlaceholder.classList.add('hidden'); if (!appState.speedGraphInstance) { appState.speedGraphInstance = new p5(speedGraphSketch); } if (videoPlayer.duration) { appState.speedGraphInstance.setData(appState.canData, appState.vizData, videoPlayer.duration); } } else { showModal(`No CAN messages with ID 0x${canIdToDecode} found.`); } }
|
|
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('appState.jsonFilename', appState.jsonFilename); calculateAndSetOffset(); const reader = new FileReader(); reader.onload = (e) => { saveFileToDB('json', e.target.result); initializeVisualization(e.target.result); }; reader.readAsText(file); });
|
|
videoFileInput.addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; appState.videoFilename = file.name; localStorage.setItem('appState.videoFilename', appState.videoFilename); saveFileToDB('video', file); calculateAndSetOffset(); if (appState.rawCanLogText) { processCanLog(appState.rawCanLogText); } const fileURL = URL.createObjectURL(file); setupVideoPlayer(fileURL); });
|
|
canFileInput.addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; appState.canLogFilename = file.name; localStorage.setItem('appState.canLogFilename', appState.canLogFilename); const reader = new FileReader(); reader.onload = (e) => { const logContent = e.target.result; saveFileToDB('canLogText', logContent); processCanLog(logContent); }; 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);
|
|
}
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initializeTheme(); // Setup theme here as soon as DOM is loaded.
|
|
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('appState.videoFilename');
|
|
appState.jsonFilename = localStorage.getItem('appState.jsonFilename');
|
|
appState.canLogFilename = localStorage.getItem('appState.canLogFilename');
|
|
console.log(`DEBUG: Found filenames in localStorage: video='${appState.videoFilename}', json='${appState.jsonFilename}', can='${appState.canLogFilename}'`);
|
|
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. Proceeding with setup.");
|
|
const finalizeSetup = () => {
|
|
console.log("DEBUG: Finalizing setup.");
|
|
if (jsonString) {
|
|
console.log("DEBUG: Initializing visualization with JSON data.");
|
|
initializeVisualization(jsonString);
|
|
} else {
|
|
console.log("DEBUG: No JSON string found to initialize.");
|
|
}
|
|
if (canLogText) {
|
|
console.log("DEBUG: Processing CAN log.");
|
|
processCanLog(canLogText);
|
|
} else {
|
|
console.log("DEBUG: No CAN log text found to process.");
|
|
}
|
|
};
|
|
if (videoBlob) {
|
|
console.log("DEBUG: Video blob exists. Setting up player.");
|
|
const fileURL = URL.createObjectURL(videoBlob);
|
|
setupVideoPlayer(fileURL);
|
|
videoPlayer.onloadedmetadata = () => {
|
|
console.log("DEBUG: 'onloadedmetadata' event fired.");
|
|
finalizeSetup();
|
|
};
|
|
if (videoPlayer.readyState >= 1) {
|
|
console.log("DEBUG: Video was already ready (readyState >= 1). Manually calling final setup.");
|
|
finalizeSetup();
|
|
}
|
|
} else {
|
|
console.log("DEBUG: No video blob. Calling final setup immediately.");
|
|
finalizeSetup();
|
|
}
|
|
}).catch(error => {
|
|
console.error("DEBUG: Error during Promise.all data loading:", error);
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|