diff --git a/steps/index.html b/steps/index.html index 580e3f0..65ad670 100644 --- a/steps/index.html +++ b/steps/index.html @@ -242,366 +242,7 @@ - + \ No newline at end of file diff --git a/steps/src/main.js b/steps/src/main.js new file mode 100644 index 0000000..b41aac5 --- /dev/null +++ b/steps/src/main.js @@ -0,0 +1,552 @@ +// =========================================================================================================== +// REFACTOR PLAN: This monolithic script will be broken down into +// the following modules in the '/src' directory: +// +// - constants.js: Shared constants (VIDEO_FPS, RADAR_X_MAX) +// - utils.js: Pure helper functions (findRadarFrameIndexForTime) +// - state.js: Central application state management +// - dom.js: DOM element references and UI updaters +// - modal.js: Modal dialog logic +// - theme.js: Dark/Light mode theme switcher +// - db.js: IndexedDB caching logic +// - fileParsers.js: JSON and CAN log parsing logic +// - p5/radarSketch.js: The main p5.js radar visualization +// - p5/speedGraph.js: The p5.js speed graph visualization +// - sync.js: Playback and synchronization loop +// - main.js: The main application entry point that wires everything +// =========================================================================================================== + +// import animation loop from './src/sync.js'; + +import { animationLoop } from "./sync.js"; +// import radar sketch from './src/p5/radarSketch.js'; +import { radarSketch } from "./p5/radarSketch.js"; +// import speed graph sketch from './src/p5/speedGraphSketch.js'; +import { speedGraphSketch } from "./p5/speedGraphSketch.js"; +// import JSON parser, can log procesor from './src/fileParsers.js'; +import { processCanLog, parseVisualizationJson } from "./fileParsers.js"; +// import constants from './constants.js'; +import { + MAX_TRAJECTORY_LENGTH, + VIDEO_FPS, + RADAR_X_MIN, + RADAR_X_MAX, + RADAR_Y_MIN, + RADAR_Y_MAX, +} from "./constants.js"; +// import utils and helpers from './src/utils.js'; +import { + findRadarFrameIndexForTime, + findLastCanIndexBefore, + extractTimestampInfo, + parseTimestamp, +} from "./utils.js"; +// import state machine from './src/state.js'; +import { appState } from "./state.js"; +// import DOM elements and UI updaters from './src/dom.js'; +import { + //---DOM Elements---// + canvasContainer, + canvasPlaceholder, + videoPlayer, + videoPlaceholder, + loadJsonBtn, + loadVideoBtn, + loadCanBtn, + jsonFileInput, + videoFileInput, + canFileInput, + playPauseBtn, + stopBtn, + timelineSlider, + frameCounter, + offsetInput, + speedSlider, + speedDisplay, + featureToggles, + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleStationaryColor, + toggleVelocity, + toggleTracks, + toggleEgoSpeed, + toggleFrameNorm, + toggleDebugOverlay, + egoSpeedDisplay, + canSpeedDisplay, + debugOverlay, + snrMinInput, + snrMaxInput, + applySnrBtn, + autoOffsetIndicator, + clearCacheBtn, + speedGraphContainer, + speedGraphPlaceholder, + toggleCloseUp, + //---UI Updaters---// + updateFrame, + resetVisualization, + updateCanDisplay, + updateDebugOverlay, +} from "./dom.js"; +// import modal dialog logic from './src/modal.js'; +import { showModal } from "./modal.js"; +// import initialize theme from './src/theme.js'; +import { initializeTheme } from "./theme.js"; +// import caching logic from './src/db.js'; +import { initDB, saveFileToDB, loadFileFromDB } from "./db.js"; + +function setupVideoPlayer(fileURL) { + videoPlayer.src = fileURL; + videoPlayer.classList.remove("hidden"); + videoPlaceholder.classList.add("hidden"); + videoPlayer.playbackRate = parseFloat(speedSlider.value); +} +loadJsonBtn.addEventListener("click", () => jsonFileInput.click()); +loadVideoBtn.addEventListener("click", () => videoFileInput.click()); +loadCanBtn.addEventListener("click", () => canFileInput.click()); +clearCacheBtn.addEventListener("click", async () => { + const confirmed = await showModal("Clear all cached data and reload?", true); + if (confirmed) { + indexedDB.deleteDatabase("visualizerDB"); + localStorage.clear(); + window.location.reload(); + } +}); +jsonFileInput.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (!file) return; + appState.jsonFilename = file.name; + localStorage.setItem("jsonFilename", appState.jsonFilename); + calculateAndSetOffset(); // This function now correctly sets appState variables + + const reader = new FileReader(); + reader.onload = (e) => { + const jsonString = e.target.result; + saveFileToDB("json", jsonString); + + // 1. Give the raw ingredients to our new JSON "chef" + const result = parseVisualizationJson( + jsonString, + appState.radarStartTimeMs, + appState.videoStartDate + ); + + // 2. Check the result + if (result.error) { + showModal(result.error); + return; + } + + // 3. Update the application's central state with the prepared data + appState.vizData = result.data; + appState.globalMinSnr = result.minSnr; + appState.globalMaxSnr = result.maxSnr; + + // 4. Now, the "waiter" updates the UI + snrMinInput.value = appState.globalMinSnr.toFixed(1); + snrMaxInput.value = appState.globalMaxSnr.toFixed(1); + resetVisualization(); // This UI function is in dom.js + canvasPlaceholder.style.display = "none"; + featureToggles.classList.remove("hidden"); + + if (!appState.p5_instance) { + appState.p5_instance = new p5(radarSketch); + } + + if (appState.speedGraphInstance) { + appState.speedGraphInstance.setData( + appState.canData, + appState.vizData, + videoPlayer.duration + ); + } else { + // Redraw p5 instance with new data + appState.p5_instance.drawSnrLegendToBuffer( + appState.globalMinSnr, + appState.globalMaxSnr + ); + appState.p5_instance.redraw(); + } + }; + reader.readAsText(file); +}); +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); + + // This is the key moment: we now have a video start date. + calculateAndSetOffset(); + + // Now, check if we have pending data that needs this date. + if (appState.rawCanLogText) { + const result = processCanLog( + appState.rawCanLogText, + appState.videoStartDate + ); + if (!result.error) { + appState.canData = result.data; + appState.rawCanLogText = null; + } + } + + // NEW: Re-process vizData if it was loaded before the video. + 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(); // Reset UI to reflect new timestamps + } + + const fileURL = URL.createObjectURL(file); + setupVideoPlayer(fileURL); + + // When the video is ready, update the speed graph + videoPlayer.onloadedmetadata = () => { + if (appState.speedGraphInstance) { + appState.speedGraphInstance.setData( + appState.canData, + appState.vizData, + videoPlayer.duration + ); + } + }; +}); + +canFileInput.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (!file) return; + appState.canLogFilename = file.name; + localStorage.setItem("canLogFilename", appState.canLogFilename); + + const reader = new FileReader(); + reader.onload = (e) => { + const logContent = e.target.result; + saveFileToDB("canLogText", logContent); + + // 1. Give the raw ingredients to the chef (our parser) + const result = processCanLog(logContent, appState.videoStartDate); + + // 2. Check what the chef gave back + if (result.error) { + // If there was an error, show it and save the raw text for later. + showModal(result.error); + appState.rawCanLogText = result.rawCanLogText; + return; + } + + // 3. If successful, update the application's central state + appState.canData = result.data; + appState.rawCanLogText = null; + + // 4. Now, the waiter updates the UI based on the new state + if (appState.canData.length > 0 || appState.vizData) { + speedGraphPlaceholder.classList.add("hidden"); + if (!appState.speedGraphInstance) { + // We need to pass the speedGraphSketch function definition here + appState.speedGraphInstance = new p5(speedGraphSketch); + } + if (videoPlayer.duration) { + appState.speedGraphInstance.setData( + appState.canData, + appState.vizData, + videoPlayer.duration + ); + } + } else { + showModal(`No CAN messages with ID 0x30F found.`); + } + }; + reader.readAsText(file); +}); +offsetInput.addEventListener("input", () => { + autoOffsetIndicator.classList.add("hidden"); + localStorage.setItem("visualizerOffset", offsetInput.value); +}); +applySnrBtn.addEventListener("click", () => { + const newMin = parseFloat(snrMinInput.value), + newMax = parseFloat(snrMaxInput.value); + if (isNaN(newMin) || isNaN(newMax) || newMin >= newMax) { + showModal("Invalid SNR range."); + return; + } + appState.globalMinSnr = newMin; + appState.globalMaxSnr = newMax; + toggleFrameNorm.checked = false; + if (appState.p5_instance) { + appState.p5_instance.drawSnrLegendToBuffer( + appState.globalMinSnr, + appState.globalMaxSnr + ); + appState.p5_instance.redraw(); + } +}); +playPauseBtn.addEventListener("click", () => { + if (!appState.vizData && !videoPlayer.src) return; + appState.isPlaying = !appState.isPlaying; + playPauseBtn.textContent = appState.isPlaying ? "Pause" : "Play"; + if (appState.isPlaying) { + if (videoPlayer.src && videoPlayer.readyState > 1) { + appState.masterClockStart = performance.now(); + appState.mediaTimeStart = videoPlayer.currentTime; + appState.lastSyncTime = appState.masterClockStart; + videoPlayer.play(); + } + requestAnimationFrame(animationLoop); + } else { + if (videoPlayer.src) videoPlayer.pause(); + } +}); +stopBtn.addEventListener("click", () => { + videoPlayer.pause(); + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; + if (appState.vizData) { + updateFrame(0, true); + } else if (videoPlayer.src) { + videoPlayer.currentTime = 0; + } + if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); +}); +timelineSlider.addEventListener("input", (event) => { + if (!appState.vizData) return; + if (appState.isPlaying) { + videoPlayer.pause(); + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; + } + const frame = parseInt(event.target.value, 10); + updateFrame(frame, true); + appState.mediaTimeStart = videoPlayer.currentTime; + appState.masterClockStart = performance.now(); +}); +speedSlider.addEventListener("input", (event) => { + const speed = parseFloat(event.target.value); + videoPlayer.playbackRate = speed; + speedDisplay.textContent = `${speed.toFixed(1)}x`; +}); + +// ADD THE NEW TOGGLE TO THE ARRAY +const colorToggles = [ + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleStationaryColor, +]; +colorToggles.forEach((t) => { + t.addEventListener("change", (e) => { + if (e.target.checked) { + colorToggles.forEach((o) => { + if (o !== e.target) o.checked = false; + }); + } + if (appState.p5_instance) appState.p5_instance.redraw(); + }); +}); + +[ + toggleVelocity, + toggleEgoSpeed, + toggleFrameNorm, + toggleTracks, + toggleDebugOverlay, +].forEach((t) => { + t.addEventListener("change", () => { + if (appState.p5_instance) { + if (t === toggleFrameNorm && !toggleFrameNorm.checked) + appState.p5_instance.drawSnrLegendToBuffer( + appState.globalMinSnr, + appState.globalMaxSnr + ); + appState.p5_instance.redraw(); + } + if (t === toggleDebugOverlay) updateDebugOverlay(videoPlayer.currentTime); + }); +}); + +toggleCloseUp.addEventListener("change", () => { + appState.isCloseUpMode = toggleCloseUp.checked; + if (appState.p5_instance) { + if (appState.isCloseUpMode) { + if (appState.isPlaying) { + playPauseBtn.click(); + } + appState.p5_instance.loop(); + } else { + appState.p5_instance.noLoop(); + appState.p5_instance.redraw(); + } + } +}); + +videoPlayer.addEventListener("ended", () => { + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; +}); +document.addEventListener("keydown", (event) => { + if ( + !appState.vizData || + ["ArrowRight", "ArrowLeft"].indexOf(event.key) === -1 + ) + return; + event.preventDefault(); + if (appState.isPlaying) { + appState.isPlaying = false; + playPauseBtn.textContent = "Play"; + videoPlayer.pause(); + } + let newFrame = appState.currentFrame; + if (event.key === "ArrowRight") + newFrame = Math.min( + appState.vizData.radarFrames.length - 1, + appState.currentFrame + 1 + ); + else if (event.key === "ArrowLeft") + newFrame = Math.max(0, appState.currentFrame - 1); + if (newFrame !== appState.currentFrame) { + updateFrame(newFrame, true); + appState.mediaTimeStart = videoPlayer.currentTime; + appState.masterClockStart = performance.now(); + } +}); +function calculateAndSetOffset() { + const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename); + const videoTimestampInfo = extractTimestampInfo(appState.videoFilename); + if (videoTimestampInfo) { + appState.videoStartDate = parseTimestamp( + videoTimestampInfo.timestampStr, + videoTimestampInfo.format + ); + if (appState.videoStartDate) + console.log( + `Video start date set to: ${appState.videoStartDate.toISOString()}` + ); + } + if (jsonTimestampInfo) { + const jsonDate = parseTimestamp( + jsonTimestampInfo.timestampStr, + jsonTimestampInfo.format + ); + if (jsonDate) { + appState.radarStartTimeMs = jsonDate.getTime(); + console.log(`Radar start date set to: ${jsonDate.toISOString()}`); + if (appState.videoStartDate) { + const offset = + appState.radarStartTimeMs - appState.videoStartDate.getTime(); + offsetInput.value = offset; + localStorage.setItem("visualizerOffset", offset); + autoOffsetIndicator.classList.remove("hidden"); + console.log(`Auto-calculated offset: ${offset} ms`); + } + } + } +} + +// --- Application Initialization --- +document.addEventListener("DOMContentLoaded", () => { + initializeTheme(); + console.log("DEBUG: DOMContentLoaded fired. Starting session load."); + + initDB(() => { + console.log("DEBUG: Database initialized."); + const savedOffset = localStorage.getItem("visualizerOffset"); + if (savedOffset !== null) { + offsetInput.value = savedOffset; + } + appState.videoFilename = localStorage.getItem("videoFilename"); + appState.jsonFilename = localStorage.getItem("jsonFilename"); + appState.canLogFilename = localStorage.getItem("canLogFilename"); + + // This is important: it sets videoStartDate if a video filename is cached + calculateAndSetOffset(); + + const videoPromise = new Promise((resolve) => + loadFileFromDB("video", resolve) + ); + const jsonPromise = new Promise((resolve) => + loadFileFromDB("json", resolve) + ); + const canLogPromise = new Promise((resolve) => + loadFileFromDB("canLogText", resolve) + ); + + Promise.all([videoPromise, jsonPromise, canLogPromise]) + .then(([videoBlob, jsonString, canLogText]) => { + console.log("DEBUG: All data fetched from IndexedDB."); + + const processAllData = () => { + console.log("DEBUG: Processing all loaded data."); + + // 1. Process JSON (only if we have a video date) + if (jsonString && appState.videoStartDate) { + const result = parseVisualizationJson( + jsonString, + appState.radarStartTimeMs, + appState.videoStartDate + ); + if (!result.error) { + appState.vizData = result.data; + appState.globalMinSnr = result.minSnr; + appState.globalMaxSnr = result.maxSnr; + snrMinInput.value = appState.globalMinSnr.toFixed(1); + snrMaxInput.value = appState.globalMaxSnr.toFixed(1); + } else { + showModal(result.error); + } + } + + // 2. Process CAN log (only if we have a video date) + if (canLogText && appState.videoStartDate) { + const result = processCanLog(canLogText, appState.videoStartDate); + if (!result.error) { + appState.canData = result.data; + } + } + + // 3. Update all UI elements now that data is processed + if (appState.vizData) { + resetVisualization(); + canvasPlaceholder.style.display = "none"; + featureToggles.classList.remove("hidden"); + if (!appState.p5_instance) { + appState.p5_instance = new p5(radarSketch); + } + } + if (appState.canData.length > 0 || appState.vizData) { + speedGraphPlaceholder.classList.add("hidden"); + if (!appState.speedGraphInstance) { + appState.speedGraphInstance = new p5(speedGraphSketch); + } + appState.speedGraphInstance.setData( + appState.canData, + appState.vizData, + videoPlayer.duration + ); + } + }; + + // This is the main controller + // --- THIS IS THE CORRECTED CODE --- + if (videoBlob) { + const fileURL = URL.createObjectURL(videoBlob); + setupVideoPlayer(fileURL); + // This ensures we ONLY process data once the video's duration is known. + videoPlayer.onloadedmetadata = processAllData; + } else { + // If there's no video, we can go ahead and process the other data. + processAllData(); + } + }) + .catch((error) => { + console.error("DEBUG: Error during Promise.all data loading:", error); + }); + }); +});