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.

+ - + + + + \ No newline at end of file