diff --git a/steps/favicon.png b/steps/favicon.png
new file mode 100644
index 0000000..2703d60
Binary files /dev/null and b/steps/favicon.png differ
diff --git a/steps/index.html b/steps/index.html
index c7a3b62..c2be164 100644
--- a/steps/index.html
+++ b/steps/index.html
@@ -6,6 +6,7 @@
Radar and Video Visualizer - Timestamp Synchronized
+
diff --git a/steps/src/db.js b/steps/src/db.js
index 8f2fb78..a94e0e5 100644
--- a/steps/src/db.js
+++ b/steps/src/db.js
@@ -1,79 +1,127 @@
-// -------------------------- IndexedDB for Caching ----------------- //
-let db;
+// In src/db.js, replace the entire file content with this:
-//---------------------------Initialize DB----------------------------//
+let db;
// Initializes the IndexedDB database.
-// @param {function} callback - A function to be called once the database is initialized.
+// Opens or creates the 'visualizerDB' database.
export function initDB(callback) {
- // Open the database with the name "visualizerDB" and version 1.
const request = indexedDB.open("visualizerDB", 1);
- // Event handler for when the database needs to be upgraded (e.g., first time creation or version change).
request.onupgradeneeded = function (event) {
const db = event.target.result;
- // Create an object store named "files" if it doesn't already exist.
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files");
+ // Creates an object store named 'files' if it doesn't exist.
}
};
- // Event handler for a successful database opening.
request.onsuccess = function (event) {
db = event.target.result;
console.log("Database initialized");
- // Call the provided callback function.
+ // Assigns the opened database to the 'db' variable.
if (callback) callback();
};
- // Event handler for an error during database opening.
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 (callback) callback();
};
}
-//---------------------------save file------------------------------//
-
-// Saves a file (or any value) to the IndexedDB.
-// @param {string} key - The key to store the value under.
-// @param {*} value - The value to be stored.
-export function saveFileToDB(key, value) {
- // If the database is not initialized, return.
+/**
+ * Saves a file and its metadata to IndexedDB for versioning and integrity checks.
+ * @param {string} key The key to store the file under (e.g., 'json', 'video').
+ * @param {File} file The file object to be cached.
+ */
+// Saves a file (Blob) along with its metadata into the IndexedDB.
+export function saveFileWithMetadata(key, file) {
if (!db) return;
- // Start a read-write transaction on the "files" object store.
+
const transaction = db.transaction(["files"], "readwrite");
const store = transaction.objectStore("files");
- // Put (add or update) the value with the given key.
- const request = store.put(value, key);
- // Event handler for a successful save operation.
- request.onsuccess = () => console.log(`File '${key}' saved to DB.`);
- // Event handler for an error during saving.
- request.onerror = (event) =>
- console.error(`Error saving file '${key}':`, event.target.error);
-}
+
+ // 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).
+ };
-//---------------------------load file--------------------------------//
+ const request = store.put(dataToStore, key);
-export function loadFileFromDB(key, callback) {
- // If the database is not initialized, return.
- if (!db) return;
- // Start a read-only transaction on the "files" object store.
- const transaction = db.transaction(["files"], "readonly");
- const store = transaction.objectStore("files");
- // Get the value associated with the given key.
- const request = store.get(key);
- // Event handler for a successful retrieval.
- request.onsuccess = function () {
- // If a result is found, call the callback with the result.
- if (request.result) {
- callback(request.result);
+ 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 {
- console.log(`File '${key}' not found in DB.`);
- callback(null);
+ // Handles potential errors during the save operation, such as QuotaExceededError.
+ console.error(`Error saving file '${key}':`, event.target.error);
}
- }; // Event handler for an error during loading.
- request.onerror = (event) => {
- console.error(`Error loading file '${key}':`, event.target.error);
- callback(null);
};
}
+
+/**
+ * Loads a file from IndexedDB only if its filename and size match expected values.
+ * @param {string} key The key of the file to load.
+ * @param {string} expectedFilename The filename we expect to find.
+ * @returns {Promise} A Promise that resolves with the Blob if it's fresh, otherwise null.
+ */
+// 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) {
+ resolve(null);
+ return;
+ }
+
+ const transaction = db.transaction(["files"], "readonly");
+ // Creates a read-only transaction.
+ const store = transaction.objectStore("files");
+ const request = store.get(key);
+
+ request.onsuccess = function () {
+ 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);
+ return;
+ }
+
+ // 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/main.js b/steps/src/main.js
index 629413b..c9a5e18 100644
--- a/steps/src/main.js
+++ b/steps/src/main.js
@@ -77,8 +77,10 @@ import {
resetVisualization,
updateDebugOverlay,
} from "./dom.js";
+
import { initializeTheme } from "./theme.js";
-import { initDB, saveFileToDB, loadFileFromDB } from "./db.js";
+
+import { initDB, saveFileWithMetadata, loadFreshFileFromDB } from "./db.js";
// Sets up the video player with the given file URL.
function setupVideoPlayer(fileURL) {
@@ -88,6 +90,82 @@ function setupVideoPlayer(fileURL) {
videoPlayer.playbackRate = parseFloat(speedSlider.value);
}
+// In src/main.js, add this new function
+function loadVideoWithProgress(videoObject) {
+ if (!videoObject) return;
+
+ showModal("Loading video...", false, true);
+ updateModalProgress(0);
+
+ // Define event handlers so we can add and remove them correctly
+ const onProgress = () => {
+ if (videoPlayer.duration > 0) {
+ // Find the end of the buffered content
+ const bufferedEnd =
+ videoPlayer.buffered.length > 0 ? videoPlayer.buffered.end(0) : 0;
+ const percent = (bufferedEnd / videoPlayer.duration) * 100;
+ updateModalProgress(percent);
+ }
+ };
+
+ const onCanPlayThrough = () => {
+ updateModalProgress(100);
+ // Give the user a moment to see 100% before closing the modal
+ setTimeout(() => {
+ document.getElementById("modal-ok-btn").click();
+ }, 400);
+
+ // Clean up the event listeners we added
+ videoPlayer.removeEventListener("progress", onProgress);
+ videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
+ };
+
+ const onError = () => {
+ showModal("Error: Could not load the video file.");
+ // Clean up event listeners on error
+ videoPlayer.removeEventListener("progress", onProgress);
+ videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
+ videoPlayer.removeEventListener("error", onError);
+ };
+
+ // This one-time event is for re-syncing data once the video's metadata is ready
+
+
+videoPlayer.addEventListener('loadedmetadata', () => {
+ // This is the perfect time to re-sync data if needed
+ if (appState.vizData) {
+ console.log("DEBUG: Video metadata loaded. Re-calculating timestamps.");
+ appState.vizData.radarFrames.forEach((frame) => {
+ frame.timestampMs = appState.radarStartTimeMs + frame.timestamp - appState.videoStartDate.getTime();
+ });
+ resetVisualization();
+ }
+
+ // --- START: New Speed Graph Logic ---
+ // If we have data and the video is ready, create/update the speed graph
+ if (appState.vizData && videoPlayer.duration > 0) {
+ speedGraphPlaceholder.classList.add("hidden");
+ if (!appState.speedGraphInstance) {
+ appState.speedGraphInstance = new p5(speedGraphSketch);
+ }
+ appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration);
+ }
+ // --- END: New Speed Graph Logic ---
+
+}, { once: true }); // { once: true } makes sure this runs only once per load
+
+ // { once: true } //makes sure this runs only once per load
+
+ // Add the listeners for progress tracking
+ videoPlayer.addEventListener("progress", onProgress);
+ videoPlayer.addEventListener("canplaythrough", onCanPlayThrough);
+ videoPlayer.addEventListener("error", onError);
+
+ // Create the object URL and set the video source to trigger loading
+ const fileURL = URL.createObjectURL(videoObject);
+ setupVideoPlayer(fileURL);
+}
+
// Event listener for loading JSON file.
loadJsonBtn.addEventListener("click", () => jsonFileInput.click());
loadVideoBtn.addEventListener("click", () => videoFileInput.click());
@@ -103,110 +181,104 @@ clearCacheBtn.addEventListener("click", async () => {
// In src/main.js, REPLACE the jsonFileInput event listener with this:
jsonFileInput.addEventListener("change", (event) => {
- const file = event.target.files[0];
- if (!file) return;
-
- appState.jsonFilename = file.name;
- localStorage.setItem("jsonFilename", appState.jsonFilename);
- calculateAndSetOffset();
- saveFileToDB("json", file); // We still cache the raw file
-
- // 1. Show the modal with the progress bar
- showModal("Parsing large JSON file...", false, true);
- updateModalProgress(0);
-
- // 2. Create a new Worker from our script
- const worker = new Worker('./src/parser.worker.js');
-
- // 3. Set up listeners for messages FROM the worker
- worker.onmessage = async (e) => {
- const { type, data, message, percent } = e.data;
+ const file = event.target.files[0];
+ if (!file) return;
- if (type === 'progress') {
- // Update the progress bar whenever the worker reports progress
- updateModalProgress(percent);
- } else if (type === 'complete') {
- // Worker is done! Process the data it sent back.
- updateModalProgress(100);
-
- const result = await parseVisualizationJson(
- data, // Use the data object directly from the worker
- appState.radarStartTimeMs,
- appState.videoStartDate
- );
-
- if (result.error) {
- showModal(result.error);
- return;
- }
-
- appState.vizData = result.data;
- appState.globalMinSnr = result.minSnr;
- appState.globalMaxSnr = result.maxSnr;
- snrMinInput.value = appState.globalMinSnr.toFixed(1);
- snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
-
- resetVisualization();
- canvasPlaceholder.style.display = "none";
- featureToggles.classList.remove("hidden");
-
- if (!appState.p5_instance) {
- appState.p5_instance = new p5(radarSketch);
- }
- if (appState.vizData && videoPlayer.duration) {
- if (!appState.speedGraphInstance) {
- appState.speedGraphInstance = new p5(speedGraphSketch);
- }
- appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration);
- }
-
- // Close the modal and terminate the worker
- document.getElementById("modal-ok-btn").click();
- worker.terminate();
+ appState.jsonFilename = file.name;
+ localStorage.setItem("jsonFilename", appState.jsonFilename);
+ calculateAndSetOffset();
+ saveFileWithMetadata("json", file); // We still cache the raw file
+
+ // 1. Show the modal with the progress bar
+ showModal("Parsing large JSON file...", false, true);
+ updateModalProgress(0);
+
+ // 2. Create a new Worker from our script
+ const worker = new Worker("./src/parser.worker.js");
+
+ // 3. Set up listeners for messages FROM the worker
+ worker.onmessage = async (e) => {
+ const { type, data, message, percent } = e.data;
+
+ if (type === "progress") {
+ // Update the progress bar whenever the worker reports progress
+ updateModalProgress(percent);
+ } else if (type === "complete") {
+ // Worker is done! Process the data it sent back.
+ updateModalProgress(100);
+
+ const result = await parseVisualizationJson(
+ data, // Use the data object directly from the worker
+ appState.radarStartTimeMs,
+ appState.videoStartDate
+ );
- } else if (type === 'error') {
- // The worker ran into an error
- showModal(message);
- worker.terminate();
+ if (result.error) {
+ showModal(result.error);
+ return;
+ }
+ // --- START: New Cleanup Logic ---
+ // If p5.js instances already exist, remove them completely
+ if (appState.p5_instance) {
+ appState.p5_instance.remove();
+ appState.p5_instance = null;
+ }
+ if (appState.speedGraphInstance) {
+ appState.speedGraphInstance.remove();
+ appState.speedGraphInstance = null;
+ // Also reset the placeholder text
+ speedGraphPlaceholder.classList.remove("hidden");
+ }
+ // --- END: New Cleanup Logic ---
+ appState.vizData = result.data;
+ appState.globalMinSnr = result.minSnr;
+ appState.globalMaxSnr = result.maxSnr;
+ snrMinInput.value = appState.globalMinSnr.toFixed(1);
+ snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
+
+ resetVisualization();
+ canvasPlaceholder.style.display = "none";
+ featureToggles.classList.remove("hidden");
+
+ if (!appState.p5_instance) {
+ appState.p5_instance = new p5(radarSketch);
+ }
+ if (appState.vizData && videoPlayer.duration) {
+ if (!appState.speedGraphInstance) {
+ appState.speedGraphInstance = new p5(speedGraphSketch);
}
- };
+ appState.speedGraphInstance.setData(
+ appState.vizData,
+ videoPlayer.duration
+ );
+ }
- // 4. Send the file TO the worker to start the job
- worker.postMessage({ file: file });
+ // Close the modal and terminate the worker
+ document.getElementById("modal-ok-btn").click();
+ worker.terminate();
+ } else if (type === "error") {
+ // The worker ran into an error
+ showModal(message);
+ worker.terminate();
+ }
+ };
+
+ // 4. Send the file TO the worker to start the job
+ worker.postMessage({ file: file });
});
// Event listener for video file input change.
+// In src/main.js, REPLACE the videoFileInput event listener with this:
videoFileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
+
appState.videoFilename = file.name;
localStorage.setItem("videoFilename", appState.videoFilename);
- saveFileToDB("video", file);
+ saveFileWithMetadata("video", file);
calculateAndSetOffset();
-
- if (appState.vizData) {
- console.log("DEBUG: Video loaded after JSON. Re-calculating timestamps.");
- appState.vizData.radarFrames.forEach((frame) => {
- frame.timestampMs =
- appState.radarStartTimeMs +
- frame.timestamp -
- appState.videoStartDate.getTime();
- });
- resetVisualization();
- }
-
- const fileURL = URL.createObjectURL(file);
- setupVideoPlayer(fileURL);
-
- videoPlayer.onloadedmetadata = () => {
- if (appState.speedGraphInstance) {
- appState.speedGraphInstance.setData(
- appState.vizData,
- videoPlayer.duration
- );
- }
- };
+ loadVideoWithProgress(file);
});
// Event listener for offset input change.
@@ -429,97 +501,99 @@ function calculateAndSetOffset() {
// Application Initialization
// In src/main.js, REPLACE the entire 'DOMContentLoaded' listener with this:
+// In src/main.js, replace the existing DOMContentLoaded listener with this entire block:
+
+// In src/main.js, replace the existing DOMContentLoaded listener with this entire block:
document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
console.log("DEBUG: DOMContentLoaded fired. Starting session load.");
- initDB(() => {
+ initDB(async () => { // Make the callback async to use await
console.log("DEBUG: Database initialized.");
const savedOffset = localStorage.getItem("visualizerOffset");
if (savedOffset !== null) {
offsetInput.value = savedOffset;
}
+
+ // Get the filenames we EXPECT to load from localStorage
appState.videoFilename = localStorage.getItem("videoFilename");
appState.jsonFilename = localStorage.getItem("jsonFilename");
calculateAndSetOffset();
- const videoPromise = new Promise((resolve) => loadFileFromDB("video", resolve));
- const jsonPromise = new Promise((resolve) => loadFileFromDB("json", resolve));
-
- Promise.all([videoPromise, jsonPromise])
- .then(([videoBlob, jsonBlob]) => {
- console.log("DEBUG: All data fetched from IndexedDB.");
-
- // This function will be called with the fully parsed JSON data when ready.
- const finalizeSetup = async (parsedJson) => {
- if (parsedJson) {
- const result = await parseVisualizationJson(
- parsedJson,
- appState.radarStartTimeMs,
- appState.videoStartDate
- );
-
- if (!result.error) {
- appState.vizData = result.data;
- appState.globalMinSnr = result.minSnr;
- appState.globalMaxSnr = result.maxSnr;
- snrMinInput.value = result.minSnr.toFixed(1);
- snrMaxInput.value = result.maxSnr.toFixed(1);
- } else {
- showModal(result.error);
- }
- }
-
- // Setup video player
- if (videoBlob) {
- const fileURL = URL.createObjectURL(videoBlob);
- setupVideoPlayer(fileURL);
- }
-
- // Final UI updates
- if (appState.vizData) {
- resetVisualization();
- canvasPlaceholder.style.display = "none";
- featureToggles.classList.remove("hidden");
- if (!appState.p5_instance) {
- appState.p5_instance = new p5(radarSketch);
- }
- }
- };
-
- if (jsonBlob) {
- // --- CACHED JSON FOUND: USE WORKER ---
- showModal("Loading data from cache...", false, true);
- updateModalProgress(0);
-
- const worker = new Worker('./src/parser.worker.js');
-
- worker.onmessage = async (e) => {
- const { type, data, message, percent } = e.data;
- if (type === 'progress') {
- updateModalProgress(percent);
- } else if (type === 'complete') {
- updateModalProgress(100);
- await finalizeSetup(data);
- document.getElementById("modal-ok-btn").click();
- worker.terminate();
- } else if (type === 'error') {
- showModal(message);
- worker.terminate();
- }
- };
-
- worker.postMessage({ file: jsonBlob });
+ // Asynchronously load files, performing freshness and integrity checks
+ const videoBlob = await loadFreshFileFromDB("video", appState.videoFilename);
+ const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename);
+
+ console.log("DEBUG: Freshness checks complete. Proceeding with valid data.");
+
+ // This function processes the parsed JSON and sets up the main visualization state
+ const finalizeSetup = async (parsedJson) => {
+ if (parsedJson) {
+ const result = await parseVisualizationJson(
+ parsedJson,
+ appState.radarStartTimeMs,
+ appState.videoStartDate
+ );
+ if (!result.error) {
+ appState.vizData = result.data;
+ appState.globalMinSnr = result.minSnr;
+ appState.globalMaxSnr = result.maxSnr;
+ snrMinInput.value = result.minSnr.toFixed(1);
+ snrMaxInput.value = result.maxSnr.toFixed(1);
} else {
- // --- NO CACHED JSON ---
- finalizeSetup(null);
+ showModal(result.error);
}
- })
- .catch((error) => {
- console.error("DEBUG: Error during Promise.all data loading:", error);
- });
+ }
+
+ // Final UI updates for the radar canvas
+ if (appState.vizData) {
+ resetVisualization();
+ canvasPlaceholder.style.display = "none";
+ featureToggles.classList.remove("hidden");
+ if (!appState.p5_instance) {
+ appState.p5_instance = new p5(radarSketch);
+ }
+ }
+ };
+
+ // --- Main Loading Logic ---
+ if (jsonBlob) {
+ // CASE 1: Cached JSON exists. Parse it first with a progress bar.
+ showModal("Loading data from cache...", false, true);
+ updateModalProgress(0);
+
+ const worker = new Worker('./src/parser.worker.js');
+
+ worker.onmessage = async (e) => {
+ const { type, data, message, percent } = e.data;
+
+ if (type === 'progress') {
+ updateModalProgress(percent);
+ } else if (type === 'complete') {
+ updateModalProgress(100);
+ await finalizeSetup(data); // Process the parsed JSON
+
+ // Hide the JSON loading modal before starting the video load
+ document.getElementById("modal-ok-btn").click();
+ worker.terminate();
+
+ // Now that JSON is ready, load the video (which will show its own modal)
+ loadVideoWithProgress(videoBlob);
+ } else if (type === 'error') {
+ showModal(message);
+ worker.terminate();
+ }
+ };
+
+ worker.postMessage({ file: jsonBlob });
+
+ } else {
+ // CASE 2: No cached JSON. Finalize setup with null data and just load the video if it exists.
+ await finalizeSetup(null);
+ loadVideoWithProgress(videoBlob);
+ }
});
});
diff --git a/steps/tests/fileParsers.test.js b/steps/tests/fileParsers.test.js
new file mode 100644
index 0000000..2cf9d0d
--- /dev/null
+++ b/steps/tests/fileParsers.test.js
@@ -0,0 +1,63 @@
+// tests/fileParsers.test.js
+
+import { parseVisualizationJson } from '../src/fileParsers.js';
+
+const resultsEl = document.getElementById('results');
+
+// A simple function to run an async test and report the result
+async function testAsync(description, testFunction) {
+ try {
+ await testFunction();
+ console.log(`✅ PASS: ${description}`);
+ resultsEl.innerHTML += `