From cdaed0d4c69c6cb93fc7a35360f176f687fe45b1 Mon Sep 17 00:00:00 2001
From: rakadu1
Date: Wed, 26 Nov 2025 17:25:01 +0530
Subject: [PATCH] File loading overhaul part 1
---
steps/src/db.js | 104 ++++++-----
steps/src/fileLoader.js | 301 +++++++++++++++++++++++++++++++
steps/src/fileParsers.js | 18 +-
steps/src/main.js | 318 +--------------------------------
steps/tests/fileLoader.test.js | 172 ++++++++++++++++++
steps/tests/test-runner.html | 87 ++++++++-
6 files changed, 638 insertions(+), 362 deletions(-)
create mode 100644 steps/src/fileLoader.js
create mode 100644 steps/tests/fileLoader.test.js
diff --git a/steps/src/db.js b/steps/src/db.js
index 76bfa20..a9dda75 100644
--- a/steps/src/db.js
+++ b/steps/src/db.js
@@ -1,9 +1,13 @@
-// In src/db.js, replace the entire file content with this:
-
let db;
+let dbReadyPromise;
+let dbReadyResolve;
+
+// Initialize the promise that tracks DB readiness
+dbReadyPromise = new Promise((resolve) => {
+ dbReadyResolve = resolve;
+});
// Initializes the IndexedDB database.
-// Opens or creates the 'visualizerDB' database.
export function initDB(callback) {
const request = indexedDB.open("visualizerDB", 1);
@@ -11,70 +15,85 @@ export function initDB(callback) {
const db = event.target.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files");
- // Creates an object store named 'files' if it doesn't exist.
}
};
request.onsuccess = function (event) {
db = event.target.result;
console.log("Database initialized");
- // Assigns the opened database to the 'db' variable.
+ dbReadyResolve(db); // Signal that DB is ready
if (callback) callback();
};
request.onerror = function (event) {
console.error("IndexedDB error:", event.target.errorCode);
- // Even if DB fails, call the callback so the app doesn't hang
- // Logs any errors during database operations.
- // Calls the callback even if there's an error to prevent the app from hanging.
+ // If DB fails, we resolve with null so operations can proceed (gracefully failing to cache)
+ dbReadyResolve(null);
if (callback) callback();
};
}
+// Ensure DB is ready before returning it
+async function getDB() {
+ if (db) return db;
+ return await dbReadyPromise;
+}
// Saves a file (Blob) along with its metadata into the IndexedDB.
export function saveFileWithMetadata(key, file) {
- if (!db) return;
-
- const transaction = db.transaction(["files"], "readwrite");
- const store = transaction.objectStore("files");
-
- // Creates a read-write transaction and gets the 'files' object store.
- // Store an object containing the blob and its metadata
- const dataToStore = {
- filename: file.name,
- size: file.size,
- type: file.type,
- blob: file
- // Prepares the data object to be stored, including filename, size, type, and the file itself (as a Blob).
- };
-
- const request = store.put(dataToStore, key);
-
- request.onsuccess = () => console.log(`File '${file.name}' saved to DB with metadata.`);
-
- // Gracefully handle errors, especially quota limits
- transaction.onerror = (event) => {
- if (event.target.error.name === 'QuotaExceededError') {
- alert("Could not cache file: Browser storage quota exceeded. The app will still work for this session.");
- } else {
- // Handles potential errors during the save operation, such as QuotaExceededError.
- console.error(`Error saving file '${key}':`, event.target.error);
+ return new Promise(async (resolve, reject) => {
+ const database = await getDB();
+
+ if (!database) {
+ // If DB failed to initialize, just warn and skip caching
+ console.warn("Database not available. Skipping cache save.");
+ resolve();
+ return;
}
- };
+
+ const transaction = database.transaction(["files"], "readwrite");
+ const store = transaction.objectStore("files");
+
+ // Store an object containing the blob and its metadata
+ const dataToStore = {
+ filename: file.name,
+ size: file.size,
+ type: file.type,
+ blob: file
+ };
+
+ const request = store.put(dataToStore, key);
+
+ request.onsuccess = () => {
+ console.log(`File '${file.name}' saved to DB with metadata.`);
+ resolve();
+ };
+
+ // Gracefully handle errors, especially quota limits
+ transaction.onerror = (event) => {
+ if (event.target.error.name === 'QuotaExceededError') {
+ alert("Could not cache file: Browser storage quota exceeded. The app will still work for this session.");
+ resolve(); // Resolve anyway to let the app continue without caching
+ } else {
+ console.error(`Error saving file '${key}':`, event.target.error);
+ reject(event.target.error);
+ }
+ };
+ });
}
// Loads a file from IndexedDB, performing checks for filename and size to ensure data integrity.
export function loadFreshFileFromDB(key, expectedFilename) {
- return new Promise((resolve) => {
- if (!db || !expectedFilename) {
+ return new Promise(async (resolve) => {
+ const database = await getDB();
+
+ if (!database || !expectedFilename) {
resolve(null);
return;
}
- const transaction = db.transaction(["files"], "readonly");
- // Creates a read-only transaction.
+ const transaction = database.transaction(["files"], "readonly");
const store = transaction.objectStore("files");
const request = store.get(key);
@@ -82,21 +101,18 @@ export function loadFreshFileFromDB(key, expectedFilename) {
const cachedData = request.result;
if (!cachedData) {
console.log(`Cache miss for key '${key}': No data found.`);
- // If no data is found for the key, resolve with null.
resolve(null);
return;
}
// 1. Versioning Check: Do the filenames match?
if (cachedData.filename !== expectedFilename) {
- // Checks if the cached filename matches the expected filename.
console.warn(`Cache miss for key '${key}': Stale data found (Filename mismatch).`);
resolve(null);
return;
}
// 2. Integrity Check: Do the sizes match?
- // Checks if the cached file size matches the stored size metadata.
if (cachedData.blob.size !== cachedData.size) {
console.error(`Cache miss for key '${key}': Corrupted data found (Size mismatch).`);
resolve(null);
@@ -104,15 +120,13 @@ export function loadFreshFileFromDB(key, expectedFilename) {
}
// All checks passed!
- // If all checks pass, resolve with the cached Blob.
console.log(`Cache hit for '${expectedFilename}'`);
resolve(cachedData.blob);
};
request.onerror = (event) => {
console.error(`Error loading file '${key}' from DB:`, event.target.error);
- // Logs any errors during the load operation.
resolve(null);
};
});
-}
\ No newline at end of file
+}
diff --git a/steps/src/fileLoader.js b/steps/src/fileLoader.js
new file mode 100644
index 0000000..2c9a6df
--- /dev/null
+++ b/steps/src/fileLoader.js
@@ -0,0 +1,301 @@
+import { appState } from "./state.js";
+import { saveFileWithMetadata } from "./db.js";
+import { parseVisualizationJson } from "./fileParsers.js";
+import {
+ showLoadingModal,
+ updateLoadingModal,
+ hideModal,
+ showModal,
+} from "./modal.js";
+import {
+ precomputeRadarVideoSync,
+ extractTimestampInfo,
+ parseTimestamp,
+} from "./utils.js";
+import { resetVisualization } from "./sync.js";
+import { radarSketch } from "./p5/radarSketch.js";
+import { speedGraphSketch } from "./p5/speedGraphSketch.js";
+import { zoomSketch } from "./p5/zoomSketch.js";
+import {
+ videoPlayer,
+ videoPlaceholder,
+ canvasPlaceholder,
+ featureToggles,
+ speedGraphPlaceholder,
+ snrMinInput,
+ snrMaxInput,
+ autoOffsetIndicator,
+ offsetInput,
+ speedSlider,
+ updatePersistentOverlays,
+ updateDebugOverlay,
+} from "./dom.js";
+
+/**
+ * This is the main handler for both manual clicks and drag-and-drop.
+ * It identifies the files and triggers the unified processing pipeline.
+ */
+export function handleFiles(files) {
+ // Identify new files from the input
+ let incomingJson = null;
+ let incomingVideo = null;
+
+ Array.from(files).forEach((file) => {
+ if (file.name.endsWith(".json")) {
+ incomingJson = file;
+ }
+ if (file.type.startsWith("video/")) {
+ incomingVideo = file;
+ }
+ });
+
+ // If no valid files were dropped, do nothing
+ if (!incomingJson && !incomingVideo) return;
+
+ // Trigger the pipeline with the identified files
+ processFilePipeline(incomingJson, incomingVideo);
+}
+
+async function processFilePipeline(jsonFile, videoFile) {
+ // 1. Show the unified loading modal.
+ showLoadingModal("Processing files...");
+ let _parsedJsonData = null;
+
+ // --- PART A: Handle JSON Replacement ---
+ if (jsonFile) {
+ // Reset old visualization data immediately
+ appState.vizData = null;
+ // Pause P5 loop to prevent errors while data is missing
+ if (appState.p5_instance) appState.p5_instance.noLoop();
+
+ // Persist filename & Cache
+ appState.jsonFilename = jsonFile.name;
+ localStorage.setItem("jsonFilename", appState.jsonFilename);
+ await saveFileWithMetadata("json", jsonFile);
+
+ // Parse JSON
+ 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: jsonFile });
+ });
+
+ _parsedJsonData = parsedData;
+
+ // Post-process JSON
+ // Note: We pass appState.videoStartDate (which might be null if video isn't loaded yet)
+ // This is fine; offset calculation handles the sync later.
+ 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;
+ }
+
+ // --- PART B: Handle Video Replacement ---
+ if (videoFile) {
+ // Persist filename & Cache
+ appState.videoFilename = videoFile.name;
+ localStorage.setItem("videoFilename", appState.videoFilename);
+ await saveFileWithMetadata("video", videoFile);
+ }
+
+ // --- PART C: Calculate Offset ---
+ // We run this if *either* file changed, as the sync relationship might have changed.
+ // This updates appState.offset and appState.videoStartDate (if video filename is available)
+ calculateAndSetOffset();
+
+ // --- PART D: Precompute Sync ---
+ // Now that we have the latest offset, bake it into the data.
+ if (appState.vizData) {
+ precomputeRadarVideoSync(appState.vizData, appState.offset);
+ }
+
+ // --- PART E: Load Video (if new) ---
+ if (videoFile) {
+ await loadVideo(videoFile);
+ }
+
+ // --- PART F: Finalize UI ---
+ finalizeSetup();
+
+ // Hide modal
+ updateLoadingModal(100, "Complete!");
+ setTimeout(hideModal, 300);
+}
+
+
+// Encapsulates the specific logic for loading a video file into the player
+function loadVideo(file) {
+ return new Promise((resolve, reject) => {
+ const fileURL = URL.createObjectURL(file);
+
+ // Setup cleanup to remove listeners
+ const cleanup = () => {
+ clearInterval(spinnerInterval);
+ videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded);
+ videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
+ videoPlayer.removeEventListener("error", onError);
+ };
+
+ const onMetadataLoaded = () => {
+ updateLoadingModal(95, "Finalizing visualization...");
+ };
+
+ const onCanPlayThrough = () => {
+ cleanup();
+ resolve();
+ };
+
+ const onError = (e) => {
+ console.error("Video loading error:", e);
+ cleanup();
+ reject(e);
+ };
+
+ // Attach listeners
+ videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { once: true });
+ videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { once: true });
+ videoPlayer.addEventListener("error", onError, { once: true });
+
+ // Spinner
+ const spinnerChars = ["|", "/", "-", "\\"];
+ let spinnerIndex = 0;
+ const spinnerInterval = setInterval(() => {
+ const spinnerText = spinnerChars[spinnerIndex % spinnerChars.length];
+ updateLoadingModal(85, `Loading video ${spinnerText}`);
+ spinnerIndex++;
+ }, 150);
+
+ // Apply source
+ setupVideoPlayer(fileURL);
+ });
+}
+
+function finalizeSetup() {
+ // 1. Manage Placeholders & Visibility
+ // If we have data (vizData), we show the canvas container.
+ if (appState.vizData) {
+ canvasPlaceholder.style.display = "none";
+ featureToggles.classList.remove("hidden");
+ } else {
+ // If we don't have data yet (video only), we might keep the placeholder or show an empty canvas?
+ // Current behavior: keep placeholder until JSON loads.
+ }
+
+ // 2. Initialize/Update P5 Sketches
+ // We check if they exist; if not, create them. If they do, they will read the new appState on next draw.
+ if (!appState.p5_instance) {
+ appState.p5_instance = new p5(radarSketch);
+ } else {
+ // If it existed, ensure it's looping/active
+ appState.p5_instance.loop();
+ }
+
+ if (!appState.zoomSketchInstance) {
+ appState.zoomSketchInstance = new p5(zoomSketch, "zoom-canvas-container");
+ }
+
+ // 3. Setup Speed Graph
+ if (appState.vizData) {
+ speedGraphPlaceholder.classList.add("hidden");
+
+ if (!appState.speedGraphInstance) {
+ appState.speedGraphInstance = new p5(speedGraphSketch);
+ }
+
+ // Important: Reset the visualization timeline to 0
+ resetVisualization();
+
+ // Update speed graph with new data + video duration
+ // Note: videoPlayer.duration might be NaN if video isn't loaded.
+ const duration = videoPlayer.duration || 0;
+ appState.speedGraphInstance.setData(appState.vizData, duration);
+ appState.speedGraphInstance.redraw();
+ }
+
+ // 4. Update UI Overlays
+ // Manually update overlays so they are visible immediately.
+ updatePersistentOverlays(videoPlayer.currentTime);
+ updateDebugOverlay(videoPlayer.currentTime);
+
+ // 5. Update SNR Inputs
+ 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;
+ videoPlayer.classList.remove("hidden");
+ videoPlaceholder.classList.add("hidden");
+ videoPlayer.playbackRate = parseFloat(speedSlider.value);
+}
+
+function calculateAndSetOffset() {
+ const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
+ const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
+
+ let videoDate = null;
+ if (videoTimestampInfo) {
+ videoDate = parseTimestamp(
+ videoTimestampInfo.timestampStr,
+ videoTimestampInfo.format
+ );
+ appState.videoStartDate = videoDate; // Store for potential future use
+ }
+
+ let jsonDate = null;
+ if (jsonTimestampInfo) {
+ jsonDate = parseTimestamp(
+ jsonTimestampInfo.timestampStr,
+ jsonTimestampInfo.format
+ );
+ }
+
+ let calculatedOffset = 0;
+ // We need both dates to calculate an offset.
+ if (jsonDate && videoDate) {
+ appState.radarStartTimeMs = jsonDate.getTime();
+ const offset = jsonDate.getTime() - videoDate.getTime();
+
+ if (isNaN(offset) || Math.abs(offset) > 30000) {
+ console.warn(`Calculated offset of ${offset}ms is invalid or exceeds 30s threshold. Defaulting to 0.`);
+ calculatedOffset = 0;
+ } else {
+ calculatedOffset = offset;
+ autoOffsetIndicator.classList.remove("hidden");
+ console.log(`Auto-calculated offset: ${calculatedOffset} ms`);
+ }
+ } else if (jsonDate) {
+ // If we have JSON but no video, we set start time but offset is 0
+ appState.radarStartTimeMs = jsonDate.getTime();
+ }
+
+ appState.offset = calculatedOffset;
+ offsetInput.value = appState.offset;
+ localStorage.setItem("visualizerOffset", appState.offset);
+}
\ No newline at end of file
diff --git a/steps/src/fileParsers.js b/steps/src/fileParsers.js
index 9f00e8c..5429264 100644
--- a/steps/src/fileParsers.js
+++ b/steps/src/fileParsers.js
@@ -55,15 +55,21 @@ export async function parseVisualizationJson(
};
}
+ // Calculate offset: (Radar Start - Video Start). Defaults to 0 if Video Start is unknown.
+ let offset = 0;
if (videoStartDate && radarStartTimeMs) {
- await processArrayInChunks(vizData.radarFrames, 5000, (chunk) => {
- chunk.forEach((frame) => {
- frame.timestampMs =
- radarStartTimeMs + frame.timestamp - videoStartDate.getTime();
- });
- });
+ offset = radarStartTimeMs - videoStartDate.getTime();
}
+ // Always populate timestampMs (Time relative to video start, in ms)
+ await processArrayInChunks(vizData.radarFrames, 5000, (chunk) => {
+ chunk.forEach((frame) => {
+ // frame.timestamp is assumed to be ms from the radar log start.
+ // We add the offset to align it with the video timeline.
+ frame.timestampMs = frame.timestamp + offset;
+ });
+ });
+
let snrValues = [];
let totalPoints = 0;
await processArrayInChunks(vizData.radarFrames, 5000, (chunk) => {
diff --git a/steps/src/main.js b/steps/src/main.js
index 2153726..81c1348 100644
--- a/steps/src/main.js
+++ b/steps/src/main.js
@@ -17,52 +17,25 @@
// - main.js: The main application entry point that wires everything
// ===========================================================================================================
-import { zoomSketch } from "./p5/zoomSketch.js";
//import { showExplorer, hideExplorer, displayInGrid } from "./dataExplorer.js";
-import { initializeDataExplorer, throttledUpdateExplorer } from "./dataExplorer.js"; // <-- ADD THIS
+import { initializeDataExplorer } from "./dataExplorer.js";
import {
showModal,
hideModal,
- updateLoadingModal,
- showLoadingModal,
-} from "./modal.js"; // Modify this import
+} from "./modal.js";
import {
- animationLoop,
- videoFrameCallback,
+ initSyncUIHandlers,
startPlayback,
pausePlayback,
stopPlayback,
- initSyncUIHandlers,
- updateFrame,
- resetVisualization,
forceResyncWithOffset,
} 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,
- precomputeRadarVideoSync,
- throttle,
- formatTime,
-} from "./utils.js";
+import { formatTime } from "./utils.js";
import { appState } from "./state.js";
import { debugFlags } from "./debug.js"; // Import the new debug flags
window.appState = appState; // exposing the appState to console
window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling
import {
- themeToggleBtn,
- canvasContainer,
canvasPlaceholder,
videoPlayer,
videoPlaceholder,
@@ -73,7 +46,6 @@ import {
playPauseBtn,
stopBtn,
timelineSlider,
- frameCounter,
offsetInput,
speedSlider,
speedDisplay,
@@ -88,15 +60,12 @@ import {
toggleFrameNorm,
toggleDebugOverlay,
toggleDebug2Overlay,
- egoSpeedDisplay,
debugOverlay,
snrMinInput,
snrMaxInput,
applySnrBtn,
autoOffsetIndicator,
clearCacheBtn,
- speedGraphContainer,
- speedGraphPlaceholder,
toggleCloseUp,
updateDebugOverlay,
timelineTooltip,
@@ -109,50 +78,17 @@ import {
collapsibleMenu,
toggleMenuBtn,
fullscreenBtn,
- mainContent,
closeMenuBtn,
menuScrim,
toggleConfirmedOnly,
resetUIForNewLoad,
- //explorerBtn,
} from "./dom.js";
import { initializeTheme } from "./theme.js";
-import { initDB, saveFileWithMetadata, loadFreshFileFromDB } from "./db.js";
+import { initDB, loadFreshFileFromDB } from "./db.js";
import { initKeyboardShortcuts } from "./keyboard.js";
-
-// --- [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
- // This loop now correctly handles both files without an else-if.
- Array.from(files).forEach((file) => {
- if (file.name.endsWith(".json")) {
- jsonFileToLoad = file;
- }
- if (file.type.startsWith("video/")) {
- videoFileToLoad = file;
- }
- });
-
- // Start the main loading process if we have at least one valid file.
- if (jsonFileToLoad || videoFileToLoad) {
- processFilePipeline();
- }
-}
+import { handleFiles } from "./fileLoader.js";
// Wire up the manual file inputs to the new handler
jsonFileInput.addEventListener("change", (event) =>
@@ -177,198 +113,6 @@ dropZone.addEventListener("drop", (event) => {
handleFiles(event.dataTransfer.files);
});
-async function processFilePipeline() {
- // 1. Show the unified loading modal.
- showLoadingModal("Starting file load...");
- let _parsedJsonData = null;
-
- // --- PRE-PROCESSING: Setup Filenames and Cache ---
- if (jsonFileToLoad) {
- appState.jsonFilename = jsonFileToLoad.name;
- localStorage.setItem("jsonFilename", appState.jsonFilename);
- await saveFileWithMetadata("json", jsonFileToLoad);
- }
-
- if (videoFileToLoad) {
- appState.videoFilename = videoFileToLoad.name;
- localStorage.setItem("videoFilename", appState.videoFilename);
- await saveFileWithMetadata("video", videoFileToLoad);
- }
-
- // --- CALCULATE OFFSET ---
- // Calculate offset/dates once we have all potential filenames.
- calculateAndSetOffset();
-
- // 2. Handle JSON Parsing (if a JSON file is present)
- if (jsonFileToLoad) {
- 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;
- precomputeRadarVideoSync(appState.vizData, appState.offset);
- }
-
- // 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...");
- };
-
- // 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
- // Note: Filename setup and caching moved to start of processFilePipeline
-
- 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;
-
- finalizeSetup(_parsedJsonData);
-
- // 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");
-
-
- // 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);
- }
- resetVisualization();
- appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration);
- appState.speedGraphInstance.redraw();
- }
- // --- START: FIX for Initial Overlay Visibility ---
- // Manually update overlays on initial load so they are visible before playback starts.
- updatePersistentOverlays(videoPlayer.currentTime);
- updateDebugOverlay(videoPlayer.currentTime);
- // --- END: FIX for Initial Overlay Visibility ---
-
- // 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;
- videoPlayer.classList.remove("hidden");
- videoPlaceholder.classList.add("hidden");
- videoPlayer.playbackRate = parseFloat(speedSlider.value);
-}
// Event listener for loading JSON file.
loadJsonBtn.addEventListener("click", () => jsonFileInput.click());
@@ -729,52 +473,6 @@ videoPlayer.addEventListener("ended", () => {
playPauseBtn.textContent = "Play";
});
-function calculateAndSetOffset() {
- const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
- const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
-
- let videoDate = null;
- if (videoTimestampInfo) {
- videoDate = parseTimestamp(
- videoTimestampInfo.timestampStr,
- videoTimestampInfo.format
- );
- appState.videoStartDate = videoDate; // Store for potential future use
- }
-
- let jsonDate = null;
- if (jsonTimestampInfo) {
- jsonDate = parseTimestamp(
- jsonTimestampInfo.timestampStr,
- jsonTimestampInfo.format
- );
- }
-
- let calculatedOffset = 0;
- if (jsonDate && videoDate) {
- appState.radarStartTimeMs = jsonDate.getTime();
- const offset = jsonDate.getTime() - videoDate.getTime();
-
- // Logic Rule: If offset is invalid or too large, default to 0.
- if (isNaN(offset) || Math.abs(offset) > 30000) {
- console.warn(`Calculated offset of ${offset}ms is invalid or exceeds 30s threshold. Defaulting to 0.`);
- calculatedOffset = 0;
- } else {
- calculatedOffset = offset;
- autoOffsetIndicator.classList.remove("hidden");
- console.log(`Auto-calculated offset: ${calculatedOffset} ms`);
- }
- }
-
- appState.offset = calculatedOffset;
- offsetInput.value = appState.offset;
- localStorage.setItem("visualizerOffset", appState.offset);
-
- // Trigger Baking: This is the point where we apply the offset to the data.
- if (appState.vizData) {
- precomputeRadarVideoSync(appState.vizData, appState.offset);
- }
-}
offsetInput.addEventListener("keydown", (event) => {
// Check if the key pressed was 'Enter'
if (event.key === "Enter") {
@@ -786,7 +484,7 @@ offsetInput.addEventListener("keydown", (event) => {
// --- [START] CORRECTED INITIALIZATION LOGIC ---
document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
- initializeDataExplorer(); // <-- ADD THIS LINE
+ initializeDataExplorer();
initKeyboardShortcuts();
initSyncUIHandlers();
initDB(async () => {
@@ -830,4 +528,4 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
});
-// --- [END] CORRECTED INITIALIZATION LOGIC ---
+// --- [END] CORRECTED INITIALIZATION LOGIC ---
\ No newline at end of file
diff --git a/steps/tests/fileLoader.test.js b/steps/tests/fileLoader.test.js
new file mode 100644
index 0000000..c9048d0
--- /dev/null
+++ b/steps/tests/fileLoader.test.js
@@ -0,0 +1,172 @@
+import { handleFiles } from "../src/fileLoader.js";
+import { appState } from "../src/state.js";
+import { initDB } from "../src/db.js";
+
+const resultsEl = document.getElementById('results');
+
+function test(description, testFunction) {
+ // Simple async test runner wrapper
+ (async () => {
+ try {
+ await testFunction();
+ console.log(`✅ PASS: ${description}`);
+ resultsEl.innerHTML += `PASS: ${description}
`;
+ } catch (error) {
+ console.error(`❌ FAIL: ${description}`, error);
+ resultsEl.innerHTML += `FAIL: ${description}
${error.stack || error}
`;
+ }
+ })();
+}
+
+// --- Setup & Mocks ---
+
+// Initialize DB for tests
+async function setupTestEnvironment() {
+ return new Promise((resolve) => {
+ initDB(() => {
+ console.log("Test DB initialized");
+ resolve();
+ });
+ });
+}
+
+// Mock URL.createObjectURL
+URL.createObjectURL = (blob) => {
+ return "blob:mock-url-" + Math.random();
+};
+URL.revokeObjectURL = () => {};
+
+// Mock Worker
+class MockWorker {
+ constructor(scriptUrl) {
+ console.log("MockWorker created for:", scriptUrl);
+ this.onmessage = null;
+ }
+ postMessage(msg) {
+ console.log("MockWorker received message:", msg);
+ // Simulate success response
+ if (this.onmessage) {
+ // Simulate parsing delay
+ setTimeout(() => {
+ this.onmessage({
+ data: {
+ type: 'complete',
+ data: {
+ // Correct mock parsed data structure
+ radarFrames: [
+ {
+ timestamp: 1000,
+ pointCloud: [],
+ tracks: []
+ }
+ ],
+ tracks: []
+ }
+ }
+ });
+ }, 50);
+ }
+ }
+ terminate() {}
+}
+window.Worker = MockWorker;
+
+// Mock p5
+window.p5 = class MockP5 {
+ constructor(sketch, node) {
+ console.log("MockP5 created");
+ sketch(this);
+ }
+ createCanvas() { return { parent: () => {} }; }
+ background() {}
+ fill() {}
+ stroke() {}
+ rect() {}
+ ellipse() {}
+ push() {}
+ pop() {}
+ translate() {}
+ scale() {}
+ frameRate() {}
+ noLoop() {}
+ loop() {}
+ redraw() {}
+ resizeCanvas() {}
+ select() { return { html: () => {}, position: () => {}, style: () => {} }; }
+ createGraphics() { return { background: () => {}, clear: () => {}, image: () => {} }; }
+ image() {}
+ text() {}
+ textSize() {}
+ textAlign() {}
+ noStroke() {}
+ color() { return {}; }
+ textFont() {}
+ drawSnrLegendToBuffer() {}
+};
+
+// --- Tests ---
+
+(async function runTests() {
+ await setupTestEnvironment();
+
+ test("fileLoader.js: handleFiles should parse JSON and update appState", async () => {
+ // 1. Setup
+ appState.vizData = null;
+ const mockJsonFile = new File(['{"some": "json"}'], "test_data.json", { type: "application/json" });
+
+ // 2. Execution
+ handleFiles([mockJsonFile]);
+
+ // 3. Verification (Wait for async operations)
+ // We need to wait long enough for DB save + Worker + Processing
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ if (!appState.vizData) {
+ throw new Error("appState.vizData was not populated after loading JSON.");
+ }
+
+ if (appState.jsonFilename !== "test_data.json") {
+ throw new Error(`Expected jsonFilename to be 'test_data.json', got '${appState.jsonFilename}'`);
+ }
+ });
+
+ test("fileLoader.js: handleFiles should handle video loading (simulated)", async () => {
+ // 1. Setup
+ appState.vizData = null;
+ appState.videoFilename = "";
+
+ const videoPlayer = document.getElementById('video-player');
+
+ // Use MutationObserver to watch for src changes
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.type === "attributes" && mutation.attributeName === "src") {
+ console.log("Video src changed, triggering events...");
+ // Trigger events asynchronously to simulate browser behavior
+ setTimeout(() => {
+ videoPlayer.dispatchEvent(new Event('loadedmetadata'));
+ videoPlayer.dispatchEvent(new Event('canplaythrough'));
+ }, 50);
+ }
+ });
+ });
+
+ observer.observe(videoPlayer, { attributes: true });
+
+ const mockVideoFile = new File(['fake video content'], "test_video.mp4", { type: "video/mp4" });
+
+ // 2. Execute
+ handleFiles([mockVideoFile]);
+
+ // 3. Verify
+ await new Promise(resolve => setTimeout(resolve, 500)); // Wait for events
+
+ observer.disconnect(); // Cleanup
+
+ if (appState.videoFilename !== "test_video.mp4") {
+ throw new Error(`Expected videoFilename to be 'test_video.mp4', got '${appState.videoFilename}'`);
+ }
+ });
+
+})();
+
diff --git a/steps/tests/test-runner.html b/steps/tests/test-runner.html
index b56e993..4e7c78d 100644
--- a/steps/tests/test-runner.html
+++ b/steps/tests/test-runner.html
@@ -15,8 +15,93 @@
Check the browser's console for detailed results.
+
-