Browse Source

File loading overhaul part 1

refactor/sync-centralize
RUSHIL AMBARISH KADU 6 months ago
parent
commit
cdaed0d4c6
  1. 102
      steps/src/db.js
  2. 301
      steps/src/fileLoader.js
  3. 18
      steps/src/fileParsers.js
  4. 316
      steps/src/main.js
  5. 172
      steps/tests/fileLoader.test.js
  6. 87
      steps/tests/test-runner.html

102
steps/src/db.js

@ -1,9 +1,13 @@
// In src/db.js, replace the entire file content with this:
let db; let db;
let dbReadyPromise;
let dbReadyResolve;
// Initialize the promise that tracks DB readiness
dbReadyPromise = new Promise((resolve) => {
dbReadyResolve = resolve;
});
// Initializes the IndexedDB database. // Initializes the IndexedDB database.
// Opens or creates the 'visualizerDB' database.
export function initDB(callback) { export function initDB(callback) {
const request = indexedDB.open("visualizerDB", 1); const request = indexedDB.open("visualizerDB", 1);
@ -11,70 +15,85 @@ export function initDB(callback) {
const db = event.target.result; const db = event.target.result;
if (!db.objectStoreNames.contains("files")) { if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files"); db.createObjectStore("files");
// Creates an object store named 'files' if it doesn't exist.
} }
}; };
request.onsuccess = function (event) { request.onsuccess = function (event) {
db = event.target.result; db = event.target.result;
console.log("Database initialized"); console.log("Database initialized");
// Assigns the opened database to the 'db' variable.
dbReadyResolve(db); // Signal that DB is ready
if (callback) callback(); if (callback) callback();
}; };
request.onerror = function (event) { request.onerror = function (event) {
console.error("IndexedDB error:", event.target.errorCode); 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(); 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. // Saves a file (Blob) along with its metadata into the IndexedDB.
export function saveFileWithMetadata(key, file) { 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. // Loads a file from IndexedDB, performing checks for filename and size to ensure data integrity.
export function loadFreshFileFromDB(key, expectedFilename) { 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); resolve(null);
return; return;
} }
const transaction = db.transaction(["files"], "readonly");
// Creates a read-only transaction.
const transaction = database.transaction(["files"], "readonly");
const store = transaction.objectStore("files"); const store = transaction.objectStore("files");
const request = store.get(key); const request = store.get(key);
@ -82,21 +101,18 @@ export function loadFreshFileFromDB(key, expectedFilename) {
const cachedData = request.result; const cachedData = request.result;
if (!cachedData) { if (!cachedData) {
console.log(`Cache miss for key '${key}': No data found.`); console.log(`Cache miss for key '${key}': No data found.`);
// If no data is found for the key, resolve with null.
resolve(null); resolve(null);
return; return;
} }
// 1. Versioning Check: Do the filenames match? // 1. Versioning Check: Do the filenames match?
if (cachedData.filename !== expectedFilename) { 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).`); console.warn(`Cache miss for key '${key}': Stale data found (Filename mismatch).`);
resolve(null); resolve(null);
return; return;
} }
// 2. Integrity Check: Do the sizes match? // 2. Integrity Check: Do the sizes match?
// Checks if the cached file size matches the stored size metadata.
if (cachedData.blob.size !== cachedData.size) { if (cachedData.blob.size !== cachedData.size) {
console.error(`Cache miss for key '${key}': Corrupted data found (Size mismatch).`); console.error(`Cache miss for key '${key}': Corrupted data found (Size mismatch).`);
resolve(null); resolve(null);
@ -104,14 +120,12 @@ export function loadFreshFileFromDB(key, expectedFilename) {
} }
// All checks passed! // All checks passed!
// If all checks pass, resolve with the cached Blob.
console.log(`Cache hit for '${expectedFilename}'`); console.log(`Cache hit for '${expectedFilename}'`);
resolve(cachedData.blob); resolve(cachedData.blob);
}; };
request.onerror = (event) => { request.onerror = (event) => {
console.error(`Error loading file '${key}' from DB:`, event.target.error); console.error(`Error loading file '${key}' from DB:`, event.target.error);
// Logs any errors during the load operation.
resolve(null); resolve(null);
}; };
}); });

301
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);
}

18
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) { 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 snrValues = [];
let totalPoints = 0; let totalPoints = 0;
await processArrayInChunks(vizData.radarFrames, 5000, (chunk) => { await processArrayInChunks(vizData.radarFrames, 5000, (chunk) => {

316
steps/src/main.js

@ -17,52 +17,25 @@
// - main.js: The main application entry point that wires everything // - main.js: The main application entry point that wires everything
// =========================================================================================================== // ===========================================================================================================
import { zoomSketch } from "./p5/zoomSketch.js";
//import { showExplorer, hideExplorer, displayInGrid } from "./dataExplorer.js"; //import { showExplorer, hideExplorer, displayInGrid } from "./dataExplorer.js";
import { initializeDataExplorer, throttledUpdateExplorer } from "./dataExplorer.js"; // <-- ADD THIS
import { initializeDataExplorer } from "./dataExplorer.js";
import { import {
showModal, showModal,
hideModal, hideModal,
updateLoadingModal,
showLoadingModal,
} from "./modal.js"; // Modify this import
} from "./modal.js";
import { import {
animationLoop,
videoFrameCallback,
initSyncUIHandlers,
startPlayback, startPlayback,
pausePlayback, pausePlayback,
stopPlayback, stopPlayback,
initSyncUIHandlers,
updateFrame,
resetVisualization,
forceResyncWithOffset, forceResyncWithOffset,
} from "./sync.js"; } 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 { appState } from "./state.js";
import { debugFlags } from "./debug.js"; // Import the new debug flags import { debugFlags } from "./debug.js"; // Import the new debug flags
window.appState = appState; // exposing the appState to console window.appState = appState; // exposing the appState to console
window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling window.debugFlags = debugFlags; // Expose debug flags to the console for runtime toggling
import { import {
themeToggleBtn,
canvasContainer,
canvasPlaceholder, canvasPlaceholder,
videoPlayer, videoPlayer,
videoPlaceholder, videoPlaceholder,
@ -73,7 +46,6 @@ import {
playPauseBtn, playPauseBtn,
stopBtn, stopBtn,
timelineSlider, timelineSlider,
frameCounter,
offsetInput, offsetInput,
speedSlider, speedSlider,
speedDisplay, speedDisplay,
@ -88,15 +60,12 @@ import {
toggleFrameNorm, toggleFrameNorm,
toggleDebugOverlay, toggleDebugOverlay,
toggleDebug2Overlay, toggleDebug2Overlay,
egoSpeedDisplay,
debugOverlay, debugOverlay,
snrMinInput, snrMinInput,
snrMaxInput, snrMaxInput,
applySnrBtn, applySnrBtn,
autoOffsetIndicator, autoOffsetIndicator,
clearCacheBtn, clearCacheBtn,
speedGraphContainer,
speedGraphPlaceholder,
toggleCloseUp, toggleCloseUp,
updateDebugOverlay, updateDebugOverlay,
timelineTooltip, timelineTooltip,
@ -109,50 +78,17 @@ import {
collapsibleMenu, collapsibleMenu,
toggleMenuBtn, toggleMenuBtn,
fullscreenBtn, fullscreenBtn,
mainContent,
closeMenuBtn, closeMenuBtn,
menuScrim, menuScrim,
toggleConfirmedOnly, toggleConfirmedOnly,
resetUIForNewLoad, resetUIForNewLoad,
//explorerBtn,
} from "./dom.js"; } from "./dom.js";
import { initializeTheme } from "./theme.js"; import { initializeTheme } from "./theme.js";
import { initDB, saveFileWithMetadata, loadFreshFileFromDB } from "./db.js";
import { initDB, loadFreshFileFromDB } from "./db.js";
import { initKeyboardShortcuts } from "./keyboard.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 // Wire up the manual file inputs to the new handler
jsonFileInput.addEventListener("change", (event) => jsonFileInput.addEventListener("change", (event) =>
@ -177,198 +113,6 @@ dropZone.addEventListener("drop", (event) => {
handleFiles(event.dataTransfer.files); 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. // Event listener for loading JSON file.
loadJsonBtn.addEventListener("click", () => jsonFileInput.click()); loadJsonBtn.addEventListener("click", () => jsonFileInput.click());
@ -729,52 +473,6 @@ videoPlayer.addEventListener("ended", () => {
playPauseBtn.textContent = "Play"; 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) => { offsetInput.addEventListener("keydown", (event) => {
// Check if the key pressed was 'Enter' // Check if the key pressed was 'Enter'
if (event.key === "Enter") { if (event.key === "Enter") {
@ -786,7 +484,7 @@ offsetInput.addEventListener("keydown", (event) => {
// --- [START] CORRECTED INITIALIZATION LOGIC --- // --- [START] CORRECTED INITIALIZATION LOGIC ---
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initializeTheme(); initializeTheme();
initializeDataExplorer(); // <-- ADD THIS LINE
initializeDataExplorer();
initKeyboardShortcuts(); initKeyboardShortcuts();
initSyncUIHandlers(); initSyncUIHandlers();
initDB(async () => { initDB(async () => {

172
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 += `<p class="pass"><b>PASS:</b> ${description}</p>`;
} catch (error) {
console.error(`❌ FAIL: ${description}`, error);
resultsEl.innerHTML += `<p class="fail"><b>FAIL:</b> ${description}<br><pre>${error.stack || error}</pre></p>`;
}
})();
}
// --- 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}'`);
}
});
})();

