diff --git a/steps/src/dom.js b/steps/src/dom.js index 39fcdd8..4bef76f 100644 --- a/steps/src/dom.js +++ b/steps/src/dom.js @@ -162,6 +162,46 @@ export function updateFrame(frame, forceVideoSeek) { } +//----------------------Reset UI for New file Load----------------------// +// Resets the UI to make sure everything is clean before new files load. +export function resetUIForNewLoad() { + console.log("Resetting UI for new file load."); + + // Hide feature toggles + featureToggles.classList.add("hidden"); + + // Show placeholders + canvasPlaceholder.style.display = 'flex'; + videoPlaceholder.classList.remove('hidden'); + + // Hide video player and overlays + videoPlayer.classList.add('hidden'); + videoPlayer.src = ''; // Clear the video source + radarInfoOverlay.classList.add('hidden'); + videoInfoOverlay.classList.add('hidden'); + + // Remove the p5 sketches completely + if (appState.p5_instance) { + appState.p5_instance.remove(); + appState.p5_instance = null; + } + if (appState.rawP5_instance) { + appState.rawP5_instance.remove(); + appState.rawP5_instance = null; + } + if (appState.zoomSketchInstance) { + appState.zoomSketchInstance.remove(); + appState.zoomSketchInstance = null; + } + if (appState.speedGraphInstance) { + appState.speedGraphInstance.remove(); + appState.speedGraphInstance = null; + } + + // Reset the speed graph container + speedGraphPlaceholder.classList.remove('hidden'); +} + //----------------------RESET VISUALIZATION Function----------------------// // Resets the visualization to its initial state. export function resetVisualization() { diff --git a/steps/src/main.js b/steps/src/main.js index ccc8c04..6ed3ee5 100644 --- a/steps/src/main.js +++ b/steps/src/main.js @@ -17,7 +17,12 @@ // =========================================================================================================== import { zoomSketch } from "./p5/zoomSketch.js"; -import { showModal, updateModalProgress } from "./modal.js"; // Modify this import +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"; @@ -96,6 +101,7 @@ import { fullscreenExitIcon, menuScrim, toggleConfirmedOnly, + resetUIForNewLoad, } from "./dom.js"; import { initializeTheme } from "./theme.js"; @@ -106,6 +112,244 @@ 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); +}); + +async function processFilePipeline() { + // 1. Show the unified loading modal. + showLoadingModal("Starting file load..."); + let _parsedJsonData = null; + + // 2. Handle JSON Parsing FIRST (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"); + 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 }); + }); + _parsedJsonData = parsedData; + 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, with two-stage initialization + if (videoFileToLoad) { + videoPlayer.addEventListener( + "durationchange", + () => { + if ( + videoPlayer.duration > 0 && + appState.speedGraphInstance && + appState.vizData + ) { + appState.speedGraphInstance.setData( + appState.vizData, + videoPlayer.duration + ); + } + }, + { once: true } + ); + let spinnerInterval; // Declare here to be accessible in all scopes + + // This single promise manages the entire video loading lifecycle. + const videoReadyPromise = new Promise((resolve, reject) => { + // Define cleanup logic to remove listeners and stop the spinner + const cleanup = () => { + clearInterval(spinnerInterval); + videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded); + videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough); + videoPlayer.removeEventListener("error", onError); + }; + + // STAGE 1: Fired when video duration is known. + const onMetadataLoaded = () => { + updateLoadingModal(95, "Finalizing visualization..."); + // This is the key fix: initialize data-dependent sketches immediately. + finalizeSetup(_parsedJsonData); + }; + + // STAGE 2: Fired when video is buffered enough to play. + const onCanPlayThrough = () => { + cleanup(); + resolve(); // Resolve the promise, allowing the pipeline to complete. + }; + + // Handle any loading errors + const onError = (e) => { + console.error("Video loading error:", e); + cleanup(); + reject(e); + }; + + // Attach the event listeners + videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { + once: true, + }); + videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { + once: true, + }); + videoPlayer.addEventListener("error", onError, { once: true }); + }); + + // Set up file metadata and start the simulated progress spinner + appState.videoFilename = videoFileToLoad.name; + localStorage.setItem("videoFilename", appState.videoFilename); + await saveFileWithMetadata("video", videoFileToLoad); + calculateAndSetOffset(); + + const spinnerChars = ["|", "/", "-", "\\"]; + let spinnerIndex = 0; + spinnerInterval = setInterval(() => { + const spinnerText = spinnerChars[spinnerIndex % spinnerChars.length]; + updateLoadingModal(85, `Loading video ${spinnerText}`); + spinnerIndex++; + }, 150); + + // Trigger the video loading process + setupVideoPlayer(URL.createObjectURL(videoFileToLoad)); + + // Await the promise, which resolves only after 'canplaythrough' fires. + await videoReadyPromise; + + // 4. Finalize the UI by hiding the modal + updateLoadingModal(100, "Complete!"); + setTimeout(hideModal, 300); + } else { + // If NO video was loaded, we must still finalize the setup and hide the modal. + updateLoadingModal(95, "Finalizing visualization..."); + finalizeSetup(_parsedJsonData); // Setup with only JSON data + setTimeout(() => { + updateLoadingModal(100, "Complete!"); + setTimeout(hideModal, 300); + }, 200); + } +} + +function finalizeSetup(_parsedJsonData) { + // Make sure the canvas placeholder is hidden and toggles are visible + canvasPlaceholder.style.display = "none"; + featureToggles.classList.remove("hidden"); + + // --- START OF THE FIX --- + // This is the critical step. Before we do anything else, we loop through the + // radar data and recalculate the relative timestamp for every single frame. + // This ensures the data is perfectly synced to the video's confirmed timeline. + if (appState.vizData && appState.videoStartDate) { + appState.vizData.radarFrames.forEach((frame) => { + frame.timestampMs = + appState.radarStartTimeMs + + frame.timestamp - + appState.videoStartDate.getTime(); + }); + } + // --- END OF THE FIX --- + + // 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) { + speedGraphPlaceholder.classList.add("hidden"); + if (!appState.speedGraphInstance) { + appState.speedGraphInstance = new p5(speedGraphSketch); + } + + // The previous logic for setting the frame and redrawing was correct. + // It failed because the underlying timestamp data was wrong. + resetVisualization(); + appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration); + appState.speedGraphInstance.redraw(); + } + + // Update SNR inputs now that data is loaded + if (appState.vizData) { + snrMinInput.value = appState.globalMinSnr.toFixed(1); + snrMaxInput.value = appState.globalMaxSnr.toFixed(1); + } +} // Sets up the video player with the given file URL. function setupVideoPlayer(fileURL) { videoPlayer.src = fileURL; @@ -159,7 +403,6 @@ function loadVideoWithProgress(videoObject) { () => { // 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 + @@ -275,10 +518,6 @@ saveSessionBtn.addEventListener("click", () => { 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) { @@ -419,112 +658,6 @@ document.addEventListener("fullscreenchange", () => { } }); -// In main.js, REPLACE your existing jsonFileInput event listener with this entire block: - -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. -// In src/main.js, REPLACE the videoFileInput event listener with this: -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"); @@ -811,7 +944,7 @@ document.addEventListener("keydown", (event) => { "s", "m", "q", - "c" + "c", ]; if (!appState.vizData || !recognizedKeys.includes(key)) { @@ -873,12 +1006,12 @@ document.addEventListener("keydown", (event) => { resetVisualization(); } if (key === "c") { - appState.isRawOnlyMode = !appState.isRawOnlyMode; - if(appState.p5_instance) { - appState.p5_instance.redraw(); + appState.isRawOnlyMode = !appState.isRawOnlyMode; + if (appState.p5_instance) { + appState.p5_instance.redraw(); } } - + if (key === "p") { togglePredictedPos.click(); appState.p5_instance.redraw(); @@ -917,11 +1050,10 @@ function calculateAndSetOffset() { videoTimestampInfo.timestampStr, videoTimestampInfo.format ); - if (appState.videoStartDate) - console.log( - `Video start date set to: ${appState.videoStartDate.toISOString()}` - ); + if (appState.videoStartDate){ + }; } + if (jsonTimestampInfo) { const jsonDate = parseTimestamp( jsonTimestampInfo.timestampStr, @@ -942,146 +1074,37 @@ function calculateAndSetOffset() { } } -// Application Initialization - -// FILE: steps/src/main.js - -// REPLACE the entire 'document.addEventListener("DOMContentLoaded", ...)' block with this: +// --- [START] CORRECTED INITIALIZATION LOGIC --- 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); - } + console.log("Database initialized. Checking for cached session..."); - // 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"); + appState.videoFilename = localStorage.getItem("videoFilename"); - 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." - ); + if (appState.jsonFilename) { + const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename); + const videoBlob = await loadFreshFileFromDB( + "video", + appState.videoFilename + ); - const finalizeSetup = async (parsedJson) => { - if (parsedJson) { - const result = await parseVisualizationJson( - parsedJson, - appState.radarStartTimeMs, - appState.videoStartDate + 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." ); - - 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); + 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) => { diff --git a/steps/src/modal.js b/steps/src/modal.js index 018c05e..f773000 100644 --- a/steps/src/modal.js +++ b/steps/src/modal.js @@ -3,57 +3,65 @@ import { modalContainer, modalOverlay, modalContent, -} from "./dom.js"; - -// First, import the new DOM elements at the top -import { modalText, - //... modalOkBtn, - modalProgressContainer, // Add this - modalProgressBar, // Add this - modalProgressText, // Add this + modalProgressContainer, + modalProgressBar, + modalProgressText, } from "./dom.js"; -// --- Custom Modal Logic --- // -// Variable to store the resolve function of the Promise, allowing the modal to return a value. let modalResolve = null; -export function showModal(message, isConfirm = false, showProgress = false) { +export function showModal(message, isConfirm = false) { return new Promise((resolve) => { - // Set the message text for the modal. modalText.textContent = message; - // Show/hide the cancel button based on whether it's a confirmation modal. + // This line correctly shows the "Cancel" button only when needed. modalCancelBtn.classList.toggle("hidden", !isConfirm); - modalProgressContainer.classList.toggle("hidden", !showProgress); + + // --- THIS IS THE FIX --- + // This ensures the "OK" button is always visible for this modal. + modalOkBtn.classList.remove("hidden"); + + modalProgressContainer.classList.add("hidden"); - // Make the modal container visible. modalContainer.classList.remove("hidden"); - // Add a slight delay for CSS transitions to take effect, making the modal appear smoothly. setTimeout(() => { modalOverlay.classList.remove("opacity-0"); modalContent.classList.remove("scale-95"); }, 10); - // Store the resolve function to be called when the modal is closed. modalResolve = resolve; }); } -// Add this new exported function to update the progress bar -export function updateModalProgress(percent) { +// A new function specifically for the loading modal +export function showLoadingModal(message) { + modalText.textContent = message; + modalOkBtn.classList.add('hidden'); + modalCancelBtn.classList.add('hidden'); + modalProgressContainer.classList.remove('hidden'); + modalProgressBar.style.width = '0%'; + modalProgressText.textContent = 'Initializing...'; + + modalContainer.classList.remove("hidden"); + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); +} + +// A new function to update the progress bar and text +export function updateLoadingModal(percent, message) { if (modalProgressBar && modalProgressText) { - const p = Math.round(percent); + const p = Math.max(0, Math.min(100, Math.round(percent))); // Clamp between 0-100 modalProgressBar.style.width = `${p}%`; - modalProgressText.textContent = - p < 100 ? `Parsing... ${p}%` : "Finalizing..."; + modalProgressText.textContent = message; } } -// Hides the modal and resolves the Promise with the given value. -function hideModal(value) { +// The hideModal function now also resets the progress bar +export function hideModal(value) { modalOverlay.classList.add("opacity-0"); modalContent.classList.add("scale-95"); setTimeout(() => { modalContainer.classList.add("hidden"); - // Reset progress bar for the next time if (modalProgressContainer && modalProgressBar && modalProgressText) { modalProgressContainer.classList.add("hidden"); modalProgressBar.style.width = "0%"; @@ -63,10 +71,7 @@ function hideModal(value) { }, 200); } -//----------------------Modal Event Listeners----------------------// -// Event listener for the "OK" button. Resolves the modal Promise with 'true'. +// Event listeners remain the same modalOkBtn.addEventListener("click", () => hideModal(true)); -// Event listener for the "Cancel" button. Resolves the modal Promise with 'false'. modalCancelBtn.addEventListener("click", () => hideModal(false)); -// Event listener for clicking on the modal overlay (outside the content). Resolves the modal Promise with 'false'. -modalOverlay.addEventListener("click", () => hideModal(false)); +modalOverlay.addEventListener("click", () => hideModal(false)); \ No newline at end of file diff --git a/steps/src/p5/speedGraphSketch.js b/steps/src/p5/speedGraphSketch.js index d1b3f83..751ae51 100644 --- a/steps/src/p5/speedGraphSketch.js +++ b/steps/src/p5/speedGraphSketch.js @@ -65,8 +65,20 @@ export const speedGraphSketch = function (p) { } const relTime = frame.timestampMs / 1000; if (relTime >= 0 && relTime <= videoDuration) { - const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); - const y = b.map(frame.canVehSpeed_kmph, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); + const x = b.map( + relTime, + 0, + videoDuration, + pad.left, + b.width - pad.right + ); + const y = b.map( + frame.canVehSpeed_kmph, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); b.vertex(x, y); } } @@ -80,9 +92,21 @@ export const speedGraphSketch = function (p) { for (const frame of radarData.radarFrames) { const relTime = frame.timestampMs / 1000; if (relTime >= 0 && relTime <= videoDuration) { - const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); + 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); + const y = b.map( + egoSpeedKmh, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); b.vertex(x, y); } } @@ -119,8 +143,9 @@ export const speedGraphSketch = function (p) { }; p.setData = function (radarData, duration) { + if (!radarData || !radarData.radarFrames) return; - videoDuration = duration; + videoDuration = duration; // Accept duration, even if it's 0 or NaN initially let speeds = []; if (radarData && radarData.radarFrames) { @@ -135,25 +160,51 @@ export const speedGraphSketch = function (p) { speeds.push(...canSpeeds); } - minSpeed = speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0; - maxSpeed = speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10; + 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(radarData); - p.redraw(); + // *** KEY CHANGE *** + // Only try to draw the static graph if the duration is valid. + if (videoDuration > 0) { + p.drawStaticGraphToBuffer(radarData); + } + //p.redraw(); }; p.draw = function () { - if (!videoDuration) return; + // *** KEY CHANGE *** + // If duration is not ready, show a waiting message and stop + if (!videoDuration || videoDuration <= 0) { + const isDark = document.documentElement.classList.contains("dark"); + p.background(isDark ? [55, 65, 81] : 255); + p.fill(isDark ? 200 : 100); + p.textAlign(p.CENTER, p.CENTER); + p.text("Waiting for video duration...", p.width / 2, p.height / 2); + return; + } p.image(staticBuffer, 0, 0); drawTimeIndicator(); }; function drawTimeIndicator() { + // This new, more robust check is the fix. It ensures that the video duration is valid AND + // the main application has initialized the currentFrame before attempting to draw. + if ( + !videoDuration || + videoDuration <= 0 || + appState.currentFrame === null || + appState.currentFrame === undefined + ) { + return; // Stop here if the state is not ready + } + // Get the current frame's data as the single source of truth const frameData = appState.vizData.radarFrames[appState.currentFrame]; - if (!frameData) return; // Exit if data isn't ready + if (!frameData) return; // Exit if data for the specific frame isn't ready // Calculate the X position from the current frame's precise timestamp const currentTimeSec = frameData.timestampMs / 1000.0; @@ -186,9 +237,9 @@ export const speedGraphSketch = function (p) { speedGraphContainer.offsetHeight ); staticBuffer = p.createGraphics(p.width, p.height); - if (appState.vizData && videoDuration) { + if (appState.vizData && videoDuration > 0) { p.drawStaticGraphToBuffer(appState.vizData); } p.redraw(); }; -}; \ No newline at end of file +}; diff --git a/steps/src/p5/zoomSketch.js b/steps/src/p5/zoomSketch.js index 161cb5b..19bd317 100644 --- a/steps/src/p5/zoomSketch.js +++ b/steps/src/p5/zoomSketch.js @@ -284,7 +284,6 @@ export const zoomSketch = function (p) { appState.zoomFactor = 4; // Set a default zoom factor in the global state p.setup = function () { - console.log("zoomSketch: Setup function has been called."); //debug p.noLoop(); }; diff --git a/zoomsketch-issue/dom.js b/zoomsketch-issue/dom.js new file mode 100644 index 0000000..4bef76f --- /dev/null +++ b/zoomsketch-issue/dom.js @@ -0,0 +1,423 @@ +import { appState } from "./state.js"; +import { formatUTCTime } from "./utils.js"; +// Also import VIDEO_FPS from constants +import { VIDEO_FPS } from "./constants.js"; + +// --- DOM Element References --- // + +export const themeToggleBtn = document.getElementById("theme-toggle"); +export const canvasContainer = document.getElementById("canvas-container"); +export const canvasPlaceholder = document.getElementById("canvas-placeholder"); +export const videoPlayer = document.getElementById("video-player"); +export const videoPlaceholder = document.getElementById("video-placeholder"); +export const loadJsonBtn = document.getElementById("load-json-btn"); +export const loadVideoBtn = document.getElementById("load-video-btn"); +export const loadCanBtn = document.getElementById("load-can-btn"); +export const jsonFileInput = document.getElementById("json-file-input"); +export const videoFileInput = document.getElementById("video-file-input"); +export const canFileInput = document.getElementById("can-file-input"); +export const playPauseBtn = document.getElementById("play-pause-btn"); +export const stopBtn = document.getElementById("stop-btn"); +export const timelineSlider = document.getElementById("timeline-slider"); +export const frameCounter = document.getElementById("frame-counter"); +export const offsetInput = document.getElementById("offset-input"); +export const speedSlider = document.getElementById("speed-slider"); +export const speedDisplay = document.getElementById("speed-display"); +export const featureToggles = document.getElementById("feature-toggles"); +export const toggleSnrColor = document.getElementById("toggle-snr-color"); +export const toggleClusterColor = document.getElementById("toggle-cluster-color"); +export const toggleInlierColor = document.getElementById("toggle-inlier-color"); +export const toggleStationaryColor = document.getElementById("toggle-stationary-color"); +export const toggleVelocity = document.getElementById("toggle-velocity"); +export const toggleTracks = document.getElementById("toggle-tracks"); +export const toggleEgoSpeed = document.getElementById("toggle-ego-speed"); +export const toggleFrameNorm = document.getElementById("toggle-frame-norm"); +export const toggleDebugOverlay = document.getElementById("toggle-debug-overlay"); +export const egoSpeedDisplay = document.getElementById("ego-speed-display"); +export const canSpeedDisplay = document.getElementById("can-speed-display"); +export const debugOverlay = document.getElementById("debug-overlay"); +export const toggleDebug2Overlay = document.getElementById("toggle-debug2-overlay"); +export const snrMinInput = document.getElementById("snr-min-input"); +export const snrMaxInput = document.getElementById("snr-max-input"); +export const applySnrBtn = document.getElementById("apply-snr-btn"); +export const autoOffsetIndicator = document.getElementById("auto-offset-indicator"); +export const clearCacheBtn = document.getElementById("clear-cache-btn"); +export const speedGraphContainer = document.getElementById("speed-graph-container"); +export const speedGraphPlaceholder = document.getElementById("speed-graph-placeholder"); +export const modalContainer = document.getElementById("modal-container"); +export const modalOverlay = document.getElementById("modal-overlay"); +export const modalContent = document.getElementById("modal-content"); +export const modalText = document.getElementById("modal-text"); +export const modalOkBtn = document.getElementById("modal-ok-btn"); +export const modalCancelBtn = document.getElementById("modal-cancel-btn"); +export const toggleCloseUp = document.getElementById("toggle-close-up"); +export const togglePredictedPos = document.getElementById("toggle-predicted-pos"); +export const toggleCovariance = document.getElementById("toggle-covariance"); +export const modalProgressContainer = document.getElementById("modal-progress-container"); +export const modalProgressBar = document.getElementById("modal-progress-bar"); +export const modalProgressText = document.getElementById("modal-progress-text"); +export const timelineTooltip = document.getElementById("timeline-tooltip"); +export const radarInfoOverlay = document.getElementById("radar-info-overlay"); +export const videoInfoOverlay = document.getElementById("video-info-overlay"); +export const saveSessionBtn = document.getElementById("save-session-btn"); +export const loadSessionBtn = document.getElementById("load-session-btn"); +export const sessionFileInput = document.getElementById("session-file-input"); +export const ttcModeDefault = document.getElementById("ttc-mode-default"); +export const ttcModeCustom = document.getElementById("ttc-mode-custom"); +export const customTtcPanel = document.getElementById("custom-ttc-panel"); +export const ttcColorCritical = document.getElementById("ttc-color-critical"); +export const ttcTimeCritical = document.getElementById("ttc-time-critical"); +export const ttcColorHigh = document.getElementById("ttc-color-high"); +export const ttcTimeHigh = document.getElementById("ttc-time-high"); +export const ttcColorMedium = document.getElementById("ttc-color-medium"); +export const ttcTimeMedium = document.getElementById("ttc-time-medium"); +export const ttcColorLow = document.getElementById("ttc-color-low"); +export const collapsibleMenu = document.getElementById("collapsible-menu"); +export const toggleMenuBtn = document.getElementById("toggle-menu-btn"); +export const fullscreenBtn = document.getElementById("fullscreen-btn"); +export const mainContent = document.querySelector("main"); +export const closeMenuBtn = document.getElementById("close-menu-btn"); +export const fullscreenEnterIcon = document.getElementById("fullscreen-enter-icon"); +export const fullscreenExitIcon = document.getElementById("fullscreen-exit-icon"); +export const menuScrim = document.getElementById("menu-scrim"); +export const toggleConfirmedOnly = document.getElementById("toggle-confirmed-only"); + + +//----------------------UPDATE FRAME Function----------------------// +// Updates the UI to reflect the current radar frame and synchronizes video playback. +export function updateFrame(frame, forceVideoSeek) { + const startTime = performance.now(); //start emasuring timer of performance. + if ( + !appState.vizData || + frame < 0 || + frame >= appState.vizData.radarFrames.length + ) + // Exit if no visualization data or invalid frame. + return; // Exit if no visualization data or invalid frame + appState.currentFrame = frame; + timelineSlider.value = appState.currentFrame; + frameCounter.textContent = `Frame: ${appState.currentFrame + 1} / ${ + appState.vizData.radarFrames.length + }`; + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + if (toggleEgoSpeed.checked && frameData) { + // Update ego speed display if enabled. + const egoVy_kmh = (frameData.egoVelocity[1] * 3.6).toFixed(1); // Convert m/s to km/h and format + egoSpeedDisplay.textContent = `Ego: ${egoVy_kmh} km/h`; + egoSpeedDisplay.classList.remove("hidden"); + } else { + egoSpeedDisplay.classList.add("hidden"); // Hide ego speed display. + } + + // --- ADD THIS NEW BLOCK --- + if ( + frameData && + frameData.canVehSpeed_kmph !== null && + !isNaN(frameData.canVehSpeed_kmph) + ) { + canSpeedDisplay.textContent = `CAN: ${frameData.canVehSpeed_kmph.toFixed( + 1 + )} km/h`; + canSpeedDisplay.classList.remove("hidden"); + } else { + canSpeedDisplay.classList.add("hidden"); + } + // --- END OF NEW BLOCK --- + + let timeForUpdates = videoPlayer.currentTime; // NEW: Default to the video's current time + + if ( + forceVideoSeek && + videoPlayer.src && + videoPlayer.readyState > 1 && + appState.videoStartDate && + frameData + ) { + const offsetMs = parseFloat(offsetInput.value) || 0; + const targetRadarTimeMs = frameData.timestampMs; + const targetVideoTimeSec = (targetRadarTimeMs - offsetMs) / 1000; + if (targetVideoTimeSec >= 0 && targetVideoTimeSec <= videoPlayer.duration) { + // Ensure target time is within video duration + if (Math.abs(videoPlayer.currentTime - targetVideoTimeSec) > 0.05) { + // Check for significant drift + videoPlayer.currentTime = targetVideoTimeSec; // Seek video if drift is significant + } + // MODIFIED: Use the calculated target time for our updates, not the stale videoPlayer.currentTime + timeForUpdates = targetVideoTimeSec; // Update time for subsequent UI updates + } + } // End of forceVideoSeek block + + if (!appState.isPlaying) { + // MODIFIED: Use our new synchronized time variable + updatePersistentOverlays(timeForUpdates); + } + // --- End of fix --- + + if (appState.p5_instance) appState.p5_instance.redraw(); // Redraw radar sketch + if (appState.speedGraphInstance && !appState.isPlaying) + // Redraw speed graph if not playing. + appState.speedGraphInstance.redraw(); + const endTime = performance.now(); + appState.lastFrameRenderTime = endTime - startTime; // <-- End timer and update state + +} + +//----------------------Reset UI for New file Load----------------------// +// Resets the UI to make sure everything is clean before new files load. +export function resetUIForNewLoad() { + console.log("Resetting UI for new file load."); + + // Hide feature toggles + featureToggles.classList.add("hidden"); + + // Show placeholders + canvasPlaceholder.style.display = 'flex'; + videoPlaceholder.classList.remove('hidden'); + + // Hide video player and overlays + videoPlayer.classList.add('hidden'); + videoPlayer.src = ''; // Clear the video source + radarInfoOverlay.classList.add('hidden'); + videoInfoOverlay.classList.add('hidden'); + + // Remove the p5 sketches completely + if (appState.p5_instance) { + appState.p5_instance.remove(); + appState.p5_instance = null; + } + if (appState.rawP5_instance) { + appState.rawP5_instance.remove(); + appState.rawP5_instance = null; + } + if (appState.zoomSketchInstance) { + appState.zoomSketchInstance.remove(); + appState.zoomSketchInstance = null; + } + if (appState.speedGraphInstance) { + appState.speedGraphInstance.remove(); + appState.speedGraphInstance = null; + } + + // Reset the speed graph container + speedGraphPlaceholder.classList.remove('hidden'); +} + +//----------------------RESET VISUALIZATION Function----------------------// +// Resets the visualization to its initial state. +export function resetVisualization() { + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; + const numFrames = appState.vizData.radarFrames.length; + timelineSlider.max = numFrames > 0 ? numFrames - 1 : 0; + updateFrame(0, true); // Update to the first frame and force video seek +} + +//----------------------CAN DISPLAY UPDATE Function----------------------// +// Updates the CAN speed display based on the current media time. + +//----------------------DEBUG OVERLAY UPDATE Function----------------------// +// Updates the debug overlay with various synchronization and time information. +export function updateDebugOverlay(currentMediaTime) { + // Check the state of both debug toggles + const isDebug1Visible = toggleDebugOverlay.checked; + const isDebug2Visible = toggleDebug2Overlay.checked; + + // If neither is checked, hide the overlay and stop + if (!isDebug1Visible && !isDebug2Visible) { + debugOverlay.classList.add("hidden"); // Hide debug overlay + return; + } + // If at least one is checked, show the overlay + debugOverlay.classList.remove("hidden"); // Show debug overlay. + let content = []; + + // --- Logic for the original debug overlay --- + if (isDebug1Visible) { + content.push(`--- Basic Info ---`); + if (appState.videoStartDate) { + const videoAbsoluteTimeMs = + appState.videoStartDate.getTime() + currentMediaTime * 1000; + content.push(`Media Time (s): ${currentMediaTime.toFixed(3)}`); + content.push(`Video Frame: ${Math.floor(currentMediaTime * VIDEO_FPS)}`); + content.push( + `Vid Abs Time: ${new Date(videoAbsoluteTimeMs) + .toISOString() + .split("T")[1] + .replace("Z", "")}` + ); // Format and display video absolute time + } else { + content.push("Video not loaded..."); // Indicate video not loaded. + } + if ( + appState.vizData && + appState.vizData.radarFrames[appState.currentFrame] + ) { + content.push(`Radar Frame: ${appState.currentFrame + 1}`); + const frameTime = + appState.vizData.radarFrames[appState.currentFrame].timestampMs; + content.push( + `Radar Abs Time: ${new Date( + appState.videoStartDate.getTime() + frameTime + ) + .toISOString() + .split("T")[1] + .replace("Z", "")}` + ); // Format and display radar absolute time + } + } + + // --- Logic for the new advanced debug overlay --- + if (isDebug2Visible) { + content.push(`--- Sync Diagnostics ---`); + if ( + appState.videoStartDate && + appState.vizData && + appState.vizData.radarFrames[appState.currentFrame] + ) { + // --- START: Corrected Debug Logic --- + const currentRadarFrame = + appState.vizData.radarFrames[appState.currentFrame]; + const targetRadarTimeMs = currentRadarFrame.timestampMs; + const offsetMs = parseFloat(offsetInput.value) || 0; // Read the current offset + + // Make the drift calculation "offset-aware" + const driftMs = currentMediaTime * 1000 + offsetMs - targetRadarTimeMs; + // --- END: Corrected Debug Logic --- + + // Style the drift value to be green if sync is good, and red if it's off. + const driftColor = Math.abs(driftMs) > 40 ? "#FF6347" : "#98FB98"; // Tomato red or Pale green + + content.push(`Video Time (s): ${currentMediaTime.toFixed(3)}`); // Display current video time + content.push(`Target Radar Time (ms): ${targetRadarTimeMs.toFixed(0)}`); + content.push(`Drift (ms): ${driftMs.toFixed(0)}`); + content.push(`Video Start Time: ${appState.videoStartDate.toISOString()}`); + content.push(`Radar Start Time: ${new Date(appState.radarStartTimeMs).toISOString()}`); + content.push(`Calculated Offset (ms): ${offsetInput.value}`); // Display calculated offset. + const renderTime = appState.lastFrameRenderTime; + // Color is green if render time is under 33ms (~30fps budget), otherwise red + const renderTimeColor = renderTime > 33 ? "#FF6347" : "#98FB98"; + content.push(`Frame Render Time: ${renderTime.toFixed(1)}ms`); + const videoRenderTime = appState.videoFrameRenderTime; + // Color is green if render time is under 34ms (~30fps), otherwise red + const videoRenderTimeColor = videoRenderTime > 34 ? "#FF6347" : "#98FB98"; + content.push(`Video Frame Time: ${videoRenderTime.toFixed(1)}ms`); + } else { + content.push("Load video and radar data to see sync info."); // Prompt to load data. + } + } + + debugOverlay.innerHTML = content.join("
"); // Update debug overlay content. +} + +// This function checks the state of the color toggles and returns the active mode. +function getCurrentColorMode() { + if (toggleSnrColor.checked) return "Color by SNR (1)"; + if (toggleClusterColor.checked) return "Color by Cluster (2)"; + if (toggleInlierColor.checked) return "Color by Inlier (3)"; + if (toggleStationaryColor.checked) return "Color by Stationary (4)"; + return "Default"; // The default mode when no specific color toggle is checked +} + +export function updatePersistentOverlays(currentMediaTime) { + // If we don't have the necessary data, hide the overlays and exit. + const isDebug1Visible = toggleDebugOverlay.checked; + const isDebug2Visible = toggleDebug2Overlay.checked; + + if (!appState.vizData || !appState.videoStartDate) { + radarInfoOverlay.classList.add("hidden"); + videoInfoOverlay.classList.add("hidden"); + return; + } + if (isDebug1Visible && isDebug2Visible) { + radarInfoOverlay.classList.add("hidden"); + videoInfoOverlay.classList.add("hidden"); + return; + } + if(isDebug1Visible || isDebug2Visible){ + videoInfoOverlay.classList.add("hidden"); + return; + } + // Otherwise, make sure they are visible. + radarInfoOverlay.classList.remove("hidden"); + videoInfoOverlay.classList.remove("hidden"); + + // --- Update Radar Overlay --- + const currentRadarFrame = appState.vizData.radarFrames[appState.currentFrame]; + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + const motionState = frameData.motionState; + if (currentRadarFrame) { + const absRadarTime = new Date( + appState.videoStartDate.getTime() + currentRadarFrame.timestampMs + ); + const targetRadarTimeMs = currentRadarFrame.timestampMs; + const offsetMs = parseFloat(offsetInput.value) || 0; + const driftMs = currentMediaTime * 1000 + offsetMs - targetRadarTimeMs; + const driftColor = Math.abs(driftMs) > 50 ? "#FF6347" : "#98FB98"; // Tomato red or Pale green + const colorMode = getCurrentColorMode(); + + radarInfoOverlay.innerHTML = ` + Frame: ${appState.currentFrame + 1} + Motion State: ${motionState} + | Abs Time: ${formatUTCTime(absRadarTime)} + | Color Mode: ${colorMode} + | Drift: ${driftMs.toFixed( + 0 + )}ms + `; + } + + // --- Update Video Overlay --- + const absVideoTime = new Date( + appState.videoStartDate.getTime() + currentMediaTime * 1000 + ); + const videoFrame = Math.floor(currentMediaTime * VIDEO_FPS); + //console.warn('Could not load radarframes ', appState.vizData.radarFrames) console warning for reference + + videoInfoOverlay.innerHTML = ` + Frame: ${videoFrame} + | Abs Time: ${formatUTCTime(absVideoTime)} + `; +} + +const customTtcInputs = [ + ttcColorCritical, + ttcTimeCritical, + ttcColorHigh, + ttcTimeHigh, + ttcColorMedium, + ttcTimeMedium, +]; + +function updateCustomTtcScheme() { + appState.customTtcScheme.critical.time = parseFloat(ttcTimeCritical.value); + appState.customTtcScheme.critical.color = ttcColorCritical.value; + appState.customTtcScheme.high.time = parseFloat(ttcTimeHigh.value); + appState.customTtcScheme.high.color = ttcColorHigh.value; + appState.customTtcScheme.medium.time = parseFloat(ttcTimeMedium.value); + appState.customTtcScheme.medium.color = ttcColorMedium.value; + + if (appState.p5_instance) { + appState.p5_instance.redraw(); + } +} + +ttcModeDefault.addEventListener("change", () => { + if (ttcModeDefault.checked) { + appState.useCustomTtcScheme = false; + customTtcPanel.classList.add("hidden"); + if (appState.p5_instance) appState.p5_instance.redraw(); + } +}); + +ttcModeCustom.addEventListener("change", () => { + if (ttcModeCustom.checked) { + appState.useCustomTtcScheme = true; + customTtcPanel.classList.remove("hidden"); + updateCustomTtcScheme(); // Apply current custom values immediately + } +}); + +// Add listeners to all custom inputs to update the scheme on the fly +customTtcInputs.forEach((input) => { + input.addEventListener("input", updateCustomTtcScheme); +}); diff --git a/zoomsketch-issue/main.js b/zoomsketch-issue/main.js new file mode 100644 index 0000000..d4a8321 --- /dev/null +++ b/zoomsketch-issue/main.js @@ -0,0 +1,1429 @@ +// =========================================================================================================== +// 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 + }
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); + } +}); diff --git a/zoomsketch-issue/modal.js b/zoomsketch-issue/modal.js new file mode 100644 index 0000000..e8a8c16 --- /dev/null +++ b/zoomsketch-issue/modal.js @@ -0,0 +1,97 @@ +import { + modalCancelBtn, + modalContainer, + modalOverlay, + modalContent, + modalText, + modalOkBtn, + modalProgressContainer, + modalProgressBar, + modalProgressText, +} from "./dom.js"; + +let modalResolve = null; + +// The showModal function is now simpler. +/* export function showModal(message, isConfirm = false) { + return new Promise((resolve) => { + modalText.textContent = message; + modalCancelBtn.classList.toggle("hidden"); + modalOkBtn.classList.toggle("hidden", isConfirm); + modalProgressContainer.classList.add("hidden"); // Hide progress by default + + modalContainer.classList.remove("hidden"); + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); + modalResolve = resolve; + }); +} */ + + + +export function showModal(message, isConfirm = false) { + return new Promise((resolve) => { + modalText.textContent = message; + // This line correctly shows the "Cancel" button only when needed. + modalCancelBtn.classList.toggle("hidden", !isConfirm); + + // --- THIS IS THE FIX --- + // This ensures the "OK" button is always visible for this modal. + modalOkBtn.classList.remove("hidden"); + + modalProgressContainer.classList.add("hidden"); + + modalContainer.classList.remove("hidden"); + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); + modalResolve = resolve; + }); +} +// A new function specifically for the loading modal +export function showLoadingModal(message) { + modalText.textContent = message; + modalOkBtn.classList.add('hidden'); + modalCancelBtn.classList.add('hidden'); + modalProgressContainer.classList.remove('hidden'); + modalProgressBar.style.width = '0%'; + modalProgressText.textContent = 'Initializing...'; + + modalContainer.classList.remove("hidden"); + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); +} + +// A new function to update the progress bar and text +export function updateLoadingModal(percent, message) { + if (modalProgressBar && modalProgressText) { + const p = Math.max(0, Math.min(100, Math.round(percent))); // Clamp between 0-100 + modalProgressBar.style.width = `${p}%`; + modalProgressText.textContent = message; + } +} + +// The hideModal function now also resets the progress bar +export function hideModal(value) { + modalOverlay.classList.add("opacity-0"); + modalContent.classList.add("scale-95"); + setTimeout(() => { + modalContainer.classList.add("hidden"); + if (modalProgressContainer && modalProgressBar && modalProgressText) { + modalProgressContainer.classList.add("hidden"); + modalProgressBar.style.width = "0%"; + modalProgressText.textContent = ""; + } + if (modalResolve) modalResolve(value); + }, 200); +} + +// Event listeners remain the same +modalOkBtn.addEventListener("click", () => hideModal(true)); +modalCancelBtn.addEventListener("click", () => hideModal(false)); +modalOverlay.addEventListener("click", () => hideModal(false)); \ No newline at end of file