Browse Source
feat: Implement robust caching, video progress, and major bug fixes.
feat: Implement robust caching, video progress, and major bug fixes.
This commit introduces a suite of major improvements focused on application robustness, user experience, and bug fixes. The changes overhaul the caching system, enhance the file loading experience, and resolve critical state management issues. ### ✨ New Features & Enhancements 1. **Robust Caching System**: - The IndexedDB caching logic now stores file metadata (filename, size) alongside the file blob. - Implemented a **versioning check** by comparing filenames to prevent loading stale, outdated cache. - Added an **integrity check** by comparing file sizes to detect and discard corrupted or incomplete cached data. - Implemented graceful error handling for browser `QuotaExceededError`. 2. **Progress Bar for All Loading Operations**: - The smooth, worker-based progress bar now appears when loading JSON data from the **IndexedDB cache**, providing a consistent experience with fresh file loads. - A new progress bar has been implemented for **video file loading**. It tracks the browser's buffering progress and appears for both fresh file selections and cached reloads. 3. **UI Polish**: - A **favicon** has been added to the application tab for a more professional look. ### 🐛 Bug Fixes 1. **Corrected Worker Parsing Logic**: - Fixed a critical bug in the JSON parsing web worker (`parser.worker.js`) where its logic failed to handle nested objects (like `pointCloud` arrays). The worker now uses a robust algorithm to correctly build the entire JSON tree, ensuring data is always parsed accurately. 2. **Fixed JSON Reloading**: - Resolved an issue where loading a new JSON file over an existing one would fail. The application now properly removes old p5.js visualization instances before creating new ones, ensuring a clean state for the new data. 3. **Fixed Speed Graph on Cached Load**: - Corrected a bug where the speed graph would not appear when the application started up from a cached session. The initialization logic now correctly creates and updates the speed graph after the cached video's metadata is loaded.refactor/modularize
7 changed files with 513 additions and 211 deletions
-
BINsteps/favicon.png
-
1steps/index.html
-
140steps/src/db.js
-
404steps/src/main.js
-
63steps/tests/fileParsers.test.js
-
22steps/tests/test-runner.html
-
94steps/tests/utils.test.js
|
After Width: 32 | Height: 32 | Size: 1.4 KiB |
@ -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); |
|||
} |
|||
|
|||
//---------------------------load file--------------------------------//
|
|||
// 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).
|
|||
}; |
|||
|
|||
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); |
|||
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 { |
|||
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<Blob|null>} 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); |
|||
}; |
|||
}); |
|||
} |
|||
@ -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 += `<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}</pre></p>`; |
|||
} |
|||
} |
|||
|
|||
|
|||
// --- Test Cases for parseVisualizationJson ---
|
|||
|
|||
const mockRawData = { |
|||
radarFrames: [ |
|||
{ timestamp: 50, pointCloud: [{ snr: 10 }, { snr: 15 }] }, |
|||
{ timestamp: 100, pointCloud: [{ snr: 5 }, { snr: 20 }] } |
|||
] |
|||
}; |
|||
|
|||
const radarStartTimeMs = 1725440400000; // Sept 4, 2025 12:30:00 PM GMT
|
|||
const videoStartDate = new Date(radarStartTimeMs - 1000); // Video starts 1 second earlier
|
|||
|
|||
testAsync("fileParsers.js: should correctly calculate timestampMs for each frame", async () => { |
|||
const result = await parseVisualizationJson(mockRawData, radarStartTimeMs, videoStartDate); |
|||
const expectedTimestamp1 = radarStartTimeMs + 50 - videoStartDate.getTime(); // 1000 + 50 = 1050
|
|||
const expectedTimestamp2 = radarStartTimeMs + 100 - videoStartDate.getTime(); // 1000 + 100 = 1100
|
|||
|
|||
if (result.data.radarFrames[0].timestampMs !== expectedTimestamp1) { |
|||
throw new Error(`Expected first frame timestamp to be ${expectedTimestamp1} but got ${result.data.radarFrames[0].timestampMs}`); |
|||
} |
|||
if (result.data.radarFrames[1].timestampMs !== expectedTimestamp2) { |
|||
throw new Error(`Expected second frame timestamp to be ${expectedTimestamp2} but got ${result.data.radarFrames[1].timestampMs}`); |
|||
} |
|||
}); |
|||
|
|||
testAsync("fileParsers.js: should correctly calculate global min and max SNR", async () => { |
|||
const result = await parseVisualizationJson(mockRawData, radarStartTimeMs, videoStartDate); |
|||
|
|||
if (result.minSnr !== 5) { |
|||
throw new Error(`Expected minSnr to be 5 but got ${result.minSnr}`); |
|||
} |
|||
if (result.maxSnr !== 20) { |
|||
throw new Error(`Expected maxSnr to be 20 but got ${result.maxSnr}`); |
|||
} |
|||
}); |
|||
|
|||
testAsync("fileParsers.js: should return an error if radarFrames are missing", async () => { |
|||
const badData = { tracks: [] }; // Missing radarFrames
|
|||
const result = await parseVisualizationJson(badData, radarStartTimeMs, videoStartDate); |
|||
|
|||
if (!result.error) { |
|||
throw new Error("Expected an error for missing radarFrames, but none was returned."); |
|||
} |
|||
}); |
|||
@ -0,0 +1,22 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<title>Visualizer Unit Tests</title> |
|||
<style> |
|||
body { font-family: sans-serif; padding: 20px; } |
|||
.pass { color: green; } |
|||
.fail { color: red; } |
|||
pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; } |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<h1>Visualizer Unit Tests</h1> |
|||
<p>Check the browser's console for detailed results.</p> |
|||
<div id="results"></div> |
|||
|
|||
<script type="module" src="utils.test.js"></script> |
|||
<script type="module" src="fileParsers.test.js"></script> </body> |
|||
|
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,94 @@ |
|||
// tests/utils.test.js
|
|||
|
|||
import { |
|||
extractTimestampInfo, |
|||
parseTimestamp, |
|||
findRadarFrameIndexForTime |
|||
} from '../src/utils.js'; |
|||
|
|||
const resultsEl = document.getElementById('results'); |
|||
|
|||
// A simple function to run a test and report the result
|
|||
function test(description, testFunction) { |
|||
try { |
|||
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}</pre></p>`; |
|||
} |
|||
} |
|||
|
|||
// --- Test Cases for Timestamp Functions ---
|
|||
|
|||
test("utils.js: should extract timestamp info from a new JSON filename format", () => { |
|||
const info = extractTimestampInfo('fHist_04092025_123000.123.json'); |
|||
if (!info || info.format !== 'json' || info.timestampStr !== '04092025_123000.123') { |
|||
throw new Error(`Expected 'json' format and correct timestamp, but got ${JSON.stringify(info)}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should extract timestamp info from a video filename", () => { |
|||
const info = extractTimestampInfo('WIN_20250904_12_30_00_Pro.mp4'); |
|||
if (!info || info.format !== 'video' || info.timestampStr !== '20250904_123000') { |
|||
throw new Error(`Expected 'video' format and correct timestamp, but got ${JSON.stringify(info)}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should correctly parse a video timestamp string into a Date object", () => { |
|||
const timestampStr = '20250904_123000'; // 4th Sept 2025, 12:30:00
|
|||
const date = parseTimestamp(timestampStr, 'video'); |
|||
const expectedDate = new Date(Date.UTC(2025, 8, 4, 12, 30, 0)); // Month is 0-indexed (8 = September)
|
|||
|
|||
if (date.getTime() !== expectedDate.getTime()) { |
|||
throw new Error(`Date mismatch. Expected ${expectedDate.toISOString()} but got ${date.toISOString()}`); |
|||
} |
|||
}); |
|||
|
|||
|
|||
// --- Test Cases for findRadarFrameIndexForTime ---
|
|||
|
|||
const mockVizData = { |
|||
radarFrames: [ |
|||
{ timestampMs: 100 }, // index 0
|
|||
{ timestampMs: 200 }, // index 1
|
|||
{ timestampMs: 300 }, // index 2
|
|||
{ timestampMs: 400 }, // index 3
|
|||
] |
|||
}; |
|||
|
|||
test("utils.js: should find the correct frame for a time that is between two frames", () => { |
|||
const index = findRadarFrameIndexForTime(250, mockVizData); // Should find the frame at 200ms
|
|||
if (index !== 1) { |
|||
throw new Error(`Expected index 1 but got ${index}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should find the correct frame for a time that exactly matches a frame", () => { |
|||
const index = findRadarFrameIndexForTime(300, mockVizData); |
|||
if (index !== 2) { |
|||
throw new Error(`Expected index 2 but got ${index}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should return the last frame for a time after the end of the data", () => { |
|||
const index = findRadarFrameIndexForTime(500, mockVizData); |
|||
if (index !== 3) { |
|||
throw new Error(`Expected index 3 but got ${index}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should return the first frame for a time before the start of the data", () => { |
|||
const index = findRadarFrameIndexForTime(50, mockVizData); |
|||
if (index !== 0) { |
|||
throw new Error(`Expected index 0 but got ${index}`); |
|||
} |
|||
}); |
|||
|
|||
test("utils.js: should return -1 if radarFrames array is empty", () => { |
|||
const index = findRadarFrameIndexForTime(100, { radarFrames: [] }); |
|||
if (index !== -1) { |
|||
throw new Error(`Expected index -1 for empty data but got ${index}`); |
|||
} |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue