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