6 changed files with 638 additions and 362 deletions
-
102steps/src/db.js
-
301steps/src/fileLoader.js
-
18steps/src/fileParsers.js
-
316steps/src/main.js
-
172steps/tests/fileLoader.test.js
-
87steps/tests/test-runner.html
@ -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); |
||||
|
} |
||||
@ -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}'`); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
})(); |
||||
|
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue