Browse Source

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.
refactor/sync-centralize
RUSHIL AMBARISH KADU 6 months ago
parent
commit
0495bb084f
  1. 351
      steps/src/main.js
  2. 131
      steps/src/session.js
  3. 155
      steps/src/ui.js

351
steps/src/main.js

@ -31,57 +31,23 @@ import {
forceResyncWithOffset, forceResyncWithOffset,
} from "./sync.js"; } from "./sync.js";
import { formatTime } from "./utils.js"; import { formatTime } from "./utils.js";
import { initSessionManagement } from "./session.js";
import { initUIEventListeners } from "./ui.js";
import { appState } from "./state.js"; import { appState } from "./state.js";
import { debugFlags } from "./debug.js"; // Import the new debug flags import { debugFlags } from "./debug.js"; // Import the new debug flags
window.appState = appState; // exposing the appState to console window.appState = appState; // exposing the appState to console
window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling
import { import {
canvasPlaceholder,
videoPlayer, videoPlayer,
videoPlaceholder,
loadJsonBtn, loadJsonBtn,
loadVideoBtn, loadVideoBtn,
jsonFileInput, jsonFileInput,
videoFileInput, videoFileInput,
playPauseBtn, playPauseBtn,
stopBtn, stopBtn,
timelineSlider,
offsetInput, offsetInput,
speedSlider,
speedDisplay,
featureToggles,
toggleSnrColor,
toggleClusterColor,
toggleInlierColor,
toggleStationaryColor,
toggleVelocity,
toggleTracks,
toggleEgoSpeed,
toggleFrameNorm,
toggleDebugOverlay,
toggleDebug2Overlay,
debugOverlay,
snrMinInput,
snrMaxInput,
applySnrBtn,
autoOffsetIndicator, autoOffsetIndicator,
clearCacheBtn, clearCacheBtn,
toggleCloseUp,
updateDebugOverlay,
timelineTooltip,
saveSessionBtn,
loadSessionBtn,
sessionFileInput,
togglePredictedPos,
toggleCovariance,
updatePersistentOverlays,
collapsibleMenu,
toggleMenuBtn,
fullscreenBtn,
closeMenuBtn,
menuScrim,
toggleConfirmedOnly,
resetUIForNewLoad,
} from "./dom.js"; } from "./dom.js";
import { initializeTheme } from "./theme.js"; import { initializeTheme } from "./theme.js";
@ -126,188 +92,6 @@ clearCacheBtn.addEventListener("click", async () => {
window.location.reload(); 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. // Event listener for offset input change.
offsetInput.addEventListener("input", () => { offsetInput.addEventListener("input", () => {
@ -315,26 +99,6 @@ offsetInput.addEventListener("input", () => {
// The value is now saved to localStorage only when 'Enter' is pressed. // 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. // Event listener for play/pause button click.
playPauseBtn.addEventListener("click", () => { playPauseBtn.addEventListener("click", () => {
if (!appState.vizData && !videoPlayer.src) return; if (!appState.vizData && !videoPlayer.src) return;
@ -359,115 +123,6 @@ stopBtn.addEventListener("click", () => {
if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); 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
}<br>Time: ${formattedTime}`;
// 4. Position the tooltip horizontally above the cursor
// The horizontal position is the mouse's X relative to the slider's start
const tooltipX = event.clientX - rect.left;
timelineTooltip.style.left = `${tooltipX}px`;
});
// Event listener for speed slider input.
speedSlider.addEventListener("input", (event) => {
const speed = parseFloat(event.target.value);
videoPlayer.playbackRate = speed;
speedDisplay.textContent = `${speed.toFixed(1)}x`;
});
const colorToggles = [
toggleSnrColor,
toggleClusterColor,
toggleInlierColor,
toggleStationaryColor,
];
colorToggles.forEach((t) => {
t.addEventListener("change", (e) => {
if (e.target.checked) {
colorToggles.forEach((o) => {
if (o !== e.target) o.checked = false;
});
}
if (appState.p5_instance) appState.p5_instance.redraw();
updatePersistentOverlays(videoPlayer.currentTime);
});
});
[
toggleVelocity,
toggleEgoSpeed,
toggleFrameNorm,
toggleTracks,
toggleDebugOverlay,
toggleDebug2Overlay,
].forEach((t) => {
t.addEventListener("change", () => {
if (appState.p5_instance) {
if (t === toggleFrameNorm && !toggleFrameNorm.checked)
appState.p5_instance.drawSnrLegendToBuffer(
appState.globalMinSnr,
appState.globalMaxSnr
);
appState.p5_instance.redraw();
}
if (t === toggleDebugOverlay || t === toggleDebug2Overlay) {
updateDebugOverlay(videoPlayer.currentTime);
updatePersistentOverlays(videoPlayer.currentTime);
}
});
});
toggleCloseUp.addEventListener("change", () => {
appState.isCloseUpMode = toggleCloseUp.checked;
if (appState.p5_instance) {
if (appState.isCloseUpMode) {
if (appState.isPlaying) {
playPauseBtn.click();
}
appState.p5_instance.loop();
} else {
appState.p5_instance.noLoop();
appState.p5_instance.redraw();
}
}
});
videoPlayer.addEventListener("ended", () => { videoPlayer.addEventListener("ended", () => {
appState.isPlaying = false; appState.isPlaying = false;
playPauseBtn.textContent = "Play"; playPauseBtn.textContent = "Play";
@ -494,6 +149,8 @@ autoOffsetIndicator.addEventListener("click", () => {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initializeTheme(); initializeTheme();
initializeDataExplorer(); initializeDataExplorer();
initSessionManagement();
initUIEventListeners();
initKeyboardShortcuts(); initKeyboardShortcuts();
initSyncUIHandlers(); initSyncUIHandlers();

131
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.
});
}

155
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}<br>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();
});
}
Loading…
Cancel
Save