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.
 
 
 

1429 lines
46 KiB

// ===========================================================================================================
// 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 { zoomSketch } from "./p5/zoomSketch.js";
import {
showModal,
hideModal,
updateLoadingModal,
showLoadingModal,
} from "./modal.js"; // Modify this import
import { animationLoop } from "./sync.js";
import { radarSketch } from "./p5/radarSketch.js";
import { speedGraphSketch } from "./p5/speedGraphSketch.js";
import { parseVisualizationJson, parseJsonWithOboe } from "./fileParsers.js";
import {
MAX_TRAJECTORY_LENGTH,
VIDEO_FPS,
RADAR_X_MIN,
RADAR_X_MAX,
RADAR_Y_MIN,
RADAR_Y_MAX,
} from "./constants.js";
import {
findRadarFrameIndexForTime,
extractTimestampInfo,
parseTimestamp,
throttle,
formatTime,
} from "./utils.js";
import { appState } from "./state.js";
window.appState = appState; // exposing the appState to console
import {
themeToggleBtn,
canvasContainer,
canvasPlaceholder,
videoPlayer,
videoPlaceholder,
loadJsonBtn,
loadVideoBtn,
jsonFileInput,
videoFileInput,
playPauseBtn,
stopBtn,
timelineSlider,
frameCounter,
offsetInput,
speedSlider,
speedDisplay,
featureToggles,
toggleSnrColor,
toggleClusterColor,
toggleInlierColor,
toggleStationaryColor,
toggleVelocity,
toggleTracks,
toggleEgoSpeed,
toggleFrameNorm,
toggleDebugOverlay,
toggleDebug2Overlay,
egoSpeedDisplay,
debugOverlay,
snrMinInput,
snrMaxInput,
applySnrBtn,
autoOffsetIndicator,
clearCacheBtn,
speedGraphContainer,
speedGraphPlaceholder,
toggleCloseUp,
updateFrame,
resetVisualization,
updateDebugOverlay,
timelineTooltip,
saveSessionBtn,
loadSessionBtn,
sessionFileInput,
togglePredictedPos,
toggleCovariance,
updatePersistentOverlays,
collapsibleMenu,
toggleMenuBtn,
fullscreenBtn,
mainContent,
closeMenuBtn,
fullscreenEnterIcon,
fullscreenExitIcon,
menuScrim,
toggleConfirmedOnly,
resetUIForNewLoad,
} from "./dom.js";
import { initializeTheme } from "./theme.js";
import { initDB, saveFileWithMetadata, loadFreshFileFromDB } from "./db.js";
let seekDebounceTimer = null; //timeline slider variables.
let lastScrollTime = 0; //timeline slider variables.
let scrollSpeed = 0; //timeline slider variables.
// --- [START] CORRECTED UNIFIED FILE LOADING LOGIC ---
// These variables will hold the file objects during the loading process.
let jsonFileToLoad = null;
let videoFileToLoad = null;
/**
* This is the main handler for both manual clicks and drag-and-drop.
* It identifies the files and triggers the unified processing pipeline.
*/
function handleFiles(files) {
// Reset the UI and clear any old data to prepare for a new session
resetUIForNewLoad();
appState.vizData = null;
// Identify the JSON and Video files from the list of files provided
Array.from(files).forEach((file) => {
if (file.name.endsWith(".json")) {
jsonFileToLoad = file;
} else if (file.type.startsWith("video/")) {
videoFileToLoad = file;
}
});
// Start the main loading process if we have at least one valid file.
if (jsonFileToLoad || videoFileToLoad) {
processFilePipeline();
}
}
// Wire up the manual file inputs to the new handler
jsonFileInput.addEventListener("change", (event) =>
handleFiles(event.target.files)
);
videoFileInput.addEventListener("change", (event) =>
handleFiles(event.target.files)
);
// Wire up the drag-and-drop functionality
const dropZone = document.querySelector("main");
dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
dropZone.style.border = "2px dashed #3b82f6";
});
dropZone.addEventListener("dragleave", () => {
dropZone.style.border = "none";
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
dropZone.style.border = "none";
handleFiles(event.dataTransfer.files);
});
// PASTE THIS NEW, ENHANCED FUNCTION INTO main.js
/**
* The core processing pipeline. This function orchestrates the entire
* loading, parsing, and initialization process in the correct order.
*/
async function processFilePipeline() {
// 1. Show the unified loading modal.
showLoadingModal("Starting file load...");
// 2. Handle JSON Parsing FIRST (if a JSON file is present)
if (jsonFileToLoad) {
// ... (The JSON loading part remains unchanged) ...
appState.jsonFilename = jsonFileToLoad.name;
localStorage.setItem("jsonFilename", appState.jsonFilename);
await saveFileWithMetadata("json", jsonFileToLoad);
calculateAndSetOffset();
const worker = new Worker("./src/parser.worker.js");
const parsedData = await new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const { type, data, percent, message } = e.data;
if (type === "progress") {
updateLoadingModal(percent * 0.8, `Parsing JSON (${percent}%)...`);
} else if (type === "complete") {
worker.terminate();
resolve(data);
} else if (type === "error") {
worker.terminate();
reject(new Error(message));
}
};
worker.postMessage({ file: jsonFileToLoad });
});
const result = await parseVisualizationJson(parsedData, appState.radarStartTimeMs, appState.videoStartDate);
if (result.error) {
hideModal();
showModal(result.error);
return;
}
appState.vizData = result.data;
appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr;
}
// 3. Handle Video Loading SECOND, now with a simulated progress spinner
if (videoFileToLoad) {
appState.videoFilename = videoFileToLoad.name;
localStorage.setItem("videoFilename", appState.videoFilename);
await saveFileWithMetadata("video", videoFileToLoad);
calculateAndSetOffset();
// --- START: NEW SIMULATED PROGRESS LOGIC ---
// Start a simple text spinner animation in the modal
const spinnerChars = ["|", "/", "-", "\\"];
let spinnerIndex = 0;
const spinnerInterval = setInterval(() => {
const spinnerText = spinnerChars[spinnerIndex % spinnerChars.length];
updateLoadingModal(85, `Loading video ${spinnerText}`); // Keep progress bar mostly full
spinnerIndex++;
}, 150); // Update the spinner character every 150ms
await new Promise((resolve, reject) => {
const onReady = () => {
clearInterval(spinnerInterval); // IMPORTANT: Stop the spinner
resolve();
};
const onError = (e) => {
clearInterval(spinnerInterval); // IMPORTANT: Stop the spinner on error
reject(e);
};
videoPlayer.addEventListener('canplaythrough', onReady, { once: true });
videoPlayer.addEventListener('error', onError, { once: true });
setupVideoPlayer(URL.createObjectURL(videoFileToLoad));
});
// --- END: NEW SIMULATED PROGRESS LOGIC ---
}
// 4. Finalize the setup
updateLoadingModal(95, "Finalizing visualization...");
finalizeSetup();
setTimeout(() => {
updateLoadingModal(100, "Complete!");
setTimeout(hideModal, 300);
}, 200);
}
/* async function processFilePipeline() {
// 1. Show the unified loading modal.
showLoadingModal("Starting file load...");
// 2. Handle JSON Parsing (if a JSON file is present)
if (jsonFileToLoad) {
appState.jsonFilename = jsonFileToLoad.name;
localStorage.setItem("jsonFilename", appState.jsonFilename);
await saveFileWithMetadata("json", jsonFileToLoad);
calculateAndSetOffset();
const worker = new Worker("./src/parser.worker.js");
// Use a promise to wait for the worker to finish parsing
const parsedData = await new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const { type, data, percent, message } = e.data;
if (type === "progress") {
updateLoadingModal(
15 + percent * 0.8,
`Parsing JSON (${percent}%)...`
);
} else if (type === "complete") {
worker.terminate();
resolve(data);
} else if (type === "error") {
worker.terminate();
reject(new Error(message));
}
};
worker.postMessage({ file: jsonFileToLoad });
});
// 4. Post-process the parsed JSON data
updateLoadingModal(95, "Finalizing visualization...");
const result = await parseVisualizationJson(
parsedData,
appState.radarStartTimeMs,
appState.videoStartDate
);
if (result.error) {
hideModal();
showModal(result.error);
return;
}
appState.vizData = result.data;
appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr;
}
// 3. Handle Video Loading first (if a video file is present)
if (videoFileToLoad) {
updateLoadingModal(5, `Loading video: ${videoFileToLoad.name}`);
appState.videoFilename = videoFileToLoad.name;
localStorage.setItem("videoFilename", appState.videoFilename);
await saveFileWithMetadata("video", videoFileToLoad);
calculateAndSetOffset();
// Use a promise to wait until the video metadata is loaded
await new Promise((resolve, reject) => {
videoPlayer.onloadedmetadata = resolve;
videoPlayer.onerror = reject;
setupVideoPlayer(URL.createObjectURL(videoFileToLoad));
});
updateLoadingModal(15, "Video metadata loaded.");
}
// 5. Finalize the setup and hide the modal
updateLoadingModal(100, "Complete!");
finalizeSetup();
setTimeout(hideModal, 300);
} */
/**
* This function creates the p5 instances and finishes the UI setup.
* It's called after all file processing is complete.
*/
function finalizeSetup() {
// Make sure the canvas placeholder is hidden and toggles are visible
canvasPlaceholder.style.display = "none";
featureToggles.classList.remove("hidden");
// Create the p5 instances
if (!appState.p5_instance) {
appState.p5_instance = new p5(radarSketch);
}
if (!appState.zoomSketchInstance) {
appState.zoomSketchInstance = new p5(zoomSketch, "zoom-canvas-container");
}
// Setup the speed graph if we have the necessary data
if (appState.vizData && videoPlayer.duration > 0) {
speedGraphPlaceholder.classList.add("hidden");
if (!appState.speedGraphInstance) {
appState.speedGraphInstance = new p5(speedGraphSketch);
}
appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration);
}
// Reset the visualization to the first frame
if (appState.vizData) {
snrMinInput.value = appState.globalMinSnr.toFixed(1);
snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
resetVisualization();
}
}
// --- [END] CORRECTED UNIFIED FILE LOADING LOGIC ---
// Sets up the video player with the given file URL.
function setupVideoPlayer(fileURL) {
videoPlayer.src = fileURL;
videoPlayer.classList.remove("hidden");
videoPlaceholder.classList.add("hidden");
videoPlayer.playbackRate = parseFloat(speedSlider.value);
}
// In src/main.js, add this new function
function loadVideoWithProgress(videoObject) {
if (!videoObject) return;
showModal("Loading video...", false, true);
updateModalProgress(0);
// Define event handlers so we can add and remove them correctly
const onProgress = () => {
if (videoPlayer.duration > 0) {
// Find the end of the buffered content
const bufferedEnd =
videoPlayer.buffered.length > 0 ? videoPlayer.buffered.end(0) : 0;
const percent = (bufferedEnd / videoPlayer.duration) * 100;
updateModalProgress(percent);
}
};
const onCanPlayThrough = () => {
updateModalProgress(100);
// Give the user a moment to see 100% before closing the modal
setTimeout(() => {
document.getElementById("modal-ok-btn").click();
}, 400);
// Clean up the event listeners we added
videoPlayer.removeEventListener("progress", onProgress);
videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
};
const onError = () => {
showModal("Error: Could not load the video file.");
// Clean up event listeners on error
videoPlayer.removeEventListener("progress", onProgress);
videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
videoPlayer.removeEventListener("error", onError);
};
// This one-time event is for re-syncing data once the video's metadata is ready
videoPlayer.addEventListener(
"loadedmetadata",
() => {
// This is the perfect time to re-sync data if needed
if (appState.vizData) {
console.log("DEBUG: Video metadata loaded. Re-calculating timestamps.");
appState.vizData.radarFrames.forEach((frame) => {
frame.timestampMs =
appState.radarStartTimeMs +
frame.timestamp -
appState.videoStartDate.getTime();
});
resetVisualization();
}
// --- START: New Speed Graph Logic ---
// If we have data and the video is ready, create/update the speed graph
if (appState.vizData && videoPlayer.duration > 0) {
speedGraphPlaceholder.classList.add("hidden");
if (!appState.speedGraphInstance) {
appState.speedGraphInstance = new p5(speedGraphSketch);
}
appState.speedGraphInstance.setData(
appState.vizData,
videoPlayer.duration
);
}
// --- END: New Speed Graph Logic ---
},
{ once: true }
); // { once: true } makes sure this runs only once per load
// { once: true } //makes sure this runs only once per load
// Add the listeners for progress tracking
videoPlayer.addEventListener("progress", onProgress);
videoPlayer.addEventListener("canplaythrough", onCanPlayThrough);
videoPlayer.addEventListener("error", onError);
// Create the object URL and set the video source to trigger loading
const fileURL = URL.createObjectURL(videoObject);
setupVideoPlayer(fileURL);
}
// Event listener for loading JSON file.
loadJsonBtn.addEventListener("click", () => jsonFileInput.click());
loadVideoBtn.addEventListener("click", () => videoFileInput.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();
}
});
// Event listener for saving the session
saveSessionBtn.addEventListener("click", () => {
// We can only save a session if at least one data file has been loaded.
if (!appState.jsonFilename && !appState.videoFilename) {
showModal("Nothing to save. Please load data files first.");
return;
}
// Collect all relevant state into a single object.
const sessionState = {
version: 1,
jsonFilename: appState.jsonFilename,
videoFilename: appState.videoFilename,
offset: offsetInput.value,
playbackSpeed: speedSlider.value,
snrMin: snrMinInput.value,
snrMax: snrMaxInput.value,
toggles: {
snrColor: toggleSnrColor.checked,
clusterColor: toggleClusterColor.checked,
inlierColor: toggleInlierColor.checked,
stationaryColor: toggleStationaryColor.checked,
velocity: toggleVelocity.checked,
tracks: toggleTracks.checked,
egoSpeed: toggleEgoSpeed.checked,
frameNorm: toggleFrameNorm.checked,
debugOverlay: toggleDebugOverlay.checked,
debug2Overlay: toggleDebug2Overlay.checked,
closeUp: toggleCloseUp.checked,
predictedPos: togglePredictedPos.checked,
covariance: toggleCovariance.checked,
},
};
const sessionString = JSON.stringify(sessionState, null, 2);
const blob = new Blob([sessionString], { type: "application/json" });
const url = URL.createObjectURL(blob);
// --- Dynamic Filename Logic ---
const now = new Date();
const pad = (num) => String(num).padStart(2, "0");
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)}`;
const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(
now.getSeconds()
)}`;
const timestamp = `${date}_${time}`;
const defaultFilename = `visualizer-session_${timestamp}.json`;
// --- Trigger "Save As" Dialog ---
const a = document.createElement("a");
a.href = url;
// This is the key instruction for the browser. It suggests a filename
// and signals that this should open a "Save As" dialog.
a.download = defaultFilename;
document.body.appendChild(a);
a.click(); // Programmatically clicking the link triggers the download/save dialog.
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/**
* A callback that runs for every new video frame presented to the screen.
* It calculates the time since the last frame to measure video performance.
*/
function videoFrameCallback(now, metadata) {
// 'now' is a high-resolution timestamp provided by the browser
if (appState.lastVideoFrameTime > 0) {
const delta = now - appState.lastVideoFrameTime;
appState.videoFrameRenderTime = delta;
}
appState.lastVideoFrameTime = now;
// Re-register the callback for the next frame to create a loop
videoPlayer.requestVideoFrameCallback(videoFrameCallback);
}
// When "Load Session" is clicked, it triggers the hidden file input.
loadSessionBtn.addEventListener("click", () => {
sessionFileInput.click();
});
// This listener handles the selected session file.
sessionFileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
// Make the function async to use 'await'
try {
const sessionState = JSON.parse(e.target.result);
// Basic validation to ensure it's a valid session file.
if (sessionState.version !== 1 || !sessionState.jsonFilename) {
showModal("Error: Invalid or corrupted session file.");
return;
}
// --- START: New Robust Session Check ---
// 1. Before doing anything else, check if the required files exist in the cache.
// We use the same 'loadFreshFileFromDB' function that the startup process uses.
const videoBlob = await loadFreshFileFromDB(
"video",
sessionState.videoFilename
);
const jsonBlob = await loadFreshFileFromDB(
"json",
sessionState.jsonFilename
);
// 2. If either file is missing from the cache, show an informative error and stop.
if (!jsonBlob || !videoBlob) {
showModal(`Session load failed: The required data files are not in the application's cache.
Please manually load '${sessionState.jsonFilename}' and '${sessionState.videoFilename}' before loading this session.`);
event.target.value = ""; // Reset file input
return;
}
// 3. If we get here, it means the files ARE in the cache and match the session!
// It is now safe to set localStorage and reload the page.
localStorage.setItem("jsonFilename", sessionState.jsonFilename || "");
localStorage.setItem("videoFilename", sessionState.videoFilename || "");
localStorage.setItem("visualizerOffset", sessionState.offset || "0");
localStorage.setItem("playbackSpeed", sessionState.playbackSpeed || "1");
localStorage.setItem("snrMin", sessionState.snrMin || "");
localStorage.setItem("snrMax", sessionState.snrMax || "");
if (sessionState.toggles) {
localStorage.setItem(
"togglesState",
JSON.stringify(sessionState.toggles)
);
}
// Inform the user and then reload the page to apply the session.
showModal(
"Session files found in cache. The application will now reload."
).then(() => {
window.location.reload();
});
// --- END: New Robust Session Check ---
} catch (error) {
showModal("Error: Could not parse the session file. It may be invalid.");
console.error("Session load error:", error);
}
};
reader.readAsText(file);
event.target.value = ""; // Clear the input for future loads.
});
// --- END: Add Session Management Logic ---
// --- Collapsible Menu Logic (Overlay Version) ---
function toggleMenu(show) {
if (show) {
collapsibleMenu.classList.remove("-translate-x-full");
menuScrim.classList.remove("hidden"); // Show the scrim
// The line that pushed the content has been REMOVED.
} else {
collapsibleMenu.classList.add("-translate-x-full");
menuScrim.classList.add("hidden"); // Hide the scrim
}
}
toggleConfirmedOnly.addEventListener("change", () => {
if (appState.p5_instance) {
appState.p5_instance.redraw();
}
});
// Open the menu
toggleMenuBtn.addEventListener("click", () => toggleMenu(true));
// Close the menu with the 'X' button
closeMenuBtn.addEventListener("click", () => toggleMenu(false));
// NEW: Close the menu by clicking on the scrim
menuScrim.addEventListener("click", () => toggleMenu(false));
// --- Fullscreen Logic ---
fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
});
// This listener updates the icon whenever fullscreen state changes,
// whether it's triggered by our button or the F11 key.
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
fullscreenEnterIcon.classList.add("hidden");
fullscreenExitIcon.classList.remove("hidden");
} else {
fullscreenEnterIcon.classList.remove("hidden");
fullscreenExitIcon.classList.add("hidden");
}
});
// jsonFileInput event listener
/* jsonFileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
appState.jsonFilename = file.name;
localStorage.setItem("jsonFilename", appState.jsonFilename);
calculateAndSetOffset();
saveFileWithMetadata("json", file); // We still cache the raw file
// 1. Show the modal with the progress bar
showModal("Parsing large JSON file...", false, true);
updateModalProgress(0);
// 2. Create a new Worker from our script
const worker = new Worker("./src/parser.worker.js");
// 3. Set up listeners for messages FROM the worker
worker.onmessage = async (e) => {
const { type, data, message, percent } = e.data;
if (type === "progress") {
updateModalProgress(percent);
} else if (type === "complete") {
updateModalProgress(100);
const result = await parseVisualizationJson(
data,
appState.radarStartTimeMs,
appState.videoStartDate
);
if (result.error) {
showModal(result.error);
worker.terminate(); // Terminate worker on error
return;
}
if (appState.p5_instance) {
appState.p5_instance.remove();
appState.p5_instance = null;
}
if (appState.speedGraphInstance) {
appState.speedGraphInstance.remove();
appState.speedGraphInstance = null;
speedGraphPlaceholder.classList.remove("hidden");
}
appState.vizData = result.data;
appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr;
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(radarSketch);
}
// --- START: This is the new, corrected logic ---
// After processing the new JSON, check if a video is already loaded and ready.
// If it is, this is the trigger to create or update the speed graph.
if (appState.vizData && videoPlayer.duration > 0) {
speedGraphPlaceholder.classList.add("hidden");
if (!appState.speedGraphInstance) {
appState.speedGraphInstance = new p5(speedGraphSketch);
}
appState.speedGraphInstance.setData(
appState.vizData,
videoPlayer.duration
);
}
// --- END: This is the new, corrected logic ---
document.getElementById("modal-ok-btn").click();
worker.terminate();
} else if (type === "error") {
showModal(message);
worker.terminate();
}
};
// 4. Send the file TO the worker to start the job
worker.postMessage({ file: file });
}); */
// Event listener for video file input change.
/* videoFileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
appState.videoFilename = file.name;
localStorage.setItem("videoFilename", appState.videoFilename);
saveFileWithMetadata("video", file);
calculateAndSetOffset();
loadVideoWithProgress(file);
// Start the performance monitoring loop as soon as a video is attached.
videoPlayer.requestVideoFrameCallback(videoFrameCallback);
}); */
// Event listener for offset input change.
offsetInput.addEventListener("input", () => {
autoOffsetIndicator.classList.add("hidden");
localStorage.setItem("visualizerOffset", offsetInput.value);
});
// Event listener for apply SNR button click.
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();
}
});
// Event listener for play/pause button click.
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();
}
});
// Event listener for stop button click.
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();
});
// Event listener for timeline slider input.
// In src/main.js, REPLACE the existing timelineSlider 'input' event listener with this:
timelineSlider.addEventListener("input", (event) => {
if (!appState.vizData) return;
updateDebugOverlay(videoPlayer.currentTime);
updatePersistentOverlays(videoPlayer.currentTime);
// --- 1. Live Seeking (Throttled for performance) ---
// This part gives you the immediate visual feedback as you drag the slider.
// We use a simple timestamp check to prevent it from running too often.
const now = performance.now();
if (
!timelineSlider.lastInputTime ||
now - timelineSlider.lastInputTime > 32
) {
// ~30fps throttle
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 = now;
timelineSlider.lastInputTime = now;
}
// --- 2. Final, Precise Sync (Debounced for reliability) ---
// This part ensures a perfect sync only AFTER you stop moving the slider.
clearTimeout(seekDebounceTimer); // Always cancel the previously scheduled sync
seekDebounceTimer = setTimeout(() => {
console.log("Slider movement stopped. Performing final, debounced resync.");
const finalFrame = parseInt(event.target.value, 10);
updateFrame(finalFrame, true); // Perform the final, precise seek
// Also update the debug overlay with the final, settled time
updateDebugOverlay(videoPlayer.currentTime);
}, 250); // Wait for 250ms of inactivity before firing
});
// --- Timeline Scroll-to-Seek Logic ---
timelineSlider.addEventListener("wheel", (event) => {
if (!appState.vizData) return;
// 1. Prevent the page from scrolling up and down
event.preventDefault();
// 2. Calculate scroll speed
const now = performance.now();
const timeDelta = now - (lastScrollTime || now); // Handle first scroll
lastScrollTime = now;
// Calculate speed as "events per second", giving more weight to recent, fast scrolls
scrollSpeed = timeDelta > 0 ? 1000 / timeDelta : scrollSpeed;
// 3. Map scroll speed to a dynamic seek multiplier
// This creates a nice acceleration curve. The '50' is a sensitivity value you can adjust.
const speedMultiplier = 1 + Math.floor(scrollSpeed / 4);
const baseSeekAmount = 1; // Base frames to move on a slow scroll
let seekAmount = Math.max(baseSeekAmount, speedMultiplier);
// 4. Calculate the new frame index
const direction = Math.sign(event.deltaY); // +1 for down/right, -1 for up/left
const currentFrame = parseInt(timelineSlider.value, 10);
let newFrame = currentFrame - seekAmount * direction;
// Clamp the new frame to the valid range
const totalFrames = appState.vizData.radarFrames.length - 1;
newFrame = Math.max(0, Math.min(newFrame, totalFrames));
// 5. Update the UI
if (appState.isPlaying) {
playPauseBtn.click(); // Pause if playing
}
updateFrame(newFrame, true);
// 6. Reuse the debouncer for a final, precise sync after scrolling stops
clearTimeout(seekDebounceTimer);
seekDebounceTimer = setTimeout(() => {
console.log("Scrolling stopped. Performing final, debounced resync.");
updateFrame(newFrame, true);
updatePersistentOverlays(videoPlayer.currentTime);
updateDebugOverlay(videoPlayer.currentTime);
}, 300); // Wait 300ms after the last scroll event
});
// In src/main.js, add this new block of event listeners
// --- Timeline Scrub-to-Seek Preview Logic ---
timelineSlider.addEventListener("mouseover", () => {
if (appState.vizData) {
timelineTooltip.classList.remove("hidden");
}
});
timelineSlider.addEventListener("mouseout", () => {
timelineTooltip.classList.add("hidden");
});
timelineSlider.addEventListener("mousemove", (event) => {
if (!appState.vizData) return;
// 1. Calculate the hover position as a fraction (0.0 to 1.0)
const rect = timelineSlider.getBoundingClientRect();
const hoverFraction = (event.clientX - rect.left) / rect.width;
// 2. Calculate the corresponding frame index
const sliderMax =
parseInt(timelineSlider.max, 10) || appState.vizData.radarFrames.length - 1;
let frameIndex = Math.round(hoverFraction * sliderMax);
// The value is already clamped by this calculation, but an extra check is safe
frameIndex = Math.max(0, Math.min(frameIndex, sliderMax));
const frameData = appState.vizData.radarFrames[frameIndex];
if (!frameData) return;
// 3. Update the tooltip's content
const formattedTime = formatTime(frameData.timestampMs);
timelineTooltip.innerHTML = `Frame: ${
frameIndex + 1
}<br>Time: ${formattedTime}`;
// 4. Position the tooltip horizontally above the cursor
// The horizontal position is the mouse's X relative to the slider's start
const tooltipX = event.clientX - rect.left;
timelineTooltip.style.left = `${tooltipX}px`;
});
// Event listener for speed slider input.
speedSlider.addEventListener("input", (event) => {
const speed = parseFloat(event.target.value);
videoPlayer.playbackRate = speed;
speedDisplay.textContent = `${speed.toFixed(1)}x`;
});
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();
updatePersistentOverlays(videoPlayer.currentTime);
});
});
[
toggleVelocity,
toggleEgoSpeed,
toggleFrameNorm,
toggleTracks,
toggleDebugOverlay,
toggleDebug2Overlay,
].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 || t === toggleDebug2Overlay) {
updateDebugOverlay(videoPlayer.currentTime);
updatePersistentOverlays(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) => {
// --- FIX APPLIED HERE ---
// We only want to block shortcuts if the user is actively typing in a text or number input.
// This allows shortcuts to work even when other elements, like the timeline slider, are focused.
const isTextInputFocused =
event.target.tagName === "INPUT" &&
(event.target.type === "text" || event.target.type === "number");
if (isTextInputFocused) {
return;
}
// --- END OF FIX ---
const key = event.key;
// We can add any new shortcut keys to this array.
const recognizedKeys = [
"ArrowRight",
"ArrowLeft",
" ",
"1",
"2",
"3",
"4",
"t",
"d",
"g",
"r",
"p",
"a",
"s",
"m",
"q",
"c",
];
if (!appState.vizData || !recognizedKeys.includes(key)) {
return;
}
event.preventDefault();
// --- Spacebar for Play/Pause ---
if (key === " ") {
playPauseBtn.click();
}
// --- Arrow keys for frame-by-frame seeking ---
if (key === "ArrowRight" || key === "ArrowLeft") {
if (appState.isPlaying) {
playPauseBtn.click();
}
let newFrame = appState.currentFrame;
if (key === "ArrowRight") {
newFrame = Math.min(
appState.vizData.radarFrames.length - 1,
appState.currentFrame + 1
);
} else if (key === "ArrowLeft") {
newFrame = Math.max(0, appState.currentFrame - 1);
}
if (newFrame !== appState.currentFrame) {
updateFrame(newFrame, true);
}
}
// --- Number keys for color modes ---
if (key >= "1" && key <= "4") {
const colorToggles = [
toggleSnrColor,
toggleClusterColor,
toggleInlierColor,
toggleStationaryColor,
];
const toggleIndex = parseInt(key) - 1;
if (colorToggles[toggleIndex]) {
colorToggles[toggleIndex].click();
}
}
if (key === "q") {
themeToggleBtn.click();
}
if (key === "t") {
toggleTracks.click();
}
if (key === "d") {
toggleVelocity.click();
}
if (key === "g") {
toggleCloseUp.click();
}
if (key === "r") {
resetVisualization();
}
if (key === "c") {
appState.isRawOnlyMode = !appState.isRawOnlyMode;
if (appState.p5_instance) {
appState.p5_instance.redraw();
}
}
if (key === "p") {
togglePredictedPos.click();
appState.p5_instance.redraw();
}
if (key === "s") {
toggleSnrColor.click();
}
if (key === "a") {
toggleDebugOverlay.click();
toggleDebug2Overlay.click();
if (isDebug1Visible && isDebug2Visible) {
radarInfoOverlay.classList.add("hidden");
videoInfoOverlay.classList.add("hidden");
return;
}
// Otherwise, make sure they are visible.
radarInfoOverlay.classList.remove("hidden");
videoInfoOverlay.classList.remove("hidden");
}
if (key === "m") {
if (collapsibleMenu.classList.contains("-translate-x-full")) {
// If the menu is hidden (closed), trigger a click on the OPEN button.
toggleMenuBtn.click();
} else {
// If the menu is not hidden (it's open), trigger a click on the CLOSE button.
closeMenuBtn.click();
}
}
});
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`);
}
}
}
}
// Application Initialization
// FILE: steps/src/main.js
// REPLACE the entire 'document.addEventListener("DOMContentLoaded", ...)' block with this:
/* document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
console.log("DEBUG: DOMContentLoaded fired. Starting session load.");
initDB(async () => {
console.log("DEBUG: Database initialized.");
// --- START: Restore Session and UI State from localStorage ---
const savedOffset = localStorage.getItem("visualizerOffset");
if (savedOffset !== null) {
offsetInput.value = savedOffset;
}
const savedSpeed = localStorage.getItem("playbackSpeed");
if (savedSpeed) {
speedSlider.value = savedSpeed;
speedDisplay.textContent = `${parseFloat(savedSpeed).toFixed(1)}x`;
videoPlayer.playbackRate = savedSpeed;
}
const savedSnrMin = localStorage.getItem("snrMin");
if (savedSnrMin) snrMinInput.value = savedSnrMin;
const savedSnrMax = localStorage.getItem("snrMax");
if (savedSnrMax) snrMaxInput.value = savedSnrMax;
// If custom SNR values were part of the session, apply them to the app state.
if (savedSnrMin && savedSnrMax) {
appState.globalMinSnr = parseFloat(savedSnrMin);
appState.globalMaxSnr = parseFloat(savedSnrMax);
}
// Restore the state of all toggle checkboxes.
const savedToggles = localStorage.getItem("togglesState");
if (savedToggles) {
try {
const toggles = JSON.parse(savedToggles);
toggleSnrColor.checked = toggles.snrColor;
toggleClusterColor.checked = toggles.clusterColor;
toggleInlierColor.checked = toggles.inlierColor;
toggleStationaryColor.checked = toggles.stationaryColor;
toggleVelocity.checked = toggles.velocity;
toggleTracks.checked = toggles.tracks;
toggleEgoSpeed.checked = toggles.egoSpeed;
toggleFrameNorm.checked = toggles.frameNorm;
toggleDebugOverlay.checked = toggles.debugOverlay;
toggleDebug2Overlay.checked = toggles.debug2Overlay;
toggleCloseUp.checked = toggles.closeUp;
togglePredictedPos.checked = toggles.predictedPos;
toggleCovariance.checked = toggles.covariance;
} catch (e) {
console.error("Could not parse saved toggle state.", e);
}
}
// --- END: Restore Session and UI State ---
// Get the filenames we EXPECT to load from localStorage
appState.videoFilename = localStorage.getItem("videoFilename");
appState.jsonFilename = localStorage.getItem("jsonFilename");
calculateAndSetOffset();
const videoBlob = await loadFreshFileFromDB(
"video",
appState.videoFilename
);
const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename);
console.log(
"DEBUG: Freshness checks complete. Proceeding with valid data."
);
const finalizeSetup = async (parsedJson) => {
if (parsedJson) {
const result = await parseVisualizationJson(
parsedJson,
appState.radarStartTimeMs,
appState.videoStartDate
);
if (!result.error) {
appState.vizData = result.data;
// Note: We use the saved SNR values if they exist, otherwise the file's global values.
appState.globalMinSnr = savedSnrMin
? parseFloat(savedSnrMin)
: result.minSnr;
appState.globalMaxSnr = savedSnrMax
? parseFloat(savedSnrMax)
: result.maxSnr;
snrMinInput.value = savedSnrMin || result.minSnr.toFixed(1);
snrMaxInput.value = savedSnrMax || result.maxSnr.toFixed(1);
} else {
showModal(result.error);
}
}
if (appState.vizData) {
resetVisualization();
canvasPlaceholder.style.display = "none";
featureToggles.classList.remove("hidden");
if (!appState.p5_instance) {
appState.p5_instance = new p5(radarSketch);
}
if (!appState.zoomSketchInstance) {
appState.zoomSketchInstance = new p5(zoomSketch, 'zoom-canvas-container');
}
}
//document.getElementById("zoom-panel").style.display = "none";
};
if (jsonBlob) {
showModal("Loading data from cache...", false, true);
updateModalProgress(0);
const worker = new Worker("./src/parser.worker.js");
worker.onmessage = async (e) => {
const { type, data, message, percent } = e.data;
if (type === "progress") {
updateModalProgress(percent);
} else if (type === "complete") {
updateModalProgress(100);
await finalizeSetup(data);
document.getElementById("modal-ok-btn").click();
worker.terminate();
loadVideoWithProgress(videoBlob);
} else if (type === "error") {
showModal(message);
worker.terminate();
}
};
worker.postMessage({ file: jsonBlob });
} else {
await finalizeSetup(null);
loadVideoWithProgress(videoBlob);
}
});
}); */
/* // In main.js
// --- INITIALIZATION ---
document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
initDB(async () => {
console.log("Database initialized. Checking for cached session...");
// --- START: RESTORED AUTO-RELOAD LOGIC ---
// Get the filenames we expect to find in the cache
appState.jsonFilename = localStorage.getItem("jsonFilename");
appState.videoFilename = localStorage.getItem("videoFilename");
if (appState.jsonFilename) {
const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename);
const videoBlob = await loadFreshFileFromDB("video", appState.videoFilename);
// If the required JSON file is in the cache, start the loading process
if (jsonBlob) {
console.log("Cached session found. Starting auto-reload...");
filesToLoad = { json: jsonBlob, video: videoBlob };
startLoadingProcess(); // This will show our new unified modal
processJsonFile(jsonBlob); // Start the process with the cached file
} else {
console.log("No valid cached session found. Ready for manual file load.");
}
} else {
console.log("No previous session found. Ready for manual file load.");
}
// --- END: RESTORED AUTO-RELOAD LOGIC ---
});
}); */
// --- [START] CORRECTED INITIALIZATION LOGIC ---
document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
initDB(async () => {
console.log("Database initialized. Checking for cached session...");
appState.jsonFilename = localStorage.getItem("jsonFilename");
appState.videoFilename = localStorage.getItem("videoFilename");
if (appState.jsonFilename) {
const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename);
const videoBlob = await loadFreshFileFromDB(
"video",
appState.videoFilename
);
if (jsonBlob) {
console.log("Cached session found. Starting auto-reload...");
// Use the handleFiles function to trigger the pipeline with cached blobs
handleFiles([jsonBlob, videoBlob].filter(Boolean)); // .filter(Boolean) removes null videoBlob if it doesn't exist
} else {
console.log(
"Cached session is stale or missing files. Ready for manual load."
);
}
} else {
console.log("No previous session found. Ready for manual file load.");
}
});
});
// --- [END] CORRECTED INITIALIZATION LOGIC ---
// In src/main.js, add this new event listener
offsetInput.addEventListener("keydown", (event) => {
// Check if the key pressed was 'Enter'
if (event.key === "Enter") {
// Prevent the default browser action for the Enter key (like submitting a form)
event.preventDefault();
// Make sure visualization data is loaded before proceeding
if (!appState.vizData) return;
console.log(
`Enter pressed. Forcing resync with new offset: ${offsetInput.value}`
);
// If the video is playing, pause it to allow for precise frame tuning.
if (appState.isPlaying) {
playPauseBtn.click();
}
// Call updateFrame, forcing it to resync the video to the current radar frame
// using the new offset value from the input box.
updateFrame(appState.currentFrame, true);
}
});