87
steps/tests/test-runner.html

@ -15,8 +15,93 @@
<p>Check the browser's console for detailed results.</p> <p>Check the browser's console for detailed results.</p>
<div id="results"></div> <div id="results"></div>
<script src="../vendor/p5.js"></script>
<script type="module" src="utils.test.js"></script> <script type="module" src="utils.test.js"></script>
<script type="module" src="fileParsers.test.js"></script> </body>
<script type="module" src="fileParsers.test.js"></script>
<script type="module" src="fileLoader.test.js"></script>
<!-- Mock DOM for dom.js -->
<div id="mock-dom" style="display: none;">
<button id="theme-toggle"></button>
<div id="canvas-container"></div>
<div id="canvas-placeholder"></div>
<video id="video-player"></video>
<div id="video-placeholder"></div>
<button id="load-json-btn"></button>
<button id="load-video-btn"></button>
<button id="load-can-btn"></button>
<input id="json-file-input" type="file">
<input id="video-file-input" type="file">
<input id="can-file-input" type="file">
<button id="play-pause-btn"></button>
<button id="stop-btn"></button>
<input id="timeline-slider" type="range">
<div id="frame-counter"></div>
<input id="offset-input">
<input id="speed-slider" type="range">
<div id="speed-display"></div>
<div id="feature-toggles">
<input type="checkbox" id="toggle-snr-color">
<input type="checkbox" id="toggle-cluster-color">
<input type="checkbox" id="toggle-inlier-color">
<input type="checkbox" id="toggle-stationary-color">
<input type="checkbox" id="toggle-velocity">
<input type="checkbox" id="toggle-tracks">
<input type="checkbox" id="toggle-ego-speed">
<input type="checkbox" id="toggle-frame-norm">
<input type="checkbox" id="toggle-debug-overlay">
<input type="checkbox" id="toggle-debug2-overlay">
<input type="checkbox" id="toggle-close-up">
<input type="checkbox" id="toggle-predicted-pos">
<input type="checkbox" id="toggle-covariance">
<input type="checkbox" id="toggle-confirmed-only">
<input type="checkbox" id="ttc-mode-default">
<input type="checkbox" id="ttc-mode-custom">
</div>
<div id="ego-speed-display"></div>
<div id="can-speed-display"></div>
<div id="debug-overlay"></div>
<input id="snr-min-input">
<input id="snr-max-input">
<button id="apply-snr-btn"></button>
<div id="auto-offset-indicator"></div>
<button id="clear-cache-btn"></button>
<div id="speed-graph-container"></div>
<div id="speed-graph-placeholder"></div>
<div id="modal-container"></div>
<div id="modal-overlay"></div>
<div id="modal-content"></div>
<div id="modal-text"></div>
<button id="modal-ok-btn"></button>
<button id="modal-cancel-btn"></button>
<div id="modal-progress-container"></div>
<div id="modal-progress-bar"></div>
<div id="modal-progress-text"></div>
<div id="timeline-tooltip"></div>
<div id="radar-info-overlay"></div>
<div id="video-info-overlay"></div>
<button id="save-session-btn"></button>
<button id="load-session-btn"></button>
<input id="session-file-input" type="file">
<div id="custom-ttc-panel"></div>
<input id="ttc-color-critical">
<input id="ttc-time-critical">
<input id="ttc-color-high">
<input id="ttc-time-high">
<input id="ttc-color-medium">
<input id="ttc-time-medium">
<input id="ttc-color-low">
<input id="ttc-time-low">
<div id="collapsible-menu"></div>
<button id="toggle-menu-btn"></button>
<button id="fullscreen-btn"></button>
<main></main>
<button id="close-menu-btn"></button>
<div id="fullscreen-enter-icon"></div>
<div id="fullscreen-exit-icon"></div>
<div id="menu-scrim"></div>
<button id="explorer-btn"></button>
<div id="zoom-canvas-container"></div>
</div>
</body> </body>
</html> </html>
Loading…
Cancel
Save