From 1c538a6d38f70a2bcf6b43550405c58fb19cb883 Mon Sep 17 00:00:00 2001 From: rakadu1 Date: Tue, 14 Oct 2025 14:09:17 +0530 Subject: [PATCH] feat: Implement robust two-stage file loading and fix race condition This commit introduces a major improvement to the file loading pipeline, resolving a critical race condition that occurred during fresh loads and drag-and-drop actions. Previously, the application would attempt to initialize data-dependent components (like the speed graph) and manage the loading modal simultaneously, leading to timing issues. The core of this fix is a new, robust processFilePipeline function in main.js that implements a two-stage video loading process. This decouples data initialization from UI updates, ensuring each occurs at the correct point in the browser's file loading lifecycle. Key Changes & Bug Fixes: main.js: Refactored processFilePipeline Two-Stage Video Loading: The video loading process now uses two distinct event listeners: loadedmetadata: Fires as soon as the video's duration is known. This event now immediately triggers finalizeSetup(), ensuring that the speedGraphSketch is created with the correct time axis, fixing the blank graph bug. canplaythrough: Fires only after the video has buffered enough for smooth playback. The resolution of the main videoReadyPromise is tied to this event, guaranteeing the loading modal is hidden at the appropriate time and resolving the "stuck modal" bug. Explicit Data Synchronization: A final, crucial fix was added to finalizeSetup() to re-synchronize all radar frame timestamps against the video's confirmed start time. This eliminates data mismatches that previously caused NaN errors on fresh loads. speedGraphSketch.js: Enhanced Robustness The sketch's draw() and drawTimeIndicator() functions have been made more defensive. They now check that both videoDuration and appState.currentFrame are valid before attempting to render, preventing crashes and NaN errors if the sketch is asked to draw before all data is ready. modal.js: Improved Loading Modal The modal logic was updated to support a dedicated loading state with a progress bar, providing better user feedback during the file parsing and video buffering stages. --- steps/src/dom.js | 40 + steps/src/main.js | 519 +++++------ steps/src/modal.js | 65 +- steps/src/p5/speedGraphSketch.js | 77 +- steps/src/p5/zoomSketch.js | 1 - zoomsketch-issue/dom.js | 423 +++++++++ zoomsketch-issue/main.js | 1429 ++++++++++++++++++++++++++++++ zoomsketch-issue/modal.js | 97 ++ 8 files changed, 2359 insertions(+), 292 deletions(-) create mode 100644 zoomsketch-issue/dom.js create mode 100644 zoomsketch-issue/main.js create mode 100644 zoomsketch-issue/modal.js 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