Visualizer work
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

506 lines
17 KiB

import { appState } from "./state.js";
import { debugFlags } from "./debug.js";
import { saveFileWithMetadata, loadManualOffset, deleteManualOffset } 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,
resetUIForNewLoad,
} from "./dom.js";
import { forceResyncWithOffset } from "./sync.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, fromCache = false) {
// 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, fromCache);
}
async function processFilePipeline(jsonFile, videoFile, fromCache) {
// 0. Reset the UI to a clean state before processing anything.
// Pass 'true' if a new video is present, 'false' if we should try to keep the old one.
const isNewVideo = !!videoFile;
resetUIForNewLoad(isNewVideo);
// 1. Show the unified loading modal.
showLoadingModal("Processing files...");
const cachePromises = [];
// --- PART A: Setup Filenames & Cache (Moved Up) ---
if (jsonFile) {
appState.jsonFilename = jsonFile.name;
localStorage.setItem("jsonFilename", appState.jsonFilename);
if (!fromCache) {
const savePromise = saveFileWithMetadata("json", jsonFile).catch((e) =>
console.warn(`Non-blocking cache save failed for JSON:`, e)
);
if (debugFlags.CACHE_BLOCKING) {
await savePromise;
} else {
cachePromises.push(savePromise);
}
}
}
if (videoFile) {
appState.videoFilename = videoFile.name;
localStorage.setItem("videoFilename", appState.videoFilename);
// CRITICAL FIX: Reset the videoMissing flag when a new video is being loaded.
appState.videoMissing = false;
if (!fromCache) {
const savePromise = saveFileWithMetadata("video", videoFile).catch((e) =>
console.warn(`Non-blocking cache save failed for Video:`, e)
);
cachePromises.push(savePromise);
}
}
// --- PART B: Calculate Offset (Moved Up) ---
// Critical: This must run BEFORE JSON parsing so valid start times are available.
await calculateAndSetOffset();
// --- PART C: Handle JSON Parsing ---
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();
// 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 });
});
// Post-process JSON with correct dates
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 D: Precompute Sync ---
// Bake the offset into the data (needs vizData from Part C and offset from Part B)
if (appState.vizData) {
precomputeRadarVideoSync(appState.vizData, appState.offset);
}
// --- PART E: Load Video (if new) ---
if (videoFile) {
const videoLoaded = await loadVideo(videoFile);
if (!videoLoaded) {
appState.videoMissing = true;
}
}
// --- PART F: Finalize UI ---
finalizeSetup();
// Hide modal only if the video didn't fail. If it failed, the video
// loader has already handled showing an error/choice modal.
if (!appState.videoMissing) {
updateLoadingModal(100, "Complete!");
setTimeout(hideModal, 300);
}
// Log the results of the non-blocking cache operations once they complete.
if (cachePromises.length > 0) {
Promise.allSettled(cachePromises).then((results) => {
console.log("Non-blocking cache operations finished:", results);
});
}
}
// Encapsulates the specific logic for loading a video file into the player
let retries = 0;
function loadVideo(file, isRetry = false) {
return new Promise(async (resolve) => {
let metadataLoaded = false;
let loadTimeout;
// Before creating a new URL, revoke the old one if it exists.
if (!isRetry && appState.videoObjectUrl) {
URL.revokeObjectURL(appState.videoObjectUrl);
appState.videoObjectUrl = null;
}
const fileURL = isRetry ? videoPlayer.src : URL.createObjectURL(file);
const cleanup = () => {
clearTimeout(loadTimeout);
videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded);
videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
videoPlayer.removeEventListener("error", onError);
};
const onMetadataLoaded = () => {
metadataLoaded = true;
updateLoadingModal(95, "Finalizing visualization...");
};
const onCanPlayThrough = () => {
cleanup();
resolve(true);
};
const handleTimeout = async () => {
if (metadataLoaded) {
console.warn(
"Video 'canplaythrough' event timed out, but 'loadedmetadata' fired. Proceeding with playback."
);
appState.videoReadyByFallback = true;
cleanup();
resolve(true);
} else {
// Neither event fired, video is likely broken.
await hideModal(); // Hide the loading modal first and wait for it to finish.
const choice = await showModal(
"Video is taking too long to load. It might be corrupted.",
true, // isConfirm
{ ok: "Retry", cancel: "Continue without Video" }
);
if (choice) { // Retry
if (retries < debugFlags.VIDEO_LOAD_RETRIES) {
retries++;
console.log(`Retrying video load... (Attempt ${retries})`);
showLoadingModal(`Retrying video load...`);
videoPlayer.load(); // Tell the video element to re-fetch
resolve(loadVideo(file, true)); // Recurse
} else {
await showModal("Video load failed after multiple retries.");
resolve(false); // Failed to load
}
} else { // Continue without video
console.warn("User opted to continue without video.");
cleanup();
// Revoke URL to free memory if we're giving up on it
if (videoPlayer.src.startsWith('blob:')) {
URL.revokeObjectURL(videoPlayer.src);
appState.videoObjectUrl = null; // Clear from state
}
videoPlayer.src = "";
videoPlayer.classList.add("hidden");
videoPlaceholder.classList.remove("hidden");
resolve(false); // Signal that video is not loaded
}
}
};
const onError = async (e) => {
console.error("Video loading error:", e);
cleanup();
await hideModal(); // Await is CRITICAL to prevent a race condition with the next modal.
showModal("Error loading video file. It may be an unsupported format or corrupted.");
resolve(false);
};
// Attach listeners
videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { once: true });
videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { once: true });
videoPlayer.addEventListener("error", onError, { once: true });
// Start the timeout
loadTimeout = setTimeout(handleTimeout, debugFlags.VIDEO_LOAD_TIMEOUT);
// Apply source only if it's not a retry
if (!isRetry) {
retries = 0; // Reset retry counter for new files
setupVideoPlayer(fileURL);
}
});
}
function finalizeSetup() {
// CRITICAL FIX: Always reset the visualization state before redrawing.
// This pauses the video and resets the timeline, ensuring a clean slate for the new data.
resetVisualization();
// 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 there's no viz data (e.g., video-only load), hide the canvas
canvasPlaceholder.style.display = ""; // Show placeholder
featureToggles.classList.add("hidden");
if (appState.p5_instance) {
appState.p5_instance.noLoop();
}
// 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 up to date.
// CRITICAL: Do NOT call .loop(). The app uses a custom animationLoop in sync.js.
appState.p5_instance.redraw();
}
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);
}
// Update speed graph with new data + video duration
// Determine the most appropriate duration for the graph's X-axis.
let finalDuration = 0;
let jsonDuration = 0;
// 1. Calculate duration from the JSON data itself as a reliable baseline.
if (appState.vizData.radarFrames && appState.vizData.radarFrames.length > 0) {
const lastFrame = appState.vizData.radarFrames[appState.vizData.radarFrames.length - 1];
jsonDuration = lastFrame.timestamp / 1000.0;
}
// 2. Get video duration, normalizing invalid values.
let videoDuration = appState.videoMissing ? 0 : (videoPlayer.duration || 0);
if (!videoDuration || isNaN(videoDuration) || videoDuration <= 0) {
videoDuration = 0;
}
// 3. Set the graph's duration. Prioritize JSON duration, but clip it
// to the video's duration if a video is present and shorter.
finalDuration = jsonDuration;
if (videoDuration > 0 && jsonDuration > videoDuration) {
finalDuration = jsonDuration;
}
appState.speedGraphInstance.setData(appState.vizData, finalDuration);
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);
// Store the new object URL if it's a blob
if (fileURL.startsWith("blob:")) {
appState.videoObjectUrl = fileURL;
}
}
async 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
);
}
// 1. Try to load a manually saved offset for this specific file pair.
// We use the JSON filename as the primary key, but ideally, it should be a combo.
// For now, sticking to the user request: "if the user uploads a similarly named file".
// We'll use the JSON filename as the key.
const savedOffset = await loadManualOffset(appState.jsonFilename);
if (savedOffset !== null) {
console.log(`Applying saved manual offset: ${savedOffset}ms`);
appState.offset = savedOffset;
if (jsonDate) {
appState.radarStartTimeMs = jsonDate.getTime();
}
// Update UI
offsetInput.value = appState.offset;
// Show "Manual" indicator
autoOffsetIndicator.textContent = "Manual";
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-gray-500"; // Gray for manual
autoOffsetIndicator.classList.remove("hidden");
localStorage.setItem("visualizerOffset", appState.offset);
return; // Exit early, skipping auto-calc
}
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;
// Show "Default" or "Out of Range" indicator
autoOffsetIndicator.textContent = isNaN(offset) ? "Default" : "Out of Range";
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-yellow-600"; // Dark Yellow
autoOffsetIndicator.classList.remove("hidden");
} else {
calculatedOffset = offset;
// Show "Auto" indicator
autoOffsetIndicator.textContent = "Auto";
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-green-500"; // Green
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();
// No specific indicator needed for JSON-only default 0, or could show "Default"
autoOffsetIndicator.classList.add("hidden");
}
appState.offset = calculatedOffset;
offsetInput.value = appState.offset;
localStorage.setItem("visualizerOffset", appState.offset);
}
/**
* Re-calculates and applies the automatic offset based on filenames.
* This function is triggered by user actions like clicking the 'Manual' indicator
* or using a keyboard shortcut to revert a manual offset.
*/
export function revertToAutoOffset() {
// 1. Calculate the automatic offset.
const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
let calculatedOffset = 0;
let indicatorText = "Default";
let indicatorClass = "text-xs font-bold ml-2 text-yellow-600"; // Default to yellow
if (jsonTimestampInfo && videoTimestampInfo) {
const jsonDate = parseTimestamp(jsonTimestampInfo.timestampStr, jsonTimestampInfo.format);
const videoDate = parseTimestamp(videoTimestampInfo.timestampStr, videoTimestampInfo.format);
if (jsonDate && videoDate) {
const offset = jsonDate.getTime() - videoDate.getTime();
if (isNaN(offset) || Math.abs(offset) > 30000) {
calculatedOffset = 0;
indicatorText = "Out of Range";
} else {
calculatedOffset = offset;
indicatorText = "Auto";
indicatorClass = "text-xs font-bold ml-2 text-green-500";
}
}
}
// 2. Update the input box with the new value.
offsetInput.value = calculatedOffset;
// 3. Delete any saved manual offset so future loads default to "Auto" logic.
if (appState.jsonFilename) {
deleteManualOffset(appState.jsonFilename);
}
// 4. Call the resync function with saveToDb = false.
forceResyncWithOffset(false);
// 5. After resyncing, set the correct indicator text and style.
autoOffsetIndicator.textContent = indicatorText;
autoOffsetIndicator.className = indicatorClass;
autoOffsetIndicator.classList.remove("hidden");
}