From 0495bb084f2d4f58e06385a9ffb13936d4761412 Mon Sep 17 00:00:00 2001 From: rakadu1 Date: Tue, 2 Dec 2025 11:24:22 +0530 Subject: [PATCH] refactor(app): Extract UI and session logic from main.js This commit refactors the monolithic `main.js` file to improve modularity, maintainability, and separation of concerns. The core application logic is now organized into more focused modules. - **UI Logic (`ui.js`):** All general UI event listeners, including feature toggles, sliders, menu controls, and tooltips, have been moved from `main.js` into a new `src/ui.js` module. This centralizes UI-specific behavior. - **Session Management (`session.js`):** The functionality for saving and loading application sessions has been extracted into a dedicated `src/session.js` module. This isolates all logic related to session state serialization and file handling. - **`main.js` Simplification:** The `main.js` file now serves as a cleaner entry point, responsible for initializing all the different application modules in the correct order. **Bug Fixes during Refactoring:** - Corrected an issue where activating "God Mode" (`toggleCloseUp`) did not properly start the p5.js `loop()`, preventing the visualization from updating with mouse movement. - Ensured the `toggleConfirmedOnly` state is now correctly saved and restored as part of the session file. --- steps/src/main.js | 353 +------------------------------------------ steps/src/session.js | 131 ++++++++++++++++ steps/src/ui.js | 155 +++++++++++++++++++ 3 files changed, 291 insertions(+), 348 deletions(-) create mode 100644 steps/src/session.js create mode 100644 steps/src/ui.js diff --git a/steps/src/main.js b/steps/src/main.js index 7cb2a81..55d9f54 100644 --- a/steps/src/main.js +++ b/steps/src/main.js @@ -31,57 +31,23 @@ import { forceResyncWithOffset, } from "./sync.js"; import { formatTime } from "./utils.js"; +import { initSessionManagement } from "./session.js"; +import { initUIEventListeners } from "./ui.js"; import { appState } from "./state.js"; import { debugFlags } from "./debug.js"; // Import the new debug flags window.appState = appState; // exposing the appState to console window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling import { - canvasPlaceholder, videoPlayer, - videoPlaceholder, loadJsonBtn, loadVideoBtn, jsonFileInput, videoFileInput, playPauseBtn, stopBtn, - timelineSlider, offsetInput, - speedSlider, - speedDisplay, - featureToggles, - toggleSnrColor, - toggleClusterColor, - toggleInlierColor, - toggleStationaryColor, - toggleVelocity, - toggleTracks, - toggleEgoSpeed, - toggleFrameNorm, - toggleDebugOverlay, - toggleDebug2Overlay, - debugOverlay, - snrMinInput, - snrMaxInput, - applySnrBtn, autoOffsetIndicator, clearCacheBtn, - toggleCloseUp, - updateDebugOverlay, - timelineTooltip, - saveSessionBtn, - loadSessionBtn, - sessionFileInput, - togglePredictedPos, - toggleCovariance, - updatePersistentOverlays, - collapsibleMenu, - toggleMenuBtn, - fullscreenBtn, - closeMenuBtn, - menuScrim, - toggleConfirmedOnly, - resetUIForNewLoad, } from "./dom.js"; import { initializeTheme } from "./theme.js"; @@ -126,188 +92,6 @@ clearCacheBtn.addEventListener("click", async () => { 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); -}); - -//videoframecallback exported from sync.js - -// 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(); - } -}); // Event listener for offset input change. offsetInput.addEventListener("input", () => { @@ -315,26 +99,6 @@ offsetInput.addEventListener("input", () => { // The value is now saved to localStorage only when 'Enter' is pressed. }); -// 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; @@ -359,115 +123,6 @@ stopBtn.addEventListener("click", () => { if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); }); -// Event listener for timeline slider input. -// --- Timeline Scroll-to-Seek Logic --- - -// 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.relativeTimeSec * 1000); - 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"; @@ -494,9 +149,11 @@ autoOffsetIndicator.addEventListener("click", () => { document.addEventListener("DOMContentLoaded", () => { initializeTheme(); initializeDataExplorer(); + initSessionManagement(); + initUIEventListeners(); initKeyboardShortcuts(); initSyncUIHandlers(); - + // Await the database initialization before attempting to load any files. // This resolves the race condition on initial load. initDB().then(async () => { diff --git a/steps/src/session.js b/steps/src/session.js new file mode 100644 index 0000000..a3f6e8e --- /dev/null +++ b/steps/src/session.js @@ -0,0 +1,131 @@ +import { appState } from "./state.js"; +import { showModal } from "./modal.js"; +import { loadFreshFileFromDB } from "./db.js"; +import { + offsetInput, + speedSlider, + snrMinInput, + snrMaxInput, + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleStationaryColor, + toggleVelocity, + toggleTracks, + toggleEgoSpeed, + toggleFrameNorm, + toggleDebugOverlay, + toggleDebug2Overlay, + toggleCloseUp, + togglePredictedPos, + toggleCovariance, + toggleConfirmedOnly, + saveSessionBtn, + loadSessionBtn, + sessionFileInput, +} from "./dom.js"; + +function saveSession() { + if (!appState.jsonFilename && !appState.videoFilename) { + showModal("Nothing to save. Please load data files first."); + return; + } + + 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, + confirmedOnly: toggleConfirmedOnly.checked, + }, + }; + + const sessionString = JSON.stringify(sessionState, null, 2); + const blob = new Blob([sessionString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const now = new Date(); + const pad = (num) => String(num).padStart(2, "0"); + const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad( + now.getDate() + )}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + const defaultFilename = `visualizer-session_${timestamp}.json`; + + const a = document.createElement("a"); + a.href = url; + a.download = defaultFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +async function loadSession(file) { + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const sessionState = JSON.parse(e.target.result); + + if (sessionState.version !== 1 || !sessionState.jsonFilename) { + showModal("Error: Invalid or corrupted session file."); + return; + } + + const videoBlob = await loadFreshFileFromDB("video", sessionState.videoFilename); + const jsonBlob = await loadFreshFileFromDB("json", sessionState.jsonFilename); + + if (!jsonBlob || (sessionState.videoFilename && !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.`); + return; + } + + 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)); + } + + showModal("Session files found in cache. The application will now reload.").then(() => { + window.location.reload(); + }); + } catch (error) { + showModal("Error: Could not parse the session file. It may be invalid."); + console.error("Session load error:", error); + } + }; + reader.readAsText(file); +} + +export function initSessionManagement() { + saveSessionBtn.addEventListener("click", saveSession); + loadSessionBtn.addEventListener("click", () => sessionFileInput.click()); + sessionFileInput.addEventListener("change", (event) => { + loadSession(event.target.files[0]); + event.target.value = ""; // Clear the input for future loads. + }); +} \ No newline at end of file diff --git a/steps/src/ui.js b/steps/src/ui.js new file mode 100644 index 0000000..9de0166 --- /dev/null +++ b/steps/src/ui.js @@ -0,0 +1,155 @@ +import { appState } from "./state.js"; +import { formatTime } from "./utils.js"; +import { showModal } from "./modal.js"; +import { pausePlayback } from "./sync.js"; +import { + videoPlayer, + timelineSlider, + speedSlider, + speedDisplay, + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleStationaryColor, + toggleVelocity, + toggleEgoSpeed, + toggleFrameNorm, + toggleTracks, + toggleDebugOverlay, + toggleDebug2Overlay, + toggleCloseUp, + snrMinInput, + snrMaxInput, + applySnrBtn, + timelineTooltip, + playPauseBtn, + updatePersistentOverlays, + updateDebugOverlay, + collapsibleMenu, + toggleMenuBtn, + closeMenuBtn, + menuScrim, + fullscreenBtn, + toggleConfirmedOnly, +} from "./dom.js"; + +function toggleMenu(show) { + if (show) { + collapsibleMenu.classList.remove("-translate-x-full"); + menuScrim.classList.remove("hidden"); + } else { + collapsibleMenu.classList.add("-translate-x-full"); + menuScrim.classList.add("hidden"); + } +} + +function handleColorToggles(e) { + const colorToggles = [ + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleStationaryColor, + ]; + 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); +} + +export function initUIEventListeners() { + // --- Menu and Fullscreen --- + toggleMenuBtn.addEventListener("click", () => toggleMenu(true)); + closeMenuBtn.addEventListener("click", () => toggleMenu(false)); + menuScrim.addEventListener("click", () => toggleMenu(false)); + fullscreenBtn.addEventListener("click", () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } + }); + + // --- Timeline Tooltip --- + 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; + const rect = timelineSlider.getBoundingClientRect(); + const hoverFraction = (event.clientX - rect.left) / rect.width; + const sliderMax = parseInt(timelineSlider.max, 10) || appState.vizData.radarFrames.length - 1; + let frameIndex = Math.max(0, Math.min(Math.round(hoverFraction * sliderMax), sliderMax)); + const frameData = appState.vizData.radarFrames[frameIndex]; + if (!frameData) return; + const formattedTime = formatTime(frameData.relativeTimeSec * 1000); + timelineTooltip.innerHTML = `Frame: ${frameIndex + 1}
Time: ${formattedTime}`; + const tooltipX = event.clientX - rect.left; + timelineTooltip.style.left = `${tooltipX}px`; + }); + + // --- Speed Slider --- + speedSlider.addEventListener("input", (event) => { + const speed = parseFloat(event.target.value); + videoPlayer.playbackRate = speed; + speedDisplay.textContent = `${speed.toFixed(1)}x`; + }); + + // --- SNR Controls --- + 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(); + } + }); + + // --- Feature Toggles --- + [toggleSnrColor, toggleClusterColor, toggleInlierColor, toggleStationaryColor].forEach((t) => { + t.addEventListener("change", handleColorToggles); + }); + + [toggleVelocity, toggleEgoSpeed, toggleFrameNorm, toggleTracks, toggleDebugOverlay, toggleDebug2Overlay].forEach((t) => { + t.addEventListener("change", () => { + if (appState.p5_instance) appState.p5_instance.redraw(); + if (t === toggleDebugOverlay || t === toggleDebug2Overlay) { + updateDebugOverlay(videoPlayer.currentTime); + updatePersistentOverlays(videoPlayer.currentTime); + } + }); + }); + + toggleCloseUp.addEventListener("change", () => { + appState.isCloseUpMode = toggleCloseUp.checked; + if (appState.isCloseUpMode && appState.isPlaying) { + // If entering close-up mode while playing, automatically pause. + pausePlayback(); + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; + } + if (appState.p5_instance) { // Handle p5 loop state + if (appState.isCloseUpMode) { + appState.p5_instance.loop(); // Start looping for mouse interaction. + } else { + appState.p5_instance.noLoop(); // Stop looping when exiting. + appState.p5_instance.redraw(); // Redraw one last time. + } + } + }); + + toggleConfirmedOnly.addEventListener("change", () => { + if (appState.p5_instance) appState.p5_instance.redraw(); + }); +} \ No newline at end of file