-
+
-
-
-
- - Load JSON data to start visualization -
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
- + Load JSON data to start visualization +
-
+
-
-
- - Load a video file -
-
-
-
-
+ - Load CAN log to see speed graph -
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
+
+
+ + Load a video file +
+ +
+
+ + Load CAN log to see speed graph +
+
-
-
-
-
"); + debugOverlay.innerHTML = content.join("
"); // Update debug overlay content. } diff --git a/steps/src/drawUtils.js b/steps/src/drawUtils.js index 04cbb9b..4ddabd5 100644 --- a/steps/src/drawUtils.js +++ b/steps/src/drawUtils.js @@ -1,38 +1,44 @@ import { - RADAR_X_MAX, - RADAR_X_MIN, - RADAR_Y_MAX, - RADAR_Y_MIN, - MAX_TRAJECTORY_LENGTH -} from './constants.js'; + RADAR_X_MAX, + RADAR_X_MIN, + RADAR_Y_MAX, + RADAR_Y_MIN, + MAX_TRAJECTORY_LENGTH, +} from "./constants.js"; +import { appState } from "./state.js"; import { - appState -} from './state.js'; -import { - toggleSnrColor, - toggleClusterColor, - toggleInlierColor, - toggleFrameNorm, - toggleVelocity, - toggleStationaryColor -} from './dom.js'; - -// Color definitions moved from the sketch + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleFrameNorm, + toggleVelocity, + toggleStationaryColor, +} from "./dom.js"; + +// Defines a set of SNR (Signal-to-Noise Ratio) colors. export const snrColors = (p) => ({ - c1: p.color(0, 0, 255), - c2: p.color(0, 255, 255), - c3: p.color(0, 255, 0), - c4: p.color(255, 255, 0), - c5: p.color(255, 0, 0) + c1: p.color(0, 0, 255), // Blue + c2: p.color(0, 255, 255), // Cyan + c3: p.color(0, 255, 0), // Green + c4: p.color(255, 255, 0), // Yellow + c5: p.color(255, 0, 0), // Red }); +// Defines a palette of colors for different clusters. export const clusterColors = (p) => [ - p.color(230, 25, 75), p.color(60, 180, 75), p.color(0, 130, 200), - p.color(245, 130, 48), p.color(145, 30, 180), p.color(70, 240, 240), - p.color(240, 50, 230), p.color(210, 245, 60), p.color(128, 0, 0), - p.color(0, 128, 128) + p.color(230, 25, 75), // Red + p.color(60, 180, 75), // Green + p.color(0, 130, 200), // Blue + p.color(245, 130, 48), // Orange + p.color(145, 30, 180), // Purple + p.color(70, 240, 240), // Cyan + p.color(240, 50, 230), // Magenta + p.color(210, 245, 60), // Lime Green + p.color(128, 0, 0), // Maroon + p.color(0, 128, 128), // Teal ]; +// Defines colors for stationary and moving objects. export const stationaryColor = (p) => p.color(218, 165, 32); // Goldenrod export const movingColor = (p) => p.color(255, 0, 255); // Magenta @@ -42,20 +48,38 @@ export const movingColor = (p) => p.color(255, 0, 255); // Magenta * @param {object} plotScales - The calculated scales for plotting. */ export function drawStaticRegionsToBuffer(p, b, plotScales) { - b.clear(); - b.push(); - b.translate(b.width / 2, b.height * 0.95); - b.scale(1, -1); - b.stroke(100, 100, 100, 150); - b.strokeWeight(1); - b.drawingContext.setLineDash([8, 8]); - const a1 = p.radians(30), - a2 = p.radians(150); - const len = 70; - b.line(0, 0, len * p.cos(a1) * plotScales.plotScaleX, len * p.sin(a1) * plotScales.plotScaleY); - b.line(0, 0, len * p.cos(a2) * plotScales.plotScaleX, len * p.sin(a2) * plotScales.plotScaleY); - b.drawingContext.setLineDash([]); - b.pop(); + b.clear(); + b.push(); + // Translate to the bottom center of the buffer. + b.translate(b.width / 2, b.height * 0.95); + // Flip the Y-axis to match radar coordinates (Y increases upwards). + b.scale(1, -1); + // Set stroke properties for the static region lines. + b.stroke(100, 100, 100, 150); + b.strokeWeight(1); + // Set dashed line pattern. + b.drawingContext.setLineDash([8, 8]); + // Define angles for the radar beams. + const a1 = p.radians(30), + a2 = p.radians(150); + const len = 70; + // Draw the first static region line. + b.line( + 0, + 0, + len * p.cos(a1) * plotScales.plotScaleX, + len * p.sin(a1) * plotScales.plotScaleY + ); + // Draw the second static region line. + b.line( + 0, + 0, + len * p.cos(a2) * plotScales.plotScaleX, + len * p.sin(a2) * plotScales.plotScaleY + ); + // Reset line dash pattern. + b.drawingContext.setLineDash([]); + b.pop(); } /** @@ -64,40 +88,73 @@ export function drawStaticRegionsToBuffer(p, b, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawAxes(p, plotScales) { + p.push(); + // Determine axis and text colors based on the current theme (dark/light mode). + const axisColor = document.documentElement.classList.contains("dark") + ? p.color(100) + : p.color(220); + const mainAxisColor = document.documentElement.classList.contains("dark") + ? p.color(150) + : p.color(180); + const textColor = document.documentElement.classList.contains("dark") + ? p.color(200) + : p.color(150); + // Draw horizontal grid lines. + p.stroke(axisColor); + p.strokeWeight(1); + for (let y = 5; y <= RADAR_Y_MAX; y += 5) + p.line( + RADAR_X_MIN * plotScales.plotScaleX, + y * plotScales.plotScaleY, + RADAR_X_MAX * plotScales.plotScaleX, + y * plotScales.plotScaleY + ); + // Draw vertical grid lines. + for (let x = -15; x <= 15; x += 5) { + if (x === 0) continue; + p.line( + x * plotScales.plotScaleX, + RADAR_Y_MIN * plotScales.plotScaleY, + x * plotScales.plotScaleX, + RADAR_Y_MAX * plotScales.plotScaleY + ); + } + p.stroke(mainAxisColor); + p.line( + RADAR_X_MIN * plotScales.plotScaleX, + 0, + RADAR_X_MAX * plotScales.plotScaleX, + 0 + ); + p.line( + 0, + RADAR_Y_MIN * plotScales.plotScaleY, + 0, + RADAR_Y_MAX * plotScales.plotScaleY + ); + // Draw Y-axis labels. + p.fill(textColor); + p.noStroke(); + p.textSize(10); + for (let y = 5; y <= RADAR_Y_MAX; y += 5) { p.push(); - const axisColor = document.documentElement.classList.contains('dark') ? p.color(100) : p.color(220); - const mainAxisColor = document.documentElement.classList.contains('dark') ? p.color(150) : p.color(180); - const textColor = document.documentElement.classList.contains('dark') ? p.color(200) : p.color(150); - p.stroke(axisColor); - p.strokeWeight(1); - for (let y = 5; y <= RADAR_Y_MAX; y += 5) p.line(RADAR_X_MIN * plotScales.plotScaleX, y * plotScales.plotScaleY, RADAR_X_MAX * plotScales.plotScaleX, y * plotScales.plotScaleY); - for (let x = -15; x <= 15; x += 5) { - if (x === 0) continue; - p.line(x * plotScales.plotScaleX, RADAR_Y_MIN * plotScales.plotScaleY, x * plotScales.plotScaleX, RADAR_Y_MAX * plotScales.plotScaleY); - } - p.stroke(mainAxisColor); - p.line(RADAR_X_MIN * plotScales.plotScaleX, 0, RADAR_X_MAX * plotScales.plotScaleX, 0); - p.line(0, RADAR_Y_MIN * plotScales.plotScaleY, 0, RADAR_Y_MAX * plotScales.plotScaleY); - p.fill(textColor); - p.noStroke(); - p.textSize(10); - for (let y = 5; y <= RADAR_Y_MAX; y += 5) { - p.push(); - p.translate(5, y * plotScales.plotScaleY); - p.scale(1, -1); - p.text(y, 0, 4); - p.pop(); - } - for (let x = -15; x <= 15; x += 5) { - if (x === 0) continue; - p.push(); - p.translate(x * plotScales.plotScaleX, -10); - p.scale(1, -1); - p.textAlign(p.CENTER); - p.text(x, 0, 0); - p.pop(); - } + p.translate(5, y * plotScales.plotScaleY); + // Flip text vertically to align with flipped Y-axis. + p.scale(1, -1); + p.text(y, 0, 4); p.pop(); + } + // Draw X-axis labels. + for (let x = -15; x <= 15; x += 5) { + if (x === 0) continue; + p.push(); + p.translate(x * plotScales.plotScaleX, -10); + p.scale(1, -1); + p.textAlign(p.CENTER); + p.text(x, 0, 0); + p.pop(); + } + p.pop(); } /** @@ -107,50 +164,88 @@ export function drawAxes(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawPointCloud(p, points, plotScales) { - p.strokeWeight(4); - const useSnr = toggleSnrColor.checked; - const useCluster = toggleClusterColor.checked; - const useInlier = toggleInlierColor.checked; - const useFrameNorm = toggleFrameNorm.checked; - let minSnr = appState.globalMinSnr, - maxSnr = appState.globalMaxSnr; - - if (useSnr && useFrameNorm && points.length > 0) { - const snrVals = points.map(p => p.snr).filter(snr => snr !== null); - if (snrVals.length > 1) { - minSnr = Math.min(...snrVals); - maxSnr = Math.max(...snrVals); - } else if (snrVals.length === 1) { - minSnr = snrVals[0] - 1; - maxSnr = snrVals[0] + 1; - } + // Set stroke weight for points. + p.strokeWeight(4); + // Get state of various toggles from the DOM. + const useSnr = toggleSnrColor.checked; + const useCluster = toggleClusterColor.checked; + const useInlier = toggleInlierColor.checked; + const useFrameNorm = toggleFrameNorm.checked; + let minSnr = appState.globalMinSnr, // Initialize with global SNR range. + maxSnr = appState.globalMaxSnr; + + if (useSnr && useFrameNorm && points.length > 0) { + const snrVals = points.map((p) => p.snr).filter((snr) => snr !== null); + if (snrVals.length > 1) { + minSnr = Math.min(...snrVals); + maxSnr = Math.max(...snrVals); + } else if (snrVals.length === 1) { + minSnr = snrVals[0] - 1; + maxSnr = snrVals[0] + 1; } - // This check is important. The p5_instance might not be fully initialized yet. - if (useSnr && p.drawSnrLegendToBuffer) p.drawSnrLegendToBuffer(minSnr, maxSnr); - - const localClusterColors = clusterColors(p); - const localSnrColors = snrColors(p); - - for (const pt of points) { - if (pt && pt.x !== null && pt.y !== null) { - if (useCluster && pt.clusterNumber !== null) { - p.stroke(pt.clusterNumber > 0 ? localClusterColors[(pt.clusterNumber - 1) % localClusterColors.length] : 128); - } else if (useInlier) { - p.stroke(pt.isOutlier === false ? p.color(0, 255, 0) : pt.isOutlier === true ? p.color(255, 0, 0) : 128); - } else if (useSnr && pt.snr !== null) { - const amt = p.map(pt.snr, minSnr, maxSnr, 0, 1, true); - let c; - if (amt < 0.25) c = p.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); - else if (amt < 0.5) c = p.lerpColor(localSnrColors.c2, localSnrColors.c3, (amt - 0.25) / 0.25); - else if (amt < 0.75) c = p.lerpColor(localSnrColors.c3, localSnrColors.c4, (amt - 0.5) / 0.25); - else c = p.lerpColor(localSnrColors.c4, localSnrColors.c5, (amt - 0.75) / 0.25); - p.stroke(c); - } else { - p.stroke(0, 150, 255); - } - p.point(pt.x * plotScales.plotScaleX, pt.y * plotScales.plotScaleY); - } + } + // Draw SNR legend if enabled and p5 instance is ready. + if (useSnr && p.drawSnrLegendToBuffer) + p.drawSnrLegendToBuffer(minSnr, maxSnr); + + // Get local color instances for cluster and SNR. + const localClusterColors = clusterColors(p); + const localSnrColors = snrColors(p); + + // Iterate through each point in the point cloud. + for (const pt of points) { + if (pt && pt.x !== null && pt.y !== null) { + // Apply cluster coloring if enabled. + if (useCluster && pt.clusterNumber !== null) { + p.stroke( + pt.clusterNumber > 0 + ? localClusterColors[ + (pt.clusterNumber - 1) % localClusterColors.length + ] + : 128 + // Default to gray if cluster number is 0 or invalid. + ); + } else if (useInlier) { + p.stroke( + pt.isOutlier === false + ? p.color(0, 255, 0) + : pt.isOutlier === true + ? p.color(255, 0, 0) + : 128 + // Default to gray if inlier status is unknown. + ); + } else if (useSnr && pt.snr !== null) { + const amt = p.map(pt.snr, minSnr, maxSnr, 0, 1, true); + let c; + if (amt < 0.25) + c = p.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); + else if (amt < 0.5) + c = p.lerpColor( + localSnrColors.c2, + localSnrColors.c3, + (amt - 0.25) / 0.25 + ); + else if (amt < 0.75) + c = p.lerpColor( + localSnrColors.c3, + localSnrColors.c4, + (amt - 0.5) / 0.25 + ); + else + c = p.lerpColor( + localSnrColors.c4, + localSnrColors.c5, + (amt - 0.75) / 0.25 + // Interpolate color based on SNR value. + ); + p.stroke(c); + // Default point color if no specific coloring is applied. + } else { + p.stroke(0, 150, 255); + } + p.point(pt.x * plotScales.plotScaleX, pt.y * plotScales.plotScaleY); } + } } /** @@ -159,37 +254,62 @@ export function drawPointCloud(p, points, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawTrajectories(p, plotScales) { - for (const track of appState.vizData.tracks) { - const logs = track.historyLog.filter(log => log.frameIdx <= appState.currentFrame + 1); - if (logs.length < 2) continue; - - const lastLog = logs[logs.length - 1]; - if (appState.currentFrame + 1 - lastLog.frameIdx > MAX_TRAJECTORY_LENGTH) continue; - - const isCurrentlyStationary = lastLog.isStationary; - let maxLen = isCurrentlyStationary ? Math.floor(MAX_TRAJECTORY_LENGTH / 4) : MAX_TRAJECTORY_LENGTH; - - let trajPts = logs.filter(log => log.correctedPosition && log.correctedPosition[0] !== null).map(log => log.correctedPosition); - if (trajPts.length > maxLen) { - trajPts = trajPts.slice(trajPts.length - maxLen); - } - - p.push(); - p.noFill(); - if (isCurrentlyStationary) { - p.stroke(34, 139, 34, 220); // Forest green - p.strokeWeight(1); - p.drawingContext.setLineDash([3, 3]); - } else { - p.stroke(document.documentElement.classList.contains('dark') ? p.color(10, 170, 255, 250) : p.color(0, 50, 255, 250)); - p.strokeWeight(1.5); - } - p.beginShape(); - for (const pos of trajPts) p.vertex(pos[0] * plotScales.plotScaleX, pos[1] * plotScales.plotScaleY); - p.endShape(); - p.drawingContext.setLineDash([]); - p.pop(); + // Iterate through each tracked object. + for (const track of appState.vizData.tracks) { + // Filter history logs to include only frames up to the current one. + const logs = track.historyLog.filter( + (log) => log.frameIdx <= appState.currentFrame + 1 + ); + // Skip if there are not enough points to draw a trajectory. + if (logs.length < 2) continue; + + // Get the last log entry. + const lastLog = logs[logs.length - 1]; + // Skip if the trajectory is too old. + if (appState.currentFrame + 1 - lastLog.frameIdx > MAX_TRAJECTORY_LENGTH) + continue; + + // Adjust trajectory length based on whether the object is stationary. + const isCurrentlyStationary = lastLog.isStationary; + let maxLen = isCurrentlyStationary + ? Math.floor(MAX_TRAJECTORY_LENGTH / 4) + : MAX_TRAJECTORY_LENGTH; + + // Filter and map corrected positions for the trajectory. + let trajPts = logs + .filter( + (log) => log.correctedPosition && log.correctedPosition[0] !== null + ) + .map((log) => log.correctedPosition); + // Slice the trajectory to the maximum allowed length. + if (trajPts.length > maxLen) { + trajPts = trajPts.slice(trajPts.length - maxLen); } + // Begin drawing the trajectory. + p.push(); + p.noFill(); + if (isCurrentlyStationary) { + p.stroke(34, 139, 34, 220); // Forest green + p.strokeWeight(1); + p.drawingContext.setLineDash([3, 3]); + } else { + // Set color and weight for moving trajectories based on theme. + p.stroke( + document.documentElement.classList.contains("dark") + ? p.color(10, 170, 255, 250) + : p.color(0, 50, 255, 250) + ); + p.strokeWeight(1.5); + } + // Draw the trajectory as a continuous line. + p.beginShape(); + for (const pos of trajPts) + p.vertex(pos[0] * plotScales.plotScaleX, pos[1] * plotScales.plotScaleY); + // End drawing and reset line dash. + p.endShape(); + p.drawingContext.setLineDash([]); + p.pop(); + } } /** @@ -198,65 +318,93 @@ export function drawTrajectories(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawTrackMarkers(p, plotScales) { - const showDetails = toggleVelocity.checked; - const useStationary = toggleStationaryColor.checked; - const textColor = document.documentElement.classList.contains('dark') ? p.color(255) : p.color(0); - const localStationaryColor = stationaryColor(p); - const localMovingColor = movingColor(p); - - for (const track of appState.vizData.tracks) { - const log = track.historyLog.find(log => log.frameIdx === appState.currentFrame + 1); - if (log) { - const pos = (log.correctedPosition && log.correctedPosition[0] !== null) ? log.correctedPosition : log.predictedPosition; - if (pos && pos.length === 2 && pos[0] !== null && pos[1] !== null) { - const size = 5, - x = pos[0] * plotScales.plotScaleX, - y = pos[1] * plotScales.plotScaleY; - let velocityColor = p.color(255, 0, 255, 200); - - p.push(); - p.strokeWeight(2); - if (useStationary && log.isStationary === true) { - p.stroke(localStationaryColor); - p.noFill(); - p.rectMode(p.CENTER); - p.square(x, y, size * 1.5); - velocityColor = localStationaryColor; - } else { - let markerColor = p.color(0, 0, 255); - if (useStationary && log.isStationary === false) { - markerColor = localMovingColor; - velocityColor = localMovingColor; - } - p.stroke(markerColor); - p.line(x - size, y, x + size, y); - p.line(x, y - size, x, y + size); - } - p.pop(); + const showDetails = toggleVelocity.checked; + const useStationary = toggleStationaryColor.checked; + // Determine text color based on theme. + const textColor = document.documentElement.classList.contains("dark") + ? p.color(255) + : p.color(0); + // Get local color instances for stationary and moving objects. + const localStationaryColor = stationaryColor(p); + const localMovingColor = movingColor(p); + + // Iterate through each tracked object. + for (const track of appState.vizData.tracks) { + // Find the log entry for the current frame. + const log = track.historyLog.find( + (log) => log.frameIdx === appState.currentFrame + 1 + ); + if (log) { + const pos = + log.correctedPosition && log.correctedPosition[0] !== null + ? log.correctedPosition // Use corrected position if available. + : log.predictedPosition; + if (pos && pos.length === 2 && pos[0] !== null && pos[1] !== null) { + const size = 5, + x = pos[0] * plotScales.plotScaleX, + y = pos[1] * plotScales.plotScaleY; + let velocityColor = p.color(255, 0, 255, 200); + p.push(); + p.strokeWeight(2); + if (useStationary && log.isStationary === true) { + p.stroke(localStationaryColor); + p.noFill(); + p.rectMode(p.CENTER); + p.square(x, y, size * 1.5); + velocityColor = localStationaryColor; // Set velocity color to stationary. + } else { + let markerColor = p.color(0, 0, 255); + if (useStationary && log.isStationary === false) { + // If not stationary, use moving color. + markerColor = localMovingColor; + // Set velocity color to moving. + velocityColor = localMovingColor; + } + p.stroke(markerColor); + p.line(x - size, y, x + size, y); + p.line(x, y - size, x, y + size); + } + p.pop(); - if (showDetails && log.predictedVelocity && log.predictedVelocity[0] !== null) { - const [vx, vy] = log.predictedVelocity; - if (log.isStationary === false) { - p.push(); - p.stroke(velocityColor); - p.strokeWeight(2); - p.line(x, y, (pos[0] + vx) * plotScales.plotScaleX, (pos[1] + vy) * plotScales.plotScaleY); - p.pop(); - } - const speed = (p.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1); - const ttc = (log.ttc !== null && isFinite(log.ttc) && log.ttc < 100) ? `TTC: ${log.ttc.toFixed(1)}s` : ''; - const text = `ID: ${track.id} | ${speed} km/h\n${ttc}`; - p.push(); - p.fill(textColor); - p.noStroke(); - p.scale(1, -1); - p.textSize(12); - p.text(text, x + 10, -y); - p.pop(); - } - } + // Draw velocity vector and text details if enabled. + if ( + showDetails && + log.predictedVelocity && + log.predictedVelocity[0] !== null + ) { + const [vx, vy] = log.predictedVelocity; + if (log.isStationary === false) { + // Only draw velocity for moving objects. + p.push(); + p.stroke(velocityColor); + p.strokeWeight(2); + p.line( + x, + y, + (pos[0] + vx) * plotScales.plotScaleX, + (pos[1] + vy) * plotScales.plotScaleY + ); + p.pop(); + } // Calculate speed in km/h. + const speed = (p.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1); + // Format TTC (Time To Collision) if available and finite. + const ttc = + log.ttc !== null && isFinite(log.ttc) && log.ttc < 100 + ? `TTC: ${log.ttc.toFixed(1)}s` + : ""; + // Construct info text. + const text = `ID: ${track.id} | ${speed} km/h\n${ttc}`; + p.push(); + p.fill(textColor); + p.noStroke(); + p.scale(1, -1); + p.textSize(12); + p.text(text, x + 10, -y); + p.pop(); } + } } + } } /** @@ -265,83 +413,106 @@ export function drawTrackMarkers(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function handleCloseUpDisplay(p, plotScales) { - const frameData = appState.vizData.radarFrames[appState.currentFrame]; - if (!frameData || !frameData.pointCloud) return; - - const hoveredPoints = []; - const radius = 10; - - for (const pt of frameData.pointCloud) { - if (pt.x === null || pt.y === null) continue; - const screenX = (pt.x * plotScales.plotScaleX) + p.width / 2; - const screenY = p.height * 0.95 - (pt.y * plotScales.plotScaleY); - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); - if (d < radius) { - hoveredPoints.push({ - point: pt, - screenX: screenX, - screenY: screenY - }); - } + // Get current frame data. + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + if (!frameData || !frameData.pointCloud) return; + + const hoveredPoints = []; + const radius = 10; + + // Iterate through point cloud to find hovered points. + for (const pt of frameData.pointCloud) { + if (pt.x === null || pt.y === null) continue; + // Convert radar coordinates to screen coordinates. + const screenX = pt.x * plotScales.plotScaleX + p.width / 2; + const screenY = p.height * 0.95 - pt.y * plotScales.plotScaleY; // Y-axis is inverted for drawing. + const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + if (d < radius) { + hoveredPoints.push({ + point: pt, + screenX: screenX, + screenY: screenY, + }); } + } - if (hoveredPoints.length > 0) { - hoveredPoints.sort((a, b) => a.screenY - b.screenY); - - p.push(); - p.textSize(12); - const lineHeight = 15; - const boxPadding = 8; - let boxWidth = 0; - const infoStrings = []; - - for (const hovered of hoveredPoints) { - const pt = hovered.point; - const vel = pt.velocity !== null ? pt.velocity.toFixed(2) : 'N/A'; - const snr = pt.snr !== null ? pt.snr.toFixed(1) : 'N/A'; - const infoText = `X:${pt.x.toFixed(2)}, Y:${pt.y.toFixed(2)} | V:${vel}, SNR:${snr}`; - infoStrings.push(infoText); - boxWidth = Math.max(boxWidth, p.textWidth(infoText)); - } - - const boxHeight = (infoStrings.length * lineHeight) + (boxPadding * 2); - boxWidth += (boxPadding * 2); - - const xOffset = 20; - let boxX = p.mouseX + xOffset; - let boxY = p.mouseY - (boxHeight / 2); - - if (boxX + boxWidth > p.width) { - boxX = p.mouseX - boxWidth - xOffset; - } - boxY = p.constrain(boxY, 0, p.height - boxHeight); - - const highlightColor = p.color(46, 204, 113); - - for (let i = 0; i < hoveredPoints.length; i++) { - const hovered = hoveredPoints[i]; - p.noFill(); - p.stroke(highlightColor); - p.strokeWeight(2); - p.ellipse(hovered.screenX, hovered.screenY, 15, 15); - p.strokeWeight(1); - p.line(boxX + boxPadding, boxY + boxPadding + (i * lineHeight) + (lineHeight / 2), hovered.screenX, hovered.screenY); - } - - const bgColor = document.documentElement.classList.contains('dark') ? p.color(20, 20, 30, 255) : p.color(245, 245, 245, 255); - p.fill(bgColor); - p.stroke(highlightColor); - p.strokeWeight(1); - p.rect(boxX, boxY, boxWidth, boxHeight, 4); + // If points are hovered, display detailed info. + if (hoveredPoints.length > 0) { + // Sort points by Y-coordinate for consistent display. + hoveredPoints.sort((a, b) => a.screenY - b.screenY); - const textColor = document.documentElement.classList.contains('dark') ? p.color(230) : p.color(20); - p.fill(textColor); - p.noStroke(); - p.textAlign(p.LEFT, p.TOP); - for (let i = 0; i < infoStrings.length; i++) { - p.text(infoStrings[i], boxX + boxPadding, boxY + boxPadding + (i * lineHeight)); - } + p.push(); + p.textSize(12); + const lineHeight = 15; // Line height for text in the info box. + const boxPadding = 8; + let boxWidth = 0; + const infoStrings = []; + + for (const hovered of hoveredPoints) { + const pt = hovered.point; + const vel = pt.velocity !== null ? pt.velocity.toFixed(2) : "N/A"; + const snr = pt.snr !== null ? pt.snr.toFixed(1) : "N/A"; + const infoText = `X:${pt.x.toFixed(2)}, Y:${pt.y.toFixed( + 2 + )} | V:${vel}, SNR:${snr}`; + infoStrings.push(infoText); + boxWidth = Math.max(boxWidth, p.textWidth(infoText)); + } // Calculate box dimensions. + const boxHeight = infoStrings.length * lineHeight + boxPadding * 2; + boxWidth += boxPadding * 2; + + // Position the info box relative to the mouse. + const xOffset = 20; + let boxX = p.mouseX + xOffset; + let boxY = p.mouseY - boxHeight / 2; + + // Adjust box position to stay within canvas bounds. + if (boxX + boxWidth > p.width) { + boxX = p.mouseX - boxWidth - xOffset; + } + boxY = p.constrain(boxY, 0, p.height - boxHeight); + + // Highlight hovered points and draw connecting lines to the info box. + const highlightColor = p.color(46, 204, 113); + + for (let i = 0; i < hoveredPoints.length; i++) { + const hovered = hoveredPoints[i]; + p.noFill(); + p.stroke(highlightColor); + p.strokeWeight(2); + p.ellipse(hovered.screenX, hovered.screenY, 15, 15); + p.strokeWeight(1); + p.line( + boxX + boxPadding, + boxY + boxPadding + i * lineHeight + lineHeight / 2, + hovered.screenX, + hovered.screenY + ); + } - p.pop(); + // Draw the info box background and border. + const bgColor = document.documentElement.classList.contains("dark") + ? p.color(20, 20, 30, 255) + : p.color(245, 245, 245, 255); + p.fill(bgColor); + p.stroke(highlightColor); + p.strokeWeight(1); + p.rect(boxX, boxY, boxWidth, boxHeight, 4); + // Draw the text content inside the info box. + const textColor = document.documentElement.classList.contains("dark") + ? p.color(230) + : p.color(20); + p.fill(textColor); + p.noStroke(); + p.textAlign(p.LEFT, p.TOP); + for (let i = 0; i < infoStrings.length; i++) { + p.text( + infoStrings[i], + boxX + boxPadding, + boxY + boxPadding + i * lineHeight + ); } + + p.pop(); + } } diff --git a/steps/src/fileParsers.js b/steps/src/fileParsers.js index 898d0c9..d3ae467 100644 --- a/steps/src/fileParsers.js +++ b/steps/src/fileParsers.js @@ -1,88 +1,139 @@ - //--------------------CAN-LOG PARSER------------------------// - export function processCanLog(logContent, videoStartDate) { - // The function receives everything it needs as arguments. - // It no longer looks at the global state. - - if (!videoStartDate) { - // If the video isn't loaded, it can't do its job. - // It returns an object describing the problem. - return { error: "Please load the video file first to synchronize the CAN log.", rawCanLogText: logContent }; + // The function now receives all necessary data (logContent, videoStartDate) as arguments, + // making it a pure function that doesn't rely on global state. + if (!videoStartDate) { + // If videoStartDate is not provided, it means the video file hasn't been loaded yet. + // The CAN log cannot be synchronized without it, so an error is returned. + return { + // Error message to be displayed to the user. + error: "Please load the video file first to synchronize the CAN log.", + // The raw log content is returned so it can be stored and processed later + // once the videoStartDate becomes available. + rawCanLogText: logContent, + }; + } + + // This is a NEW, LOCAL variable, only for this function. + const canData = []; + const lines = logContent.split("\n"); + // Regular expression to parse CAN log lines. + // It captures time components (HH:MM:SS:ms), CAN ID, and data bytes. + const logRegex = + /(\d{2}):(\d{2}):(\d{2}):(\d{4})\s+Rx\s+\d+\s+0x([0-9a-fA-F]+)\s+s\s+\d+((?:\s+[0-9a-fA-F]{2})+)/; + // The specific CAN ID (0x30F) we are interested in for speed data. + const canIdToDecode = "30F"; + + for (const line of lines) { + const match = line.match(logRegex); + // Check if the line matches the regex and if the CAN ID is the one we want. + if (match && match[5].toUpperCase() === canIdToDecode) { + // Extract time components from the regex match. + const [h, m, s, ms] = [ + parseInt(match[1]), + parseInt(match[2]), + parseInt(match[3]), + parseInt(match[4].substring(0, 3)), + ]; + // Create a Date object for the CAN message timestamp. + // It uses the video's start date and then sets the time components from the log. + const msgDate = new Date(videoStartDate); + msgDate.setUTCHours(h, m, s, ms); + // Extract and parse data bytes from the regex match. + const dataBytes = match[6] + .trim() + .split(/\s+/) + .map((hex) => parseInt(hex, 16)); + // Check if there are enough data bytes to extract speed information. + if (dataBytes.length >= 2) { + // Decode the raw speed value from the first two data bytes. + // This specific decoding logic is based on the CAN message format. + const rawVal = (dataBytes[0] << 3) | (dataBytes[1] >> 5); + // Convert the raw value to km/h and format it to one decimal place. + const speed = (rawVal * 0.1).toFixed(1); + canData.push({ time: msgDate.getTime(), speed: speed }); + } } + } + // Sort the processed CAN data points by their timestamp. + canData.sort((a, b) => a.time - b.time); - // This is a NEW, LOCAL variable, only for this function. - const canData = []; - const lines = logContent.split('\n'); - const logRegex = /(\d{2}):(\d{2}):(\d{2}):(\d{4})\s+Rx\s+\d+\s+0x([0-9a-fA-F]+)\s+s\s+\d+((?:\s+[0-9a-fA-F]{2})+)/; - const canIdToDecode = '30F'; - - for (const line of lines) { - const match = line.match(logRegex); - if (match && match[5].toUpperCase() === canIdToDecode) { - const [h, m, s, ms] = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4].substring(0, 3))]; - const msgDate = new Date(videoStartDate); - msgDate.setUTCHours(h, m, s, ms); - const dataBytes = match[6].trim().split(/\s+/).map(hex => parseInt(hex, 16)); - if (dataBytes.length >= 2) { - const rawVal = (dataBytes[0] << 3) | (dataBytes[1] >> 5); - const speed = (rawVal * 0.1).toFixed(1); - canData.push({ time: msgDate.getTime(), speed: speed }); - } - } - } - // It sorts the LOCAL canData array. - canData.sort((a, b) => a.time - b.time); + console.log( + `Processed ${canData.length} CAN messages for ID ${canIdToDecode}.` + ); - console.log(`Processed ${canData.length} CAN messages for ID ${canIdToDecode}.`); - - // It returns the finished product in a structured object. - return { data: canData }; + // It returns the finished product in a structured object. + // The processed CAN data is returned under the 'data' key. + return { data: canData }; } - //--------------------JSON PARSER------------------------// -// Add this new function to src/fileParsers.js - -export function parseVisualizationJson(jsonString, radarStartTimeMs, videoStartDate) { - try { - const cleanJsonString = jsonString.replace(/\b(Infinity|NaN|-Infinity)\b/gi, 'null'); - const vizData = JSON.parse(cleanJsonString); - - if (!vizData.radarFrames || vizData.radarFrames.length === 0) { - return { error: 'Error: The JSON file does not contain any radar frames.' }; - } - - // Perform timestamp calculations - vizData.radarFrames.forEach(frame => { - frame.timestampMs = (radarStartTimeMs + frame.timestamp) - videoStartDate.getTime(); - }); +export function parseVisualizationJson( + jsonString, + radarStartTimeMs, + videoStartDate +) { + try { + // Replace Infinity, NaN, and -Infinity with "null" to prevent JSON.parse errors. + const cleanJsonString = jsonString.replace( + /\b(Infinity|NaN|-Infinity)\b/gi, + "null" + ); + // Parse the cleaned JSON string into a JavaScript object. + const vizData = JSON.parse(cleanJsonString); + + // Validate if the parsed data contains radar frames. + if (!vizData.radarFrames || vizData.radarFrames.length === 0) { + return { + error: "Error: The JSON file does not contain any radar frames.", + }; + } - // Calculate SNR range from the data - let snrValues = [], totalPoints = 0; - vizData.radarFrames.forEach(frame => { - if (frame.pointCloud && frame.pointCloud.length > 0) { - totalPoints += frame.pointCloud.length; - frame.pointCloud.forEach(p => { - if (p.snr !== null) snrValues.push(p.snr); - }); - } + // Perform timestamp calculations for each radar frame. + // The `timestampMs` for each frame is calculated relative to the video's start time, + // taking into account the `radarStartTimeMs` (extracted from JSON filename) + // and the `videoStartDate` (extracted from video filename). + // This ensures synchronization between radar data and video. + vizData.radarFrames.forEach((frame) => { + frame.timestampMs = + radarStartTimeMs + frame.timestamp - videoStartDate.getTime(); + }); + + // Calculate SNR range from the data + let snrValues = [], + totalPoints = 0; // Counter for total points across all frames. + vizData.radarFrames.forEach((frame) => { + if (frame.pointCloud && frame.pointCloud.length > 0) { + totalPoints += frame.pointCloud.length; + frame.pointCloud.forEach((p) => { + // Collect SNR values, ignoring nulls. + if (p.snr !== null) snrValues.push(p.snr); }); + } + }); - if (totalPoints === 0) { - console.warn('Warning: Loaded frames contain no point cloud data.'); - } - - const minSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0; - const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; - - // Return the finished data package - return { data: vizData, minSnr: minSnr, maxSnr: maxSnr }; - - } catch (error) { - console.error("JSON Parsing Error:", error); - return { error: 'Error parsing JSON file. Please check file format. Error: ' + error.message }; + // Warn if no point cloud data was found in the loaded frames. + if (totalPoints === 0) { + console.warn("Warning: Loaded frames contain no point cloud data."); } -} \ No newline at end of file + + // Determine the global minimum and maximum SNR values from the collected data. + // These values are used for scaling the SNR color legend. + // Default to 0 and 1 if no SNR values are found to prevent errors. + const minSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0; + const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; + + // Return the finished data package + // This object contains the processed visualization data, and the calculated min/max SNR. + return { data: vizData, minSnr: minSnr, maxSnr: maxSnr }; + } catch (error) { + console.error("JSON Parsing Error:", error); + return { + error: + "Error parsing JSON file. Please check file format. Error: " + + error.message, + }; + } +} diff --git a/steps/src/main.js b/steps/src/main.js index 30f7d33..19322c8 100644 --- a/steps/src/main.js +++ b/steps/src/main.js @@ -40,7 +40,7 @@ import { findLastCanIndexBefore, extractTimestampInfo, parseTimestamp, - throttle + throttle, } from "./utils.js"; // import state machine from './src/state.js'; import { appState } from "./state.js"; @@ -92,19 +92,21 @@ import { updateCanDisplay, updateDebugOverlay, } from "./dom.js"; -// import modal dialog logic from './src/modal.js'; +// Import modal dialog logic from './src/modal.js'. import { showModal } from "./modal.js"; -// import initialize theme from './src/theme.js'; +// Import theme initialization from './src/theme.js'. import { initializeTheme } from "./theme.js"; -// import caching logic from './src/db.js'; +// Import caching logic from './src/db.js'. import { initDB, saveFileToDB, loadFileFromDB } from "./db.js"; +// 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); } +// Event listener for loading JSON file. loadJsonBtn.addEventListener("click", () => jsonFileInput.click()); loadVideoBtn.addEventListener("click", () => videoFileInput.click()); loadCanBtn.addEventListener("click", () => canFileInput.click()); @@ -116,6 +118,7 @@ clearCacheBtn.addEventListener("click", async () => { window.location.reload(); } }); +// Event listener for JSON file input change. jsonFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -174,6 +177,7 @@ jsonFileInput.addEventListener("change", (event) => { }; reader.readAsText(file); }); +// Event listener for video file input change. videoFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -222,7 +226,7 @@ videoFileInput.addEventListener("change", (event) => { } }; }); - +// Event listener for CAN file input change. canFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -269,10 +273,12 @@ canFileInput.addEventListener("change", (event) => { }; reader.readAsText(file); }); +// Event listener for offset input change. offsetInput.addEventListener("input", () => { autoOffsetIndicator.classList.add("hidden"); localStorage.setItem("visualizerOffset", offsetInput.value); }); +// Event listener for apply SNR button click. applySnrBtn.addEventListener("click", () => { const newMin = parseFloat(snrMinInput.value), newMax = parseFloat(snrMaxInput.value); @@ -291,6 +297,7 @@ applySnrBtn.addEventListener("click", () => { appState.p5_instance.redraw(); } }); +// Event listener for play/pause button click. playPauseBtn.addEventListener("click", () => { if (!appState.vizData && !videoPlayer.src) return; appState.isPlaying = !appState.isPlaying; @@ -307,6 +314,7 @@ playPauseBtn.addEventListener("click", () => { if (videoPlayer.src) videoPlayer.pause(); } }); +// Event listener for stop button click. stopBtn.addEventListener("click", () => { videoPlayer.pause(); appState.isPlaying = false; @@ -318,18 +326,24 @@ stopBtn.addEventListener("click", () => { } if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); }); -timelineSlider.addEventListener('input', throttle((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(); -}, 16 )); // 50ms throttle delay +// Event listener for timeline slider input. +timelineSlider.addEventListener( + "input", + throttle((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(); + }, 16) +); // Throttle delay for smoother updates. +// Currently set at 16 ms to achieve smooth 60fps. +// Event listener for speed slider input. speedSlider.addEventListener("input", (event) => { const speed = parseFloat(event.target.value); videoPlayer.playbackRate = speed; @@ -337,7 +351,8 @@ speedSlider.addEventListener("input", (event) => { }); // ADD THE NEW TOGGLE TO THE ARRAY -const colorToggles = [ +// Array of color toggles. +const colorToggles = [ toggleSnrColor, toggleClusterColor, toggleInlierColor, @@ -353,14 +368,14 @@ colorToggles.forEach((t) => { if (appState.p5_instance) appState.p5_instance.redraw(); }); }); - +// Event listeners for various feature toggles. [ toggleVelocity, toggleEgoSpeed, toggleFrameNorm, toggleTracks, toggleDebugOverlay, - toggleDebug2Overlay + toggleDebug2Overlay, ].forEach((t) => { t.addEventListener("change", () => { if (appState.p5_instance) { @@ -371,10 +386,12 @@ colorToggles.forEach((t) => { ); appState.p5_instance.redraw(); } - if (t === toggleDebugOverlay || t === toggleDebug2Overlay) { updateDebugOverlay(videoPlayer.currentTime)}; + if (t === toggleDebugOverlay || t === toggleDebug2Overlay) { + updateDebugOverlay(videoPlayer.currentTime); + } }); }); - +// Event listener for close-up toggle. toggleCloseUp.addEventListener("change", () => { appState.isCloseUpMode = toggleCloseUp.checked; if (appState.p5_instance) { @@ -389,11 +406,12 @@ toggleCloseUp.addEventListener("change", () => { } } }); - +// Event listener for video ended event. videoPlayer.addEventListener("ended", () => { appState.isPlaying = false; playPauseBtn.textContent = "Play"; }); +// Event listener for keyboard arrow key presses to navigate frames. document.addEventListener("keydown", (event) => { if ( !appState.vizData || @@ -420,6 +438,7 @@ document.addEventListener("keydown", (event) => { appState.masterClockStart = performance.now(); } }); +// Calculates and sets the time offset between JSON and video timestamps. function calculateAndSetOffset() { const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename); const videoTimestampInfo = extractTimestampInfo(appState.videoFilename); @@ -453,10 +472,10 @@ function calculateAndSetOffset() { } } -// --- Application Initialization --- +// Application Initialization: Event listener for DOMContentLoaded. document.addEventListener("DOMContentLoaded", () => { initializeTheme(); - console.log("DEBUG: DOMContentLoaded fired. Starting session load."); + console.log("DEBUG: DOMContentLoaded fired. Starting session load."); // Log for debugging. initDB(() => { console.log("DEBUG: Database initialized."); @@ -469,8 +488,9 @@ document.addEventListener("DOMContentLoaded", () => { appState.canLogFilename = localStorage.getItem("canLogFilename"); // This is important: it sets videoStartDate if a video filename is cached - calculateAndSetOffset(); + calculateAndSetOffset(); // Calculate offset based on cached filenames. + // Promises to load files from IndexedDB. const videoPromise = new Promise((resolve) => loadFileFromDB("video", resolve) ); @@ -480,7 +500,7 @@ document.addEventListener("DOMContentLoaded", () => { const canLogPromise = new Promise((resolve) => loadFileFromDB("canLogText", resolve) ); - + // Once all files are loaded from DB, process them. Promise.all([videoPromise, jsonPromise, canLogPromise]) .then(([videoBlob, jsonString, canLogText]) => { console.log("DEBUG: All data fetched from IndexedDB."); @@ -488,7 +508,7 @@ document.addEventListener("DOMContentLoaded", () => { const processAllData = () => { console.log("DEBUG: Processing all loaded data."); - // 1. Process JSON (only if we have a video date) + // 1. Process JSON (only if video start date is available). if (jsonString && appState.videoStartDate) { const result = parseVisualizationJson( jsonString, @@ -506,7 +526,7 @@ document.addEventListener("DOMContentLoaded", () => { } } - // 2. Process CAN log (only if we have a video date) + // 2. Process CAN log (only if video start date is available). if (canLogText && appState.videoStartDate) { const result = processCanLog(canLogText, appState.videoStartDate); if (!result.error) { @@ -514,7 +534,7 @@ document.addEventListener("DOMContentLoaded", () => { } } - // 3. Update all UI elements now that data is processed + // 3. Update all UI elements now that data is processed. if (appState.vizData) { resetVisualization(); canvasPlaceholder.style.display = "none"; @@ -536,15 +556,14 @@ document.addEventListener("DOMContentLoaded", () => { } }; - // This is the main controller - // --- THIS IS THE CORRECTED CODE --- + // Main controller for processing data based on video availability. 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. + // If there's no video, process other data immediately. processAllData(); } }) diff --git a/steps/src/modal.js b/steps/src/modal.js index 7e9cc1b..0d2e2b0 100644 --- a/steps/src/modal.js +++ b/steps/src/modal.js @@ -1,33 +1,46 @@ - -import { modalText, modalCancelBtn, modalContainer, modalOverlay, modalContent, modalOkBtn } from './dom.js'; +import { + modalText, + modalCancelBtn, + modalContainer, + modalOverlay, + modalContent, + modalOkBtn, +} from "./dom.js"; // --- Custom Modal Logic --- // - let modalResolve = null; - export function showModal(message, isConfirm = false) { - return new Promise(resolve => { - modalText.textContent = message; - modalCancelBtn.classList.toggle('hidden', !isConfirm); - modalContainer.classList.remove('hidden'); - setTimeout(() => { - modalOverlay.classList.remove('opacity-0'); - modalContent.classList.remove('scale-95'); - } - , 10); - modalResolve = resolve; - }); - } - function hideModal(value) { - modalOverlay.classList.add('opacity-0'); - modalContent.classList.add('scale-95'); - setTimeout(() => { - modalContainer.classList.add('hidden'); - if (modalResolve) modalResolve(value); - }, 200); - } - - - //----------------------Modal Event Listeners----------------------// +// Variable to store the resolve function of the Promise, allowing the modal to return a value. +let modalResolve = null; +export function showModal(message, isConfirm = false) { + return new Promise((resolve) => { + // Set the message text for the modal. + modalText.textContent = message; + // Show/hide the cancel button based on whether it's a confirmation modal. + modalCancelBtn.classList.toggle("hidden", !isConfirm); + // Make the modal container visible. + modalContainer.classList.remove("hidden"); + // Add a slight delay for CSS transitions to take effect, making the modal appear smoothly. + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); + // Store the resolve function to be called when the modal is closed. + modalResolve = resolve; + }); +} +// Hides the modal and resolves the Promise with the given value. +function hideModal(value) { + modalOverlay.classList.add("opacity-0"); + modalContent.classList.add("scale-95"); + setTimeout(() => { + modalContainer.classList.add("hidden"); + if (modalResolve) modalResolve(value); + }, 200); +} - modalOkBtn.addEventListener('click', () => hideModal(true)); - modalCancelBtn.addEventListener('click', () => hideModal(false)); - modalOverlay.addEventListener('click', () => hideModal(false)); \ No newline at end of file +//----------------------Modal Event Listeners----------------------// +// Event listener for the "OK" button. Resolves the modal Promise with 'true'. +modalOkBtn.addEventListener("click", () => hideModal(true)); +// Event listener for the "Cancel" button. Resolves the modal Promise with 'false'. +modalCancelBtn.addEventListener("click", () => hideModal(false)); +// Event listener for clicking on the modal overlay (outside the content). Resolves the modal Promise with 'false'. +modalOverlay.addEventListener("click", () => hideModal(false)); diff --git a/steps/src/p5/radarSketch.js b/steps/src/p5/radarSketch.js index 84ba3d2..d52b410 100644 --- a/steps/src/p5/radarSketch.js +++ b/steps/src/p5/radarSketch.js @@ -1,126 +1,170 @@ +import { appState } from "../state.js"; import { - appState -} from '../state.js'; + RADAR_X_MAX, + // Define radar plot boundaries + RADAR_X_MIN, + RADAR_Y_MAX, + RADAR_Y_MIN, +} from "../constants.js"; +import { canvasContainer, toggleSnrColor, toggleTracks } from "../dom.js"; import { - RADAR_X_MAX, - RADAR_X_MIN, - RADAR_Y_MAX, - RADAR_Y_MIN -} from '../constants.js'; -import { - canvasContainer, - toggleSnrColor, - toggleTracks -} from '../dom.js'; -import { - drawStaticRegionsToBuffer, - drawAxes, - drawPointCloud, - drawTrajectories, - drawTrackMarkers, - snrColors, - handleCloseUpDisplay // BUG FIX 1: Import the close-up handler -} from '../drawUtils.js'; + drawStaticRegionsToBuffer, + drawAxes, + drawPointCloud, + // Import drawing utility functions + drawTrajectories, + drawTrackMarkers, + snrColors, + handleCloseUpDisplay, // BUG FIX 1: Import the close-up handler +} from "../drawUtils.js"; -export const radarSketch = function(p) { - let plotScales = { - plotScaleX: 1, - plotScaleY: 1 - }; - let staticBackgroundBuffer, snrLegendBuffer; - - function calculatePlotScales() { - const hPad = 0.05, - vPad = 0.05, - bOff = 0.05; - const aW = p.width * (1 - 2 * hPad); - const aH = p.height * (1 - bOff - vPad); - plotScales.plotScaleX = aW / (RADAR_X_MAX - RADAR_X_MIN); - plotScales.plotScaleY = aH / (RADAR_Y_MAX - RADAR_Y_MIN); - } +export const radarSketch = function (p) { + // Object to store calculated plot scales + let plotScales = { + plotScaleX: 1, + plotScaleY: 1, + }; + // p5.Graphics buffers for static elements to optimize drawing + let staticBackgroundBuffer, snrLegendBuffer; - p.setup = function() { - let canvas = p.createCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); - canvas.parent('canvas-container'); - staticBackgroundBuffer = p.createGraphics(p.width, p.height); - snrLegendBuffer = p.createGraphics(100, 450); + // Function to calculate scaling factors for radar coordinates to canvas pixels + function calculatePlotScales() { + // Padding and offset values for the plot area + const hPad = 0.05, + vPad = 0.05, + bOff = 0.05; + // Calculate available width and height for the plot + const aW = p.width * (1 - 2 * hPad); + const aH = p.height * (1 - bOff - vPad); + // Determine plot scales based on radar boundaries and available canvas space + plotScales.plotScaleX = aW / (RADAR_X_MAX - RADAR_X_MIN); + plotScales.plotScaleY = aH / (RADAR_Y_MAX - RADAR_Y_MIN); + } - calculatePlotScales(); - p.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); - drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); - p.noLoop(); - }; + p.setup = function () { + // Create the p5.js canvas and attach it to the specified DOM element + let canvas = p.createCanvas( + canvasContainer.offsetWidth, + canvasContainer.offsetHeight + ); + canvas.parent("canvas-container"); + // Initialize graphics buffers + staticBackgroundBuffer = p.createGraphics(p.width, p.height); + snrLegendBuffer = p.createGraphics(100, 450); - p.draw = function() { - p.background(document.documentElement.classList.contains('dark') ? p.color(55, 65, 81) : 255); - if (!appState.vizData) return; + calculatePlotScales(); + p.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); + drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); + p.noLoop(); + // Disable continuous looping, redraw will be called manually + }; - p.image(staticBackgroundBuffer, 0, 0); + p.draw = function () { + // Set background color based on current theme (dark/light) + p.background( + document.documentElement.classList.contains("dark") + ? p.color(55, 65, 81) + : 255 + ); + // If no visualization data is loaded, stop drawing + if (!appState.vizData) return; - p.push(); - p.translate(p.width / 2, p.height * 0.95); - p.scale(1, -1); + // Draw the pre-rendered static background elements + p.image(staticBackgroundBuffer, 0, 0); - calculatePlotScales(); - drawAxes(p, plotScales); + // Apply transformations for radar coordinate system (origin at bottom-center, Y-axis inverted) + p.push(); + p.translate(p.width / 2, p.height * 0.95); + p.scale(1, -1); - const frameData = appState.vizData.radarFrames[appState.currentFrame]; - if (frameData) { - if (toggleTracks.checked) { - drawTrajectories(p, plotScales); - drawTrackMarkers(p, plotScales); - } - drawPointCloud(p, frameData.pointCloud, plotScales); - } - p.pop(); + // Recalculate plot scales (important for window resizing) + calculatePlotScales(); + // Draw coordinate axes + drawAxes(p, plotScales); - // BUG FIX 1: Call the close-up handler if the mode is active - if (appState.isCloseUpMode) { - handleCloseUpDisplay(p, plotScales); - } + // Get current frame data + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + if (frameData) { + // Draw object trajectories and markers if enabled + if (toggleTracks.checked) { + drawTrajectories(p, plotScales); + drawTrackMarkers(p, plotScales); + } + // Draw the point cloud for the current frame + drawPointCloud(p, frameData.pointCloud, plotScales); + } + p.pop(); - if (toggleSnrColor.checked) { - p.image(snrLegendBuffer, 10, p.height - snrLegendBuffer.height - 10); - } - }; + // BUG FIX 1: Call the close-up handler if the mode is active + if (appState.isCloseUpMode) { + handleCloseUpDisplay(p, plotScales); + } - p.drawSnrLegendToBuffer = function(minV, maxV) { - const b = snrLegendBuffer; - const localSnrColors = snrColors(p); - b.clear(); - b.push(); - const lx = 10, - ly = 20, - lw = 15, - lh = 400; - for (let i = 0; i < lh; i++) { - const amt = b.map(i, 0, lh, 1, 0); - let c; - if (amt < 0.25) c = b.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); - else if (amt < 0.5) c = b.lerpColor(localSnrColors.c2, localSnrColors.c3, (amt - 0.25) / 0.25); - else if (amt < 0.75) c = b.lerpColor(localSnrColors.c3, localSnrColors.c4, (amt - 0.5) / 0.25); - else c = b.lerpColor(localSnrColors.c4, localSnrColors.c5, (amt - 0.75) / 0.25); - b.stroke(c); - b.line(lx, ly + i, lx + lw, ly + i); - } - b.fill(document.documentElement.classList.contains('dark') ? 255 : 0); - b.noStroke(); - b.textSize(10); - b.textAlign(b.LEFT, b.CENTER); - b.text(maxV.toFixed(1), lx + lw + 5, ly); - b.text(minV.toFixed(1), lx + lw + 5, ly + lh); - b.text("SNR", lx, ly - 10); - b.pop(); - }; + // Draw the SNR legend if enabled + if (toggleSnrColor.checked) { + p.image(snrLegendBuffer, 10, p.height - snrLegendBuffer.height - 10); + } + }; + // Function to draw the SNR legend to its buffer + p.drawSnrLegendToBuffer = function (minV, maxV) { + // Reference to the SNR legend buffer + const b = snrLegendBuffer; + const localSnrColors = snrColors(p); + b.clear(); + b.push(); + const lx = 10, + ly = 20, + lw = 15, + // Dimensions for the color bar + lh = 400; + for (let i = 0; i < lh; i++) { + const amt = b.map(i, 0, lh, 1, 0); + let c; + if (amt < 0.25) + c = b.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); + else if (amt < 0.5) + c = b.lerpColor( + localSnrColors.c2, + localSnrColors.c3, + (amt - 0.25) / 0.25 + ); + else if (amt < 0.75) + c = b.lerpColor( + localSnrColors.c3, + localSnrColors.c4, + (amt - 0.5) / 0.25 + ); + else + c = b.lerpColor( + localSnrColors.c4, + localSnrColors.c5, + // Interpolate colors based on position + (amt - 0.75) / 0.25 + ); + b.stroke(c); + b.line(lx, ly + i, lx + lw, ly + i); + } + // Set text color based on theme + b.fill(document.documentElement.classList.contains("dark") ? 255 : 0); + b.noStroke(); + b.textSize(10); + b.textAlign(b.LEFT, b.CENTER); + // Draw min/max SNR values and label + b.text(maxV.toFixed(1), lx + lw + 5, ly); + b.text(minV.toFixed(1), lx + lw + 5, ly + lh); + b.text("SNR", lx, ly - 10); + b.pop(); + }; - p.windowResized = function() { - p.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); - // BUG FIX 2: Re-create the buffer instead of resizing it - staticBackgroundBuffer = p.createGraphics(p.width, p.height); - calculatePlotScales(); - // Re-draw the static content to the new buffer - drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); - if (appState.vizData) p.redraw(); - }; + // Handle window resizing event + p.windowResized = function () { + p.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); + // BUG FIX 2: Re-create the buffer instead of resizing it + staticBackgroundBuffer = p.createGraphics(p.width, p.height); + calculatePlotScales(); + // Re-draw the static content to the new buffer + drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); + if (appState.vizData) p.redraw(); + }; }; diff --git a/steps/src/p5/speedGraphSketch.js b/steps/src/p5/speedGraphSketch.js index 0938746..2cccd23 100644 --- a/steps/src/p5/speedGraphSketch.js +++ b/steps/src/p5/speedGraphSketch.js @@ -1,170 +1,265 @@ //---Import APPSTATE VIDEOPLAYER and FindLastCanIndex---// -import { appState - -} from '../state.js'; - -import { videoPlayer, speedGraphContainer - -} from '../dom.js'; - -import { findLastCanIndexBefore - -} from '../utils.js'; - +import { appState } from "../state.js"; +import { videoPlayer, speedGraphContainer } from "../dom.js"; +import { findLastCanIndexBefore } from "../utils.js"; export const speedGraphSketch = function (p) { - let staticBuffer, minSpeed, maxSpeed, videoDuration; - const pad = { top: 20, right: 130, bottom: 30, left: 50 }; - - // This function is now attached to the p5 instance, making it public - // It's responsible for drawing the static background and data lines - p.drawStaticGraphToBuffer = function (canSpeedData, radarData) { - const b = staticBuffer; - b.clear(); - const isDark = document.documentElement.classList.contains('dark'); - b.background(isDark ? [55, 65, 81] : 255); - const gridColor = isDark ? 100 : 200; - const textColor = isDark ? 200 : 100; - - b.push(); - b.stroke(gridColor); - b.strokeWeight(1); - b.line(pad.left, pad.top, pad.left, b.height - pad.bottom); - b.line(pad.left, b.height - pad.bottom, b.width - pad.right, b.height - pad.bottom); - b.textAlign(b.RIGHT, b.CENTER); - b.noStroke(); - b.fill(textColor); - b.textSize(10); - for (let s = minSpeed; s <= maxSpeed; s += 10) { - const y = b.map(s, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); - b.text(s, pad.left - 8, y); - if (s === 0) { - b.strokeWeight(1.5); - b.stroke(isDark ? 150 : 180); - } else { - b.strokeWeight(1); - b.stroke(isDark ? 80 : 230); - } - b.line(pad.left + 1, y, b.width - pad.right, y); - b.noStroke(); - } - - b.fill(textColor); - b.text("km/h", pad.left - 8, pad.top - 8); - b.textAlign(b.CENTER, b.TOP); - b.noStroke(); - b.fill(isDark ? 180 : 150); - const tInt = Math.max(1, Math.floor(videoDuration / 10)); - for (let t = 0; t <= videoDuration; t += tInt) { const x = b.map(t, 0, videoDuration, pad.left, b.width - pad.right); b.text(Math.round(t), x, b.height - pad.bottom + 5); } - b.fill(textColor); - b.text("Time (s)", b.width / 2, b.height - pad.bottom + 18); - b.pop(); - - if (canSpeedData && canSpeedData.length > 0) { - b.noFill(); - b.stroke(0, 150, 255); - b.strokeWeight(1.5); - b.beginShape(); - for (const d of canSpeedData) { const relTime = (d.time - appState.videoStartDate.getTime()) / 1000; if (relTime >= 0 && relTime <= videoDuration) { const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); const y = b.map(d.speed, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); b.vertex(x, y); } } - b.endShape(); - } + // Declare variables for the static buffer, min/max speed for scaling, and video duration. + let staticBuffer, minSpeed, maxSpeed, videoDuration; + // Define padding for the graph to ensure elements are not drawn at the edges. + const pad = { top: 20, right: 130, bottom: 30, left: 50 }; - if (radarData && radarData.radarFrames) { - b.stroke(0, 200, 100); - b.drawingContext.setLineDash([5, 5]); - b.beginShape(); - for (const frame of radarData.radarFrames) { - const relTime = frame.timestampMs / 1000; - if (relTime >= 0 && relTime <= videoDuration) { - const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); - const egoSpeedKmh = frame.egoVelocity[1] * 3.6; - const y = b.map(egoSpeedKmh, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); - b.vertex(x, y); - } - } - b.endShape(); - b.drawingContext.setLineDash([]); - } + /** + * Draws the static elements of the speed graph (axes, grid, labels, and data lines) + * to an off-screen buffer. This optimizes performance by not redrawing these elements + * every frame. + * @param {Array} canSpeedData - Array of CAN speed data points. + * @param {Object} radarData - Object containing radar frames with ego velocity. + */ + // This function is now attached to the p5 instance, making it public + // It's responsible for drawing the static background and data lines + p.drawStaticGraphToBuffer = function (canSpeedData, radarData) { + const b = staticBuffer; + b.clear(); + const isDark = document.documentElement.classList.contains("dark"); + b.background(isDark ? [55, 65, 81] : 255); + const gridColor = isDark ? 100 : 200; + const textColor = isDark ? 200 : 100; // Determine text color based on theme. - b.push(); - b.strokeWeight(2); - b.noStroke(); - b.fill(textColor); - b.textAlign(b.LEFT, b.CENTER); - b.stroke(0, 150, 255); - b.line(b.width - 120, pad.top + 10, b.width - 100, pad.top + 10); - b.noStroke(); - b.text("CAN Speed", b.width - 95, pad.top + 10); - b.stroke(0, 200, 100); - b.drawingContext.setLineDash([3, 3]); - b.line(b.width - 120, pad.top + 30, b.width - 100, pad.top + 30); - b.drawingContext.setLineDash([]); - b.noStroke(); - b.text("Ego Speed", b.width - 95, pad.top + 30); - b.pop(); - }; + // Push current drawing style settings onto a stack. + b.push(); + // Set stroke for grid lines. + b.stroke(gridColor); + // Set stroke weight for grid lines. + b.strokeWeight(1); + b.line(pad.left, pad.top, pad.left, b.height - pad.bottom); + b.line( + pad.left, + b.height - pad.bottom, + b.width - pad.right, + b.height - pad.bottom + ); // Draw Y and X axes. + // Set text alignment for Y-axis labels. + b.textAlign(b.RIGHT, b.CENTER); + b.noStroke(); + b.fill(textColor); + // Set text size for labels. + b.textSize(10); + // Draw horizontal grid lines and speed labels. + for (let s = minSpeed; s <= maxSpeed; s += 10) { + const y = b.map(s, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); + b.text(s, pad.left - 8, y); + if (s === 0) { + b.strokeWeight(1.5); + b.stroke(isDark ? 150 : 180); + } else { + b.strokeWeight(1); + b.stroke(isDark ? 80 : 230); + } + b.line(pad.left + 1, y, b.width - pad.right, y); + b.noStroke(); + } + // Draw Y-axis unit label. + b.fill(textColor); + b.text("km/h", pad.left - 8, pad.top - 8); + // Set text alignment for X-axis labels. + b.textAlign(b.CENTER, b.TOP); + b.noStroke(); + b.fill(isDark ? 180 : 150); + // Calculate time interval for X-axis labels. + const tInt = Math.max(1, Math.floor(videoDuration / 10)); + for (let t = 0; t <= videoDuration; t += tInt) { + const x = b.map(t, 0, videoDuration, pad.left, b.width - pad.right); + b.text(Math.round(t), x, b.height - pad.bottom + 5); + } + b.fill(textColor); + b.text("Time (s)", b.width / 2, b.height - pad.bottom + 18); + // Restore previous drawing style settings. + b.pop(); - p.setup = function () { - let canvas = p.createCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight); - canvas.parent('speed-graph-container'); - staticBuffer = p.createGraphics(p.width, p.height); - p.noLoop(); - }; + // Draw CAN speed data line if available. + if (canSpeedData && canSpeedData.length > 0) { + b.noFill(); // Do not fill the shape. + b.stroke(0, 150, 255); + b.strokeWeight(1.5); + b.beginShape(); + for (const d of canSpeedData) { + const relTime = (d.time - appState.videoStartDate.getTime()) / 1000; + if (relTime >= 0 && relTime <= videoDuration) { + const x = b.map( + relTime, + 0, + videoDuration, + pad.left, + b.width - pad.right + ); + const y = b.map( + d.speed, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); + b.vertex(x, y); + } + } + b.endShape(); + } // End of CAN speed data drawing. - p.setData = function (canSpeedData, radarData, duration) { - if ((!canSpeedData || canSpeedData.length === 0) && !radarData) return; - videoDuration = duration; + // Draw radar ego speed data line if available. + if (radarData && radarData.radarFrames) { + b.stroke(0, 200, 100); + b.drawingContext.setLineDash([5, 5]); + b.beginShape(); + for (const frame of radarData.radarFrames) { + const relTime = frame.timestampMs / 1000; + if (relTime >= 0 && relTime <= videoDuration) { + const x = b.map( + relTime, + 0, + videoDuration, + pad.left, + b.width - pad.right + ); + const egoSpeedKmh = frame.egoVelocity[1] * 3.6; + const y = b.map( + egoSpeedKmh, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); + b.vertex(x, y); + } + } + b.endShape(); + b.drawingContext.setLineDash([]); // Reset line dash to solid. + } // End of radar ego speed data drawing. - let speeds = []; - if (canSpeedData) { - speeds.push(...canSpeedData.map(d => parseFloat(d.speed))); - } - if (radarData && radarData.radarFrames) { - const egoSpeeds = radarData.radarFrames.map(frame => frame.egoVelocity[1] * 3.6); - speeds.push(...egoSpeeds); - } + // Draw legend for the graph lines. + b.push(); + b.strokeWeight(2); + b.noStroke(); + b.fill(textColor); + b.textAlign(b.LEFT, b.CENTER); + b.stroke(0, 150, 255); + b.line(b.width - 120, pad.top + 10, b.width - 100, pad.top + 10); + b.noStroke(); + b.text("CAN Speed", b.width - 95, pad.top + 10); + b.stroke(0, 200, 100); + b.drawingContext.setLineDash([3, 3]); + b.line(b.width - 120, pad.top + 30, b.width - 100, pad.top + 30); + b.drawingContext.setLineDash([]); + b.noStroke(); + b.text("Ego Speed", b.width - 95, pad.top + 30); + b.pop(); + }; + /** + * p5.js setup function. Initializes the canvas and static buffer. + */ + p.setup = function () { + let canvas = p.createCanvas( + speedGraphContainer.offsetWidth, + speedGraphContainer.offsetHeight + ); + canvas.parent("speed-graph-container"); + // Create an off-screen graphics buffer for static elements. + staticBuffer = p.createGraphics(p.width, p.height); + // Disable continuous looping; draw will be called manually. + p.noLoop(); + }; + /** + * Sets the data for the speed graph and recalculates min/max speed for scaling. + * @param {Array} canSpeedData - Array of CAN speed data points. + * @param {Object} radarData - Object containing radar frames with ego velocity. + * @param {number} duration - The total duration of the video in seconds. + */ + p.setData = function (canSpeedData, radarData, duration) { + if ((!canSpeedData || canSpeedData.length === 0) && !radarData) return; // Exit if no data. + videoDuration = duration; - minSpeed = speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0; - maxSpeed = speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10; - if (maxSpeed <= 0) maxSpeed = 10; - if (minSpeed >= 0) minSpeed = 0; + let speeds = []; + if (canSpeedData) { + speeds.push(...canSpeedData.map((d) => parseFloat(d.speed))); + } + if (radarData && radarData.radarFrames) { + const egoSpeeds = radarData.radarFrames.map( + (frame) => frame.egoVelocity[1] * 3.6 + ); + speeds.push(...egoSpeeds); + } - p.drawStaticGraphToBuffer(canSpeedData, radarData); - p.redraw(); - }; + // Calculate min and max speeds for Y-axis scaling, rounding to nearest 10. + minSpeed = + speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0; + maxSpeed = + speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10; + // Ensure maxSpeed is at least 10 if all speeds are non-positive. + if (maxSpeed <= 0) maxSpeed = 10; + // Ensure minSpeed is 0 if all speeds are non-negative. + if (minSpeed >= 0) minSpeed = 0; - p.draw = function () { - if (!videoDuration) return; - p.image(staticBuffer, 0, 0); - drawTimeIndicator(); - }; + // Redraw the static graph elements to the buffer with new data. + p.drawStaticGraphToBuffer(canSpeedData, radarData); + // Request a redraw of the main canvas. + p.redraw(); + }; + /** + * p5.js draw function. Draws the static buffer and the dynamic time indicator. + */ + p.draw = function () { + if (!videoDuration) return; // Only draw if video duration is set. + p.image(staticBuffer, 0, 0); + drawTimeIndicator(); + }; - function drawTimeIndicator() { - const currentTime = videoPlayer.currentTime; - const x = p.map(currentTime, 0, videoDuration, pad.left, p.width - pad.right); - p.stroke(255, 0, 0, 150); - p.strokeWeight(1.5); - p.line(x, pad.top, x, p.height - pad.bottom); - const videoAbsTimeMs = appState.videoStartDate.getTime() + (currentTime * 1000); - const canIndex = findLastCanIndexBefore(videoAbsTimeMs, appState.canData); - if (canIndex !== -1) { - const canMsg = appState.canData[canIndex]; - const y = p.map(canMsg.speed, minSpeed, maxSpeed, p.height - pad.bottom, pad.top); - p.fill(255, 0, 0); - p.noStroke(); - p.ellipse(x, y, 8, 8); - } - } + function drawTimeIndicator() { + const currentTime = videoPlayer.currentTime; + const x = p.map( + currentTime, + 0, + videoDuration, + pad.left, + p.width - pad.right + ); // Map current time to X-coordinate. + // Draw the red time indicator line. + p.stroke(255, 0, 0, 150); + p.strokeWeight(1.5); + p.line(x, pad.top, x, p.height - pad.bottom); - p.windowResized = function () { - p.resizeCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight); - // Instead of resizing the buffer, we re-create it - staticBuffer = p.createGraphics(p.width, p.height); - // And we must re-draw the static content to the new buffer - if ((appState.canData.length > 0 || appState.vizData) && videoDuration) { - p.drawStaticGraphToBuffer(appState.canData, appState.vizData); - } - p.redraw(); - }; - }; \ No newline at end of file + // Draw a circle on the CAN speed line at the current time. + const videoAbsTimeMs = + appState.videoStartDate.getTime() + currentTime * 1000; + const canIndex = findLastCanIndexBefore(videoAbsTimeMs, appState.canData); + if (canIndex !== -1) { + const canMsg = appState.canData[canIndex]; + const y = p.map( + canMsg.speed, + minSpeed, + maxSpeed, + p.height - pad.bottom, + pad.top + ); + p.fill(255, 0, 0); + p.noStroke(); // No stroke for the ellipse. + p.ellipse(x, y, 8, 8); + } + } + /** + * Handles window resizing. Resizes the canvas and recreates/redraws the static buffer. + */ + p.windowResized = function () { + p.resizeCanvas( + speedGraphContainer.offsetWidth, + speedGraphContainer.offsetHeight + ); + // Instead of resizing the buffer, we re-create it + staticBuffer = p.createGraphics(p.width, p.height); + // And we must re-draw the static content to the new buffer + if ((appState.canData.length > 0 || appState.vizData) && videoDuration) { + p.drawStaticGraphToBuffer(appState.canData, appState.vizData); + } + p.redraw(); + }; +}; diff --git a/steps/src/state.js b/steps/src/state.js index 4f5c43d..bc2c5e2 100644 --- a/steps/src/state.js +++ b/steps/src/state.js @@ -1,19 +1,38 @@ export const appState = { - - - vizData : null, - canData : [], - rawCanLogText : null, - videoStartDate : null, - radarStartTimeMs : 0, - isPlaying : false, - currentFrame : 0, - globalMinSnr : 0, globalMaxSnr : 1, - p5_instance : null, speedGraphInstance : null, - jsonFilename : '', videoFilename : '', canLogFilename : '', - isCloseUpMode : false, - masterClockStart : 0, - mediaTimeStart : 0, - lastSyncTime : 0, - -}; \ No newline at end of file + // Stores the parsed visualization data (radar frames, tracks, etc.) + vizData: null, + // Stores the processed CAN bus data (speed, time) + canData: [], + // Temporarily holds raw CAN log text if video start date is not yet available for processing + rawCanLogText: null, + // The Date object representing the start time of the video + videoStartDate: null, + // The timestamp (in milliseconds) of the first radar frame, extracted from the JSON filename + radarStartTimeMs: 0, + // Boolean indicating if the playback is currently active + isPlaying: false, + // The index of the currently displayed radar frame + currentFrame: 0, + // The global minimum SNR value across all radar frames, used for color scaling + globalMinSnr: 0, + // The global maximum SNR value across all radar frames, used for color scaling + globalMaxSnr: 1, + // Reference to the p5.js instance for the radar visualization + p5_instance: null, + // Reference to the p5.js instance for the speed graph visualization + speedGraphInstance: null, + // The filename of the loaded JSON file + jsonFilename: "", + // The filename of the loaded video file + videoFilename: "", + // The filename of the loaded CAN log file + canLogFilename: "", + // Boolean indicating if the close-up interaction mode is active + isCloseUpMode: false, + // Timestamp (from performance.now()) when the master clock started for synchronized playback + masterClockStart: 0, + // The media time (in seconds) of the video when the master clock started + mediaTimeStart: 0, + // Timestamp (from performance.now()) of the last synchronization check + lastSyncTime: 0, +}; diff --git a/steps/src/sync.js b/steps/src/sync.js index 152e27e..cee3cde 100644 --- a/steps/src/sync.js +++ b/steps/src/sync.js @@ -1,6 +1,14 @@ -import { appState } from './state.js'; -import { videoPlayer, speedSlider, offsetInput, stopBtn, updateFrame, updateCanDisplay, updateDebugOverlay } from './dom.js'; -import { findRadarFrameIndexForTime } from './utils.js'; +import { appState } from "./state.js"; +import { + videoPlayer, + speedSlider, + offsetInput, + stopBtn, + updateFrame, + updateCanDisplay, + updateDebugOverlay, +} from "./dom.js"; +import { findRadarFrameIndexForTime } from "./utils.js"; /** * The main animation loop that drives the synchronized playback. @@ -8,45 +16,61 @@ import { findRadarFrameIndexForTime } from './utils.js'; * finds the corresponding radar frame, and handles resynchronization with the video element. */ export function animationLoop() { - if (!appState.isPlaying) return; + if (!appState.isPlaying) return; - const playbackSpeed = parseFloat(speedSlider.value); - const elapsedRealTime = performance.now() - appState.masterClockStart; - const currentMediaTime = appState.mediaTimeStart + (elapsedRealTime / 1000) * playbackSpeed; + // Get the current playback speed from the slider + const playbackSpeed = parseFloat(speedSlider.value); + // Calculate the elapsed real time since the master clock started + const elapsedRealTime = performance.now() - appState.masterClockStart; + // Calculate the current media time based on the master clock, initial media time, elapsed real time, and playback speed + const currentMediaTime = + appState.mediaTimeStart + (elapsedRealTime / 1000) * playbackSpeed; - // Update radar frame based on the master clock - if (appState.vizData && appState.videoStartDate) { - const offsetMs = parseFloat(offsetInput.value) || 0; - const targetRadarTimeMs = (currentMediaTime * 1000); - const targetFrame = findRadarFrameIndexForTime(targetRadarTimeMs, appState.vizData); - if (targetFrame !== appState.currentFrame) { - updateFrame(targetFrame, false); - } + // Update radar frame based on the master clock + // Check if visualization data and video start date are available + if (appState.vizData && appState.videoStartDate) { + // Get the offset from the input field, default to 0 if not a valid number + const offsetMs = parseFloat(offsetInput.value) || 0; + // Calculate the target radar time in milliseconds + const targetRadarTimeMs = currentMediaTime * 1000; + // Find the index of the radar frame that corresponds to the target time + const targetFrame = findRadarFrameIndexForTime( + targetRadarTimeMs, + appState.vizData + ); + if (targetFrame !== appState.currentFrame) { + // Update the displayed frame if it's different from the current one + updateFrame(targetFrame, false); } + } - // Periodically check for drift between master clock and video element - const now = performance.now(); - if (now - appState.lastSyncTime > 500) { - const videoTime = videoPlayer.currentTime; - const drift = Math.abs(currentMediaTime - videoTime); - if (drift > 0.15) { // Resync if drift is > 150ms - console.warn(`Resyncing video. Drift was: ${drift.toFixed(3)}s`); - videoPlayer.currentTime = currentMediaTime; - } - appState.lastSyncTime = now; + // Periodically check for drift between master clock and video element + const now = performance.now(); + if (now - appState.lastSyncTime > 500) { + const videoTime = videoPlayer.currentTime; + const drift = Math.abs(currentMediaTime - videoTime); + // Resync if drift is > 150ms + if (drift > 0.15) { + // Resync if drift is > 150ms + console.warn(`Resyncing video. Drift was: ${drift.toFixed(3)}s`); + videoPlayer.currentTime = currentMediaTime; } + appState.lastSyncTime = now; + } - // Stop playback at the end of the video - if (currentMediaTime >= videoPlayer.duration) { - stopBtn.click(); - return; - } + // Stop playback at the end of the video + if (currentMediaTime >= videoPlayer.duration) { + stopBtn.click(); + return; + } - // Update other UI elements - updateCanDisplay(currentMediaTime); - updateDebugOverlay(currentMediaTime); - if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); + // Update CAN bus data display + updateCanDisplay(currentMediaTime); + // Update debug overlay information + updateDebugOverlay(currentMediaTime); + // Redraw the speed graph if an instance exists + if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); - // Request the next frame - requestAnimationFrame(animationLoop); + // Request the next frame + requestAnimationFrame(animationLoop); } diff --git a/steps/src/theme.js b/steps/src/theme.js index cae3052..1930f08 100644 --- a/steps/src/theme.js +++ b/steps/src/theme.js @@ -1,54 +1,58 @@ -import { appState } from './state.js'; -import { videoPlayer } from './dom.js'; -// --- DARK MODE: Step 3 - Add the JavaScript Logic --- -const themeToggleBtn = document.getElementById('theme-toggle'); -const darkIcon = document.getElementById('theme-toggle-dark-icon'); -const lightIcon = document.getElementById('theme-toggle-light-icon'); +import { appState } from "./state.js"; +import { videoPlayer } from "./dom.js"; +const themeToggleBtn = document.getElementById("theme-toggle"); +const darkIcon = document.getElementById("theme-toggle-dark-icon"); +const lightIcon = document.getElementById("theme-toggle-light-icon"); function setTheme(theme) { - if (theme === 'dark') { - document.documentElement.classList.add('dark'); - lightIcon.classList.remove('hidden'); - darkIcon.classList.add('hidden'); - localStorage.setItem('color-theme', 'dark'); - } else { - document.documentElement.classList.remove('dark'); - darkIcon.classList.remove('hidden'); - lightIcon.classList.add('hidden'); - localStorage.setItem('color-theme', 'light'); - } - - // Redraw the main radar plot - if (appState.p5_instance) appState.p5_instance.redraw(); + if (theme === "dark") { + document.documentElement.classList.add("dark"); + lightIcon.classList.remove("hidden"); + darkIcon.classList.add("hidden"); + localStorage.setItem("color-theme", "dark"); + } else { + document.documentElement.classList.remove("dark"); + darkIcon.classList.remove("hidden"); + lightIcon.classList.add("hidden"); + localStorage.setItem("color-theme", "light"); + } + + // Redraw the main radar plot to apply theme changes + if (appState.p5_instance) appState.p5_instance.redraw(); - // =================== THE FIX IS HERE =================== - if (appState.speedGraphInstance) { - // 1. Check if there's data to draw. - if ((appState.canData.length > 0 || appState.vizData) && videoPlayer.duration) { - // 2. Force it to take a new "photograph" with the new theme colors. - appState.speedGraphInstance.drawStaticGraphToBuffer(appState.canData, appState.vizData); - } - // 3. Display the new photograph. - appState.speedGraphInstance.redraw(); + // Redraw the speed graph to apply theme changes + if (appState.speedGraphInstance) { + // Check if there's data available to draw on the speed graph + if ( + (appState.canData.length > 0 || appState.vizData) && + videoPlayer.duration + ) { + // If data exists, redraw the static parts of the graph to a buffer + // This ensures the background and static elements reflect the new theme + appState.speedGraphInstance.drawStaticGraphToBuffer( + appState.canData, + appState.vizData + ); } - // ================= END OF FIX ========================= + // Request a redraw of the speed graph to display the updated buffer + appState.speedGraphInstance.redraw(); + } } - export function initializeTheme() { - const savedTheme = localStorage.getItem('color-theme'); - if (savedTheme) { - setTheme(savedTheme); + const savedTheme = localStorage.getItem("color-theme"); + if (savedTheme) { + setTheme(savedTheme); + } else { + // Default to light mode if no theme is saved + setTheme("light"); + } + + themeToggleBtn.addEventListener("click", () => { + if (document.documentElement.classList.contains("dark")) { + setTheme("light"); } else { - // Default to light mode if no theme is saved - setTheme('light'); + setTheme("dark"); } - - themeToggleBtn.addEventListener('click', () => { - if (document.documentElement.classList.contains('dark')) { - setTheme('light'); - } else { - setTheme('dark'); - } - }); + }); } diff --git a/steps/src/utils.js b/steps/src/utils.js index 5747d12..37ae08e 100644 --- a/steps/src/utils.js +++ b/steps/src/utils.js @@ -1,65 +1,126 @@ export function findRadarFrameIndexForTime(targetTimeMs, vizData) { - if (!vizData || vizData.radarFrames.length === 0) return -1; - let low = 0, high = vizData.radarFrames.length - 1, ans = 0; - while (low <= high) { - let mid = Math.floor((low + high) / 2); - if (vizData.radarFrames[mid].timestampMs <= targetTimeMs) { - ans = mid; low = mid + 1; - } - else { - high = mid - 1; - } + if (!vizData || vizData.radarFrames.length === 0) return -1; + // Initialize low, high, and answer variables for binary search + // 'ans' will store the index of the closest frame found so far + // 'low' and 'high' define the search range + let low = 0, + high = vizData.radarFrames.length - 1, + ans = 0; + // Perform binary search to find the radar frame whose timestamp is closest to, but not exceeding, the target time + while (low <= high) { + let mid = Math.floor((low + high) / 2); + // If the current frame's timestamp is less than or equal to the target time, + // it's a potential answer, and we try to find a more recent one in the right half. + if (vizData.radarFrames[mid].timestampMs <= targetTimeMs) { + ans = mid; + low = mid + 1; + } else { + // If the current frame's timestamp is greater than the target time, + // we need to look in the left half. + high = mid - 1; } - return ans; + } + // Return the index of the found radar frame. + return ans; } export function findLastCanIndexBefore(targetTime, canData) { - if (!canData || canData.length === 0) return -1; - let low = 0, high = canData.length - 1, ans = -1; - while (low <= high) { - let mid = Math.floor((low + high) / 2); - if (canData[mid].time <= targetTime) { - ans = mid; low = mid + 1; - } else { - high = mid - 1; + // Check for empty or invalid CAN data + if (!canData || canData.length === 0) return -1; - } + // Initialize low, high, and answer variables for binary search + // 'ans' will store the index of the last CAN data point found before the target time + // 'low' and 'high' define the search range + let low = 0, + high = canData.length - 1, + ans = -1; // Initialize ans to -1, indicating no suitable frame found yet. + while (low <= high) { + let mid = Math.floor((low + high) / 2); + if (canData[mid].time <= targetTime) { + ans = mid; + low = mid + 1; + } else { + high = mid - 1; } - return ans; + } + // Return the index of the found CAN data point. + return ans; } export function extractTimestampInfo(filename) { - if (!filename) return null; - let match = filename.match(/Tracks_(\d{8}_\d{6}\.\d{3})/); - if (match) return { timestampStr: match[1], format: 'json' }; - match = filename.match(/WIN_(\d{8})_(\d{2})_(\d{2})_(\d{2})/); - if (match) { - const timestamp = `${match[1]}_${match[2]}${match[3]}${match[4]}`; - return { timestampStr: timestamp, format: 'video' }; - } match = filename.match(/video_(\d{8}_\d{6})/); - if (match) return { - timestampStr: match[1], format: 'video' + // Return null if filename is not provided + if (!filename) return null; + // Try to match JSON filename pattern: "Tracks_YYYYMMDD_HHMMSS.ms" + // Example: Tracks_20231027_103000.123 + let match = filename.match(/Tracks_(\d{8}_\d{6}\.\d{3})/); + if (match) return { timestampStr: match[1], format: "json" }; + // Try to match video filename pattern (e.g., from GoPro): "WIN_YYYYMMDD_HH_MM_SS" + // Example: WIN_20231027_10_30_00 + match = filename.match(/WIN_(\d{8})_(\d{2})_(\d{2})_(\d{2})/); + if (match) { + const timestamp = `${match[1]}_${match[2]}${match[3]}${match[4]}`; + return { timestampStr: timestamp, format: "video" }; + } + // Try to match another common video filename pattern: "video_YYYYMMDD_HHMMSS" + // Example: video_20231027_103000 + match = filename.match(/video_(\d{8}_\d{6})/); + if (match) + return { + timestampStr: match[1], + format: "video", }; - - return null; + // If no pattern matches, return null + return null; } export function parseTimestamp(timestampStr, format) { - if (!timestampStr || !format) return null; - let day, month, year, hour, minute, second, millisecond = 0; - if (format === 'video') { - [year, month, day] = [timestampStr.substring(0, 4), timestampStr.substring(4, 6), timestampStr.substring(6, 8)]; - [hour, minute, second] = [timestampStr.substring(9, 11), timestampStr.substring(11, 13), timestampStr.substring(13, 15)]; - } - else if (format === 'json') { - [day, month, year] = [timestampStr.substring(0, 2), timestampStr.substring(2, 4), timestampStr.substring(4, 8)]; - [hour, minute, second, millisecond] = [timestampStr.substring(9, 11), timestampStr.substring(11, 13), timestampStr.substring(13, 15), parseInt(timestampStr.substring(16, 19))]; - } - else { - return null; - } - const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond)); - return isNaN(date.getTime()) ? null : date; + // Return null if timestamp string or format is not provided. + if (!timestampStr || !format) return null; + let day, + month, + year, + hour, + minute, + second, + millisecond = 0; + // Parse video timestamp format: YYYYMMDD_HH_MM_SS + // Example: 20231027_10_30_00 + if (format === "video") { + [year, month, day] = [ + timestampStr.substring(0, 4), + timestampStr.substring(4, 6), + timestampStr.substring(6, 8), + ]; + [hour, minute, second] = [ + timestampStr.substring(9, 11), + timestampStr.substring(11, 13), + timestampStr.substring(13, 15), + ]; + } + else if (format === "json") { + // Parse JSON timestamp format: DDMMYYYY_HHMMSS.ms + [day, month, year] = [ + timestampStr.substring(0, 2), + timestampStr.substring(2, 4), + timestampStr.substring(4, 8), + ]; + [hour, minute, second, millisecond] = [ + timestampStr.substring(9, 11), + timestampStr.substring(11, 13), + timestampStr.substring(13, 15), + parseInt(timestampStr.substring(16, 19)), + ]; + } else { + // Return null for unsupported formats + return null; + } // Create a Date object using UTC to avoid timezone issues + const date = new Date( + Date.UTC(year, month - 1, day, hour, minute, second, millisecond) + ); + + // Check if the created Date object is valid. + // If getTime() returns NaN, the date is invalid. + return isNaN(date.getTime()) ? null : date; } /** @@ -70,13 +131,19 @@ export function parseTimestamp(timestampStr, format) { * @returns {Function} Returns the new throttled function. */ export function throttle(func, delay) { - let lastCall = 0; - return function(...args) { - const now = new Date().getTime(); - if (now - lastCall < delay) { - return; - } - lastCall = now; - return func(...args); - }; -} \ No newline at end of file + // `lastCall` keeps track of the timestamp of the last successful invocation. + let lastCall = 0; + // Return a new function that, when called, will throttle the execution of the original function + return function (...args) { + // Get the current timestamp. + const now = new Date().getTime(); + + // If the time since the last call is less than the delay, do not execute the function + if (now - lastCall < delay) { + return; + } + // Otherwise, update the last call time and execute the original function + lastCall = now; + return func(...args); // Apply the original function with its arguments. + }; +}
-
-
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/steps/readme.md b/steps/readme.md
index 45091e9..a29c67c 100644
--- a/steps/readme.md
+++ b/steps/readme.md
@@ -1,10 +1,10 @@
Radar and Video Timestamp VisualizerThis is a high-precision, browser-based tool for visualizing radar point cloud data, object tracks, and CAN bus speed data, synchronized with a corresponding video file. The application was refactored from a single monolithic HTML file into a modern, modular JavaScript application for improved maintainability, performance, and future extensibility.
-Features:
+Features:
-*Synchronized Playback: Simultaneously plays a video file and visualizes radar data frames based on precise timestamps.
+\*Synchronized Playback: Simultaneously plays a video file and visualizes radar data frames based on precise timestamps.
-*Multi-File Support: Load and visualize data from three distinct sources:
+\*Multi-File Support: Load and visualize data from three distinct sources:
-JSON: Contains radar point clouds and tracked object data.
-Video: The ground-truth video corresponding to the radar data.
-CAN Log: A text log file containing vehicle speed data over time.
@@ -18,16 +18,15 @@ Features:
-Distinguish between stationary and moving objects with unique colors and markers.
-Adjustable SNR range for fine-tuning the visualization.
-*Playback Controls: Full control over the playback, including play, pause, stop, frame-by-frame stepping (using arrow keys), and a draggable timeline slider.
+\*Playback Controls: Full control over the playback, including play, pause, stop, frame-by-frame stepping (using arrow keys), and a draggable timeline slider.
-*Data Caching: Uses IndexedDB to cache loaded files, allowing for instant reloading of the last session.
+\*Data Caching: Uses IndexedDB to cache loaded files, allowing for instant reloading of the last session.
-*Dark/Light Theme: A theme toggle for user comfort that persists across sessions.
+\*Dark/Light Theme: A theme toggle for user comfort that persists across sessions.
+How to Run Locally:-
-How to Run Locally:-
-
-Because this project uses ES Modules (import/export), you cannot simply open the index.html file directly in your browser from the file system (file:///...).
+Because this project uses ES Modules (import/export), you cannot simply open the index.html file directly in your browser from the file system (file:///...).
You must serve the files using a local web server.The easiest way to do this is with Python or Node.js.
1.) Navigate to the Project Directory: Open your terminal or command prompt and change to the root directory of this project (the one containing index.html).
@@ -37,34 +36,34 @@ You must serve the files using a local web server.The easiest way to do this is
--# For Python 3.x
" python -m http.server "
---# Using Node.js (with serve):
+--# Using Node.js (with serve):
If you don't have serve, install it first:
" npm install -g serve.serve ".
-3.) Open in Browser: Open your web browser and navigate to the URL provided by the server (usually http://localhost:8000 or http://localhost:3000).
-
+3.) Open in Browser: Open your web browser and navigate to the URL provided by the server (usually http://localhost:8000 or http://localhost:3000). # Navigate to the server URL
Project StructureThe project has been refactored into a modular structure to separate concerns. All JavaScript source code resides in the src/ directory..
-├── index.html # The main HTML shell for the application
-├── README.md # This documentation file
+├── index.html # The main HTML shell for the application
+├── README.md # This documentation file
└── src/
- ├── constants.js # Shared constants (e.g., radar bounds, FPS)
- ├── db.js # IndexedDB logic for caching files
- ├── dom.js # DOM element references and UI update functions
- ├── drawUtils.js # p5.js drawing helper functions for the radar sketch
- ├── fileParsers.js # Logic for parsing JSON and CAN log files
- ├── main.js # The main application entry point and event wiring
- ├── modal.js # Logic for the pop-up modal dialog
- ├── state.js # Centralized application state management
- ├── sync.js # The core animation loop and playback synchronization logic
- ├── theme.js # Dark/Light mode theme switching logic
- ├── utils.js # General utility functions (e.g., binary search, throttling)
+ ├── constants.js # Shared constants (e.g., radar bounds, FPS)
+ ├── db.js # IndexedDB logic for caching files
+ ├── dom.js # DOM element references and UI update functions
+ ├── drawUtils.js # p5.js drawing helper functions for the radar sketch
+ ├── fileParsers.js # Logic for parsing JSON and CAN log files
+ ├── main.js # The main application entry point and event wiring
+ ├── modal.js # Logic for the pop-up modal dialog
+ ├── state.js # Centralized application state management
+ ├── sync.js # The core animation loop and playback synchronization logic
+ ├── theme.js # Dark/Light mode theme switching logic
+ ├── utils.js # General utility functions (e.g., binary search, throttling)
└── p5/
- ├── radarSketch.js # The p5.js sketch for the main radar visualization
+ ├── radarSketch.js # The p5.js sketch for the main radar visualization
└── speedGraphSketch.js # The p5.js sketch for the speed graph
How to Use the Application
+
- Load Files: Use the "Load JSON", "Load Video", and "Load CAN Log" buttons to select your data files. The application works best when all three are loaded. The application will automatically attempt to calculate the time offset between the JSON and video files based on their filenames.
- Playback: Use the "Play/Pause" and "Stop" buttons to control the timeline. You can also click and drag the main timeline slider or use the Left and Right arrow keys to step through frames.
- Adjust Speed: Use the "Speed" slider to change the playback rate of the video and visualization.
-- Use Toggles: Use the checkboxes to toggle various visualization features, such as showing object tracks, coloring points by SNR, or displaying debug information.
\ No newline at end of file
+- Use Toggles: Use the checkboxes to toggle various visualization features, such as showing object tracks, coloring points by SNR, or displaying debug information.
diff --git a/steps/src/constants.js b/steps/src/constants.js
index bc37f60..faba47c 100644
--- a/steps/src/constants.js
+++ b/steps/src/constants.js
@@ -1,7 +1,12 @@
+// Maximum number of points to store for each object's trajectory.
export const MAX_TRAJECTORY_LENGTH = 50;
+// Frames per second for the video playback.
export const VIDEO_FPS = 30;
-
+// Minimum X-coordinate for the radar plot in meters.
export const RADAR_X_MIN = -20;
+// Maximum X-coordinate for the radar plot in meters.
export const RADAR_X_MAX = 20;
+// Minimum Y-coordinate for the radar plot in meters.
export const RADAR_Y_MIN = 0;
-export const RADAR_Y_MAX = 60;
+// Maximum Y-coordinate for the radar plot in meters.
+export const RADAR_Y_MAX = 60;
diff --git a/steps/src/db.js b/steps/src/db.js
index ad575fa..8f2fb78 100644
--- a/steps/src/db.js
+++ b/steps/src/db.js
@@ -1,55 +1,79 @@
-
-
-// --- IndexedDB for Caching --- //
+// -------------------------- IndexedDB for Caching ----------------- //
let db;
-//---Initialize DB---//
+//---------------------------Initialize DB----------------------------//
+// Initializes the IndexedDB database.
+// @param {function} callback - A function to be called once the database is initialized.
export function initDB(callback) {
- const request = indexedDB.open('visualizerDB', 1);
- request.onupgradeneeded = function (event) {
- const db = event.target.result;
- if (!db.objectStoreNames.contains('files')) {
- db.createObjectStore('files');
- }
- };
- request.onsuccess = function (event) {
- db = event.target.result;
- console.log("Database initialized");
- if (callback) callback();
- }; request.onerror = function (event) {
- console.error("IndexedDB error:", event.target.errorCode);
- };
+ // 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");
+ }
+ };
+
+ // Event handler for a successful database opening.
+ request.onsuccess = function (event) {
+ db = event.target.result;
+ console.log("Database initialized");
+ // Call the provided callback function.
+ if (callback) callback();
+ };
+
+ // Event handler for an error during database opening.
+ request.onerror = function (event) {
+ console.error("IndexedDB error:", event.target.errorCode);
+ };
}
-//---save file---//
+//---------------------------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 (!db) return;
- const transaction = db.transaction(['files'], 'readwrite');
- const store = transaction.objectStore('files');
- const request = store.put(value, key);
- request.onsuccess = () => console.log(`File '${key}' saved to DB.`);
- request.onerror = (event) => console.error(`Error saving file '${key}':`, event.target.error);
+ // If the database is not initialized, return.
+ 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---//
+//---------------------------load file--------------------------------//
export function loadFileFromDB(key, callback) {
- if (!db) return;
- const transaction = db.transaction(['files'], 'readonly');
- const store = transaction.objectStore('files'); const request = store.get(key);
- request.onsuccess = function () {
- if (request.result) {
- callback(request.result);
- }
- else {
- console.log(`File '${key}' not found in DB.`);
- callback(null);
- }
- };
- request.onerror = (event) => {
- console.error(`Error loading file '${key}':`, event.target.error);
- callback(null);
- };
+ // 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);
+ } else {
+ console.log(`File '${key}' not found in DB.`);
+ callback(null);
+ }
+ }; // Event handler for an error during loading.
+ request.onerror = (event) => {
+ console.error(`Error loading file '${key}':`, event.target.error);
+ callback(null);
+ };
}
diff --git a/steps/src/dom.js b/steps/src/dom.js
index c362069..a22d14f 100644
--- a/steps/src/dom.js
+++ b/steps/src/dom.js
@@ -1,6 +1,6 @@
import { appState } from "./state.js";
import { findLastCanIndexBefore } from "./utils.js";
-import { VIDEO_FPS } from "./constants.js";
+import { VIDEO_FPS } from "./constants.js"; // Import VIDEO_FPS for debug overlay calculations
// --- DOM Element References --- //
@@ -65,28 +65,26 @@ export const modalCancelBtn = document.getElementById("modal-cancel-btn");
export const toggleCloseUp = document.getElementById("toggle-close-up");
//----------------------UPDATE FRAME Function----------------------//
-
-// Located in: src/dom.js
-
+// Updates the UI to reflect the current radar frame and synchronizes video playback.
export function updateFrame(frame, forceVideoSeek) {
if (
!appState.vizData ||
frame < 0 ||
frame >= appState.vizData.radarFrames.length
- )
- return;
+ ) // Exit if no visualization data or invalid frame.
+ return; // Exit if no visualization data or invalid frame
appState.currentFrame = frame;
timelineSlider.value = appState.currentFrame;
frameCounter.textContent = `Frame: ${appState.currentFrame + 1} / ${
appState.vizData.radarFrames.length
}`;
const frameData = appState.vizData.radarFrames[appState.currentFrame];
- if (toggleEgoSpeed.checked && frameData) {
- const egoVy_kmh = (frameData.egoVelocity[1] * 3.6).toFixed(1);
+ if (toggleEgoSpeed.checked && frameData) { // Update ego speed display if enabled.
+ const egoVy_kmh = (frameData.egoVelocity[1] * 3.6).toFixed(1); // Convert m/s to km/h and format
egoSpeedDisplay.textContent = `Ego: ${egoVy_kmh} km/h`;
egoSpeedDisplay.classList.remove("hidden");
} else {
- egoSpeedDisplay.classList.add("hidden");
+ egoSpeedDisplay.classList.add("hidden"); // Hide ego speed display.
}
// --- Start of fix ---
@@ -102,14 +100,14 @@ export function updateFrame(frame, forceVideoSeek) {
const offsetMs = parseFloat(offsetInput.value) || 0;
const targetRadarTimeMs = frameData.timestampMs;
const targetVideoTimeSec = (targetRadarTimeMs - offsetMs) / 1000;
- if (targetVideoTimeSec >= 0 && targetVideoTimeSec <= videoPlayer.duration) {
- if (Math.abs(videoPlayer.currentTime - targetVideoTimeSec) > 0.05) {
- videoPlayer.currentTime = targetVideoTimeSec;
+ if (targetVideoTimeSec >= 0 && targetVideoTimeSec <= videoPlayer.duration) { // Ensure target time is within video duration
+ if (Math.abs(videoPlayer.currentTime - targetVideoTimeSec) > 0.05) { // Check for significant drift
+ videoPlayer.currentTime = targetVideoTimeSec; // Seek video if drift is significant
}
// MODIFIED: Use the calculated target time for our updates, not the stale videoPlayer.currentTime
- timeForUpdates = targetVideoTimeSec;
+ timeForUpdates = targetVideoTimeSec; // Update time for subsequent UI updates
}
- }
+ } // End of forceVideoSeek block
if (!appState.isPlaying) {
// MODIFIED: Use our new synchronized time variable
@@ -118,23 +116,23 @@ export function updateFrame(frame, forceVideoSeek) {
}
// --- End of fix ---
- if (appState.p5_instance) appState.p5_instance.redraw();
- if (appState.speedGraphInstance && !appState.isPlaying)
+ if (appState.p5_instance) appState.p5_instance.redraw(); // Redraw radar sketch
+ if (appState.speedGraphInstance && !appState.isPlaying) // Redraw speed graph if not playing.
appState.speedGraphInstance.redraw();
}
//----------------------RESET VISUALIZATION Function----------------------//
-
+// Resets the visualization to its initial state.
export function resetVisualization() {
appState.isPlaying = false;
playPauseBtn.textContent = "Play";
const numFrames = appState.vizData.radarFrames.length;
timelineSlider.max = numFrames > 0 ? numFrames - 1 : 0;
- updateFrame(0, true);
+ updateFrame(0, true); // Update to the first frame and force video seek
}
//----------------------CAN DISPLAY UPDATE Function----------------------//
-
+// Updates the CAN speed display based on the current media time.
export function updateCanDisplay(currentMediaTime) {
if (
appState.canData.length > 0 &&
@@ -148,19 +146,19 @@ export function updateCanDisplay(currentMediaTime) {
appState.canData
);
if (canIndex !== -1) {
- const currentCanMessage = appState.canData[canIndex];
- canSpeedDisplay.textContent = `CAN: ${currentCanMessage.speed} km/h`;
+ const currentCanMessage = appState.canData[canIndex]; // Get the CAN message at the found index
+ canSpeedDisplay.textContent = `CAN: ${currentCanMessage.speed} km/h`; // Display CAN speed
canSpeedDisplay.classList.remove("hidden");
} else {
- canSpeedDisplay.classList.add("hidden");
+ canSpeedDisplay.classList.add("hidden"); // Hide CAN speed display
}
} else {
- canSpeedDisplay.classList.add("hidden");
+ canSpeedDisplay.classList.add("hidden"); // Hide CAN speed display.
}
}
//----------------------DEBUG OVERLAY UPDATE Function----------------------//
-
+// Updates the debug overlay with various synchronization and time information.
export function updateDebugOverlay(currentMediaTime) {
// Check the state of both debug toggles
const isDebug1Visible = toggleDebugOverlay.checked;
@@ -168,13 +166,13 @@ export function updateDebugOverlay(currentMediaTime) {
// If neither is checked, hide the overlay and stop
if (!isDebug1Visible && !isDebug2Visible) {
- debugOverlay.classList.add("hidden");
+ debugOverlay.classList.add("hidden"); // Hide debug overlay
return;
}
-
- debugOverlay.classList.remove("hidden");
+ // If at least one is checked, show the overlay
+ debugOverlay.classList.remove("hidden"); // Show debug overlay.
let content = [];
-
+
// --- Logic for the original debug overlay ---
if (isDebug1Visible) {
content.push(`--- Basic Info ---`);
@@ -188,9 +186,9 @@ export function updateDebugOverlay(currentMediaTime) {
.toISOString()
.split("T")[1]
.replace("Z", "")}`
- );
+ ); // Format and display video absolute time
} else {
- content.push("Video not loaded...");
+ content.push("Video not loaded..."); // Indicate video not loaded.
}
if (
appState.vizData &&
@@ -206,9 +204,9 @@ export function updateDebugOverlay(currentMediaTime) {
.toISOString()
.split("T")[1]
.replace("Z", "")}`
- );
+ ); // Format and display radar absolute time
}
- }
+ }
// --- Logic for the new advanced debug overlay ---
if (isDebug2Visible) {
@@ -223,10 +221,10 @@ export function updateDebugOverlay(currentMediaTime) {
const targetRadarTimeMs = currentRadarFrame.timestampMs;
const driftMs = currentMediaTime * 1000 - targetRadarTimeMs;
- // Style the drift value to be green if sync is good, and red if it's off
+ // Style the drift value to be green if sync is good, and red if it's off.
const driftColor = Math.abs(driftMs) > 40 ? "#FF6347" : "#98FB98"; // Tomato red or Pale green
- content.push(`Video Time (s): ${currentMediaTime.toFixed(3)}`);
+ content.push(`Video Time (s): ${currentMediaTime.toFixed(3)}`); // Display current video time
content.push(`Target Radar Time (ms): ${targetRadarTimeMs.toFixed(0)}`);
content.push(
`Drift (ms): ${driftMs.toFixed(0)}`
@@ -237,11 +235,11 @@ export function updateDebugOverlay(currentMediaTime) {
content.push(
`Radar Start Time: ${new Date(appState.radarStartTimeMs).toISOString()}`
);
- content.push(`Calculated Offset (ms): ${offsetInput.value}`);
+ content.push(`Calculated Offset (ms): ${offsetInput.value}`); // Display calculated offset.
} else {
- content.push("Load video and radar data to see sync info.");
+ content.push("Load video and radar data to see sync info."); // Prompt to load data.
}
}
- debugOverlay.innerHTML = content.join("
+
+
-
+
+
+
"); + debugOverlay.innerHTML = content.join("
"); // Update debug overlay content. } diff --git a/steps/src/drawUtils.js b/steps/src/drawUtils.js index 04cbb9b..4ddabd5 100644 --- a/steps/src/drawUtils.js +++ b/steps/src/drawUtils.js @@ -1,38 +1,44 @@ import { - RADAR_X_MAX, - RADAR_X_MIN, - RADAR_Y_MAX, - RADAR_Y_MIN, - MAX_TRAJECTORY_LENGTH -} from './constants.js'; + RADAR_X_MAX, + RADAR_X_MIN, + RADAR_Y_MAX, + RADAR_Y_MIN, + MAX_TRAJECTORY_LENGTH, +} from "./constants.js"; +import { appState } from "./state.js"; import { - appState -} from './state.js'; -import { - toggleSnrColor, - toggleClusterColor, - toggleInlierColor, - toggleFrameNorm, - toggleVelocity, - toggleStationaryColor -} from './dom.js'; - -// Color definitions moved from the sketch + toggleSnrColor, + toggleClusterColor, + toggleInlierColor, + toggleFrameNorm, + toggleVelocity, + toggleStationaryColor, +} from "./dom.js"; + +// Defines a set of SNR (Signal-to-Noise Ratio) colors. export const snrColors = (p) => ({ - c1: p.color(0, 0, 255), - c2: p.color(0, 255, 255), - c3: p.color(0, 255, 0), - c4: p.color(255, 255, 0), - c5: p.color(255, 0, 0) + c1: p.color(0, 0, 255), // Blue + c2: p.color(0, 255, 255), // Cyan + c3: p.color(0, 255, 0), // Green + c4: p.color(255, 255, 0), // Yellow + c5: p.color(255, 0, 0), // Red }); +// Defines a palette of colors for different clusters. export const clusterColors = (p) => [ - p.color(230, 25, 75), p.color(60, 180, 75), p.color(0, 130, 200), - p.color(245, 130, 48), p.color(145, 30, 180), p.color(70, 240, 240), - p.color(240, 50, 230), p.color(210, 245, 60), p.color(128, 0, 0), - p.color(0, 128, 128) + p.color(230, 25, 75), // Red + p.color(60, 180, 75), // Green + p.color(0, 130, 200), // Blue + p.color(245, 130, 48), // Orange + p.color(145, 30, 180), // Purple + p.color(70, 240, 240), // Cyan + p.color(240, 50, 230), // Magenta + p.color(210, 245, 60), // Lime Green + p.color(128, 0, 0), // Maroon + p.color(0, 128, 128), // Teal ]; +// Defines colors for stationary and moving objects. export const stationaryColor = (p) => p.color(218, 165, 32); // Goldenrod export const movingColor = (p) => p.color(255, 0, 255); // Magenta @@ -42,20 +48,38 @@ export const movingColor = (p) => p.color(255, 0, 255); // Magenta * @param {object} plotScales - The calculated scales for plotting. */ export function drawStaticRegionsToBuffer(p, b, plotScales) { - b.clear(); - b.push(); - b.translate(b.width / 2, b.height * 0.95); - b.scale(1, -1); - b.stroke(100, 100, 100, 150); - b.strokeWeight(1); - b.drawingContext.setLineDash([8, 8]); - const a1 = p.radians(30), - a2 = p.radians(150); - const len = 70; - b.line(0, 0, len * p.cos(a1) * plotScales.plotScaleX, len * p.sin(a1) * plotScales.plotScaleY); - b.line(0, 0, len * p.cos(a2) * plotScales.plotScaleX, len * p.sin(a2) * plotScales.plotScaleY); - b.drawingContext.setLineDash([]); - b.pop(); + b.clear(); + b.push(); + // Translate to the bottom center of the buffer. + b.translate(b.width / 2, b.height * 0.95); + // Flip the Y-axis to match radar coordinates (Y increases upwards). + b.scale(1, -1); + // Set stroke properties for the static region lines. + b.stroke(100, 100, 100, 150); + b.strokeWeight(1); + // Set dashed line pattern. + b.drawingContext.setLineDash([8, 8]); + // Define angles for the radar beams. + const a1 = p.radians(30), + a2 = p.radians(150); + const len = 70; + // Draw the first static region line. + b.line( + 0, + 0, + len * p.cos(a1) * plotScales.plotScaleX, + len * p.sin(a1) * plotScales.plotScaleY + ); + // Draw the second static region line. + b.line( + 0, + 0, + len * p.cos(a2) * plotScales.plotScaleX, + len * p.sin(a2) * plotScales.plotScaleY + ); + // Reset line dash pattern. + b.drawingContext.setLineDash([]); + b.pop(); } /** @@ -64,40 +88,73 @@ export function drawStaticRegionsToBuffer(p, b, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawAxes(p, plotScales) { + p.push(); + // Determine axis and text colors based on the current theme (dark/light mode). + const axisColor = document.documentElement.classList.contains("dark") + ? p.color(100) + : p.color(220); + const mainAxisColor = document.documentElement.classList.contains("dark") + ? p.color(150) + : p.color(180); + const textColor = document.documentElement.classList.contains("dark") + ? p.color(200) + : p.color(150); + // Draw horizontal grid lines. + p.stroke(axisColor); + p.strokeWeight(1); + for (let y = 5; y <= RADAR_Y_MAX; y += 5) + p.line( + RADAR_X_MIN * plotScales.plotScaleX, + y * plotScales.plotScaleY, + RADAR_X_MAX * plotScales.plotScaleX, + y * plotScales.plotScaleY + ); + // Draw vertical grid lines. + for (let x = -15; x <= 15; x += 5) { + if (x === 0) continue; + p.line( + x * plotScales.plotScaleX, + RADAR_Y_MIN * plotScales.plotScaleY, + x * plotScales.plotScaleX, + RADAR_Y_MAX * plotScales.plotScaleY + ); + } + p.stroke(mainAxisColor); + p.line( + RADAR_X_MIN * plotScales.plotScaleX, + 0, + RADAR_X_MAX * plotScales.plotScaleX, + 0 + ); + p.line( + 0, + RADAR_Y_MIN * plotScales.plotScaleY, + 0, + RADAR_Y_MAX * plotScales.plotScaleY + ); + // Draw Y-axis labels. + p.fill(textColor); + p.noStroke(); + p.textSize(10); + for (let y = 5; y <= RADAR_Y_MAX; y += 5) { p.push(); - const axisColor = document.documentElement.classList.contains('dark') ? p.color(100) : p.color(220); - const mainAxisColor = document.documentElement.classList.contains('dark') ? p.color(150) : p.color(180); - const textColor = document.documentElement.classList.contains('dark') ? p.color(200) : p.color(150); - p.stroke(axisColor); - p.strokeWeight(1); - for (let y = 5; y <= RADAR_Y_MAX; y += 5) p.line(RADAR_X_MIN * plotScales.plotScaleX, y * plotScales.plotScaleY, RADAR_X_MAX * plotScales.plotScaleX, y * plotScales.plotScaleY); - for (let x = -15; x <= 15; x += 5) { - if (x === 0) continue; - p.line(x * plotScales.plotScaleX, RADAR_Y_MIN * plotScales.plotScaleY, x * plotScales.plotScaleX, RADAR_Y_MAX * plotScales.plotScaleY); - } - p.stroke(mainAxisColor); - p.line(RADAR_X_MIN * plotScales.plotScaleX, 0, RADAR_X_MAX * plotScales.plotScaleX, 0); - p.line(0, RADAR_Y_MIN * plotScales.plotScaleY, 0, RADAR_Y_MAX * plotScales.plotScaleY); - p.fill(textColor); - p.noStroke(); - p.textSize(10); - for (let y = 5; y <= RADAR_Y_MAX; y += 5) { - p.push(); - p.translate(5, y * plotScales.plotScaleY); - p.scale(1, -1); - p.text(y, 0, 4); - p.pop(); - } - for (let x = -15; x <= 15; x += 5) { - if (x === 0) continue; - p.push(); - p.translate(x * plotScales.plotScaleX, -10); - p.scale(1, -1); - p.textAlign(p.CENTER); - p.text(x, 0, 0); - p.pop(); - } + p.translate(5, y * plotScales.plotScaleY); + // Flip text vertically to align with flipped Y-axis. + p.scale(1, -1); + p.text(y, 0, 4); p.pop(); + } + // Draw X-axis labels. + for (let x = -15; x <= 15; x += 5) { + if (x === 0) continue; + p.push(); + p.translate(x * plotScales.plotScaleX, -10); + p.scale(1, -1); + p.textAlign(p.CENTER); + p.text(x, 0, 0); + p.pop(); + } + p.pop(); } /** @@ -107,50 +164,88 @@ export function drawAxes(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawPointCloud(p, points, plotScales) { - p.strokeWeight(4); - const useSnr = toggleSnrColor.checked; - const useCluster = toggleClusterColor.checked; - const useInlier = toggleInlierColor.checked; - const useFrameNorm = toggleFrameNorm.checked; - let minSnr = appState.globalMinSnr, - maxSnr = appState.globalMaxSnr; - - if (useSnr && useFrameNorm && points.length > 0) { - const snrVals = points.map(p => p.snr).filter(snr => snr !== null); - if (snrVals.length > 1) { - minSnr = Math.min(...snrVals); - maxSnr = Math.max(...snrVals); - } else if (snrVals.length === 1) { - minSnr = snrVals[0] - 1; - maxSnr = snrVals[0] + 1; - } + // Set stroke weight for points. + p.strokeWeight(4); + // Get state of various toggles from the DOM. + const useSnr = toggleSnrColor.checked; + const useCluster = toggleClusterColor.checked; + const useInlier = toggleInlierColor.checked; + const useFrameNorm = toggleFrameNorm.checked; + let minSnr = appState.globalMinSnr, // Initialize with global SNR range. + maxSnr = appState.globalMaxSnr; + + if (useSnr && useFrameNorm && points.length > 0) { + const snrVals = points.map((p) => p.snr).filter((snr) => snr !== null); + if (snrVals.length > 1) { + minSnr = Math.min(...snrVals); + maxSnr = Math.max(...snrVals); + } else if (snrVals.length === 1) { + minSnr = snrVals[0] - 1; + maxSnr = snrVals[0] + 1; } - // This check is important. The p5_instance might not be fully initialized yet. - if (useSnr && p.drawSnrLegendToBuffer) p.drawSnrLegendToBuffer(minSnr, maxSnr); - - const localClusterColors = clusterColors(p); - const localSnrColors = snrColors(p); - - for (const pt of points) { - if (pt && pt.x !== null && pt.y !== null) { - if (useCluster && pt.clusterNumber !== null) { - p.stroke(pt.clusterNumber > 0 ? localClusterColors[(pt.clusterNumber - 1) % localClusterColors.length] : 128); - } else if (useInlier) { - p.stroke(pt.isOutlier === false ? p.color(0, 255, 0) : pt.isOutlier === true ? p.color(255, 0, 0) : 128); - } else if (useSnr && pt.snr !== null) { - const amt = p.map(pt.snr, minSnr, maxSnr, 0, 1, true); - let c; - if (amt < 0.25) c = p.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); - else if (amt < 0.5) c = p.lerpColor(localSnrColors.c2, localSnrColors.c3, (amt - 0.25) / 0.25); - else if (amt < 0.75) c = p.lerpColor(localSnrColors.c3, localSnrColors.c4, (amt - 0.5) / 0.25); - else c = p.lerpColor(localSnrColors.c4, localSnrColors.c5, (amt - 0.75) / 0.25); - p.stroke(c); - } else { - p.stroke(0, 150, 255); - } - p.point(pt.x * plotScales.plotScaleX, pt.y * plotScales.plotScaleY); - } + } + // Draw SNR legend if enabled and p5 instance is ready. + if (useSnr && p.drawSnrLegendToBuffer) + p.drawSnrLegendToBuffer(minSnr, maxSnr); + + // Get local color instances for cluster and SNR. + const localClusterColors = clusterColors(p); + const localSnrColors = snrColors(p); + + // Iterate through each point in the point cloud. + for (const pt of points) { + if (pt && pt.x !== null && pt.y !== null) { + // Apply cluster coloring if enabled. + if (useCluster && pt.clusterNumber !== null) { + p.stroke( + pt.clusterNumber > 0 + ? localClusterColors[ + (pt.clusterNumber - 1) % localClusterColors.length + ] + : 128 + // Default to gray if cluster number is 0 or invalid. + ); + } else if (useInlier) { + p.stroke( + pt.isOutlier === false + ? p.color(0, 255, 0) + : pt.isOutlier === true + ? p.color(255, 0, 0) + : 128 + // Default to gray if inlier status is unknown. + ); + } else if (useSnr && pt.snr !== null) { + const amt = p.map(pt.snr, minSnr, maxSnr, 0, 1, true); + let c; + if (amt < 0.25) + c = p.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); + else if (amt < 0.5) + c = p.lerpColor( + localSnrColors.c2, + localSnrColors.c3, + (amt - 0.25) / 0.25 + ); + else if (amt < 0.75) + c = p.lerpColor( + localSnrColors.c3, + localSnrColors.c4, + (amt - 0.5) / 0.25 + ); + else + c = p.lerpColor( + localSnrColors.c4, + localSnrColors.c5, + (amt - 0.75) / 0.25 + // Interpolate color based on SNR value. + ); + p.stroke(c); + // Default point color if no specific coloring is applied. + } else { + p.stroke(0, 150, 255); + } + p.point(pt.x * plotScales.plotScaleX, pt.y * plotScales.plotScaleY); } + } } /** @@ -159,37 +254,62 @@ export function drawPointCloud(p, points, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawTrajectories(p, plotScales) { - for (const track of appState.vizData.tracks) { - const logs = track.historyLog.filter(log => log.frameIdx <= appState.currentFrame + 1); - if (logs.length < 2) continue; - - const lastLog = logs[logs.length - 1]; - if (appState.currentFrame + 1 - lastLog.frameIdx > MAX_TRAJECTORY_LENGTH) continue; - - const isCurrentlyStationary = lastLog.isStationary; - let maxLen = isCurrentlyStationary ? Math.floor(MAX_TRAJECTORY_LENGTH / 4) : MAX_TRAJECTORY_LENGTH; - - let trajPts = logs.filter(log => log.correctedPosition && log.correctedPosition[0] !== null).map(log => log.correctedPosition); - if (trajPts.length > maxLen) { - trajPts = trajPts.slice(trajPts.length - maxLen); - } - - p.push(); - p.noFill(); - if (isCurrentlyStationary) { - p.stroke(34, 139, 34, 220); // Forest green - p.strokeWeight(1); - p.drawingContext.setLineDash([3, 3]); - } else { - p.stroke(document.documentElement.classList.contains('dark') ? p.color(10, 170, 255, 250) : p.color(0, 50, 255, 250)); - p.strokeWeight(1.5); - } - p.beginShape(); - for (const pos of trajPts) p.vertex(pos[0] * plotScales.plotScaleX, pos[1] * plotScales.plotScaleY); - p.endShape(); - p.drawingContext.setLineDash([]); - p.pop(); + // Iterate through each tracked object. + for (const track of appState.vizData.tracks) { + // Filter history logs to include only frames up to the current one. + const logs = track.historyLog.filter( + (log) => log.frameIdx <= appState.currentFrame + 1 + ); + // Skip if there are not enough points to draw a trajectory. + if (logs.length < 2) continue; + + // Get the last log entry. + const lastLog = logs[logs.length - 1]; + // Skip if the trajectory is too old. + if (appState.currentFrame + 1 - lastLog.frameIdx > MAX_TRAJECTORY_LENGTH) + continue; + + // Adjust trajectory length based on whether the object is stationary. + const isCurrentlyStationary = lastLog.isStationary; + let maxLen = isCurrentlyStationary + ? Math.floor(MAX_TRAJECTORY_LENGTH / 4) + : MAX_TRAJECTORY_LENGTH; + + // Filter and map corrected positions for the trajectory. + let trajPts = logs + .filter( + (log) => log.correctedPosition && log.correctedPosition[0] !== null + ) + .map((log) => log.correctedPosition); + // Slice the trajectory to the maximum allowed length. + if (trajPts.length > maxLen) { + trajPts = trajPts.slice(trajPts.length - maxLen); } + // Begin drawing the trajectory. + p.push(); + p.noFill(); + if (isCurrentlyStationary) { + p.stroke(34, 139, 34, 220); // Forest green + p.strokeWeight(1); + p.drawingContext.setLineDash([3, 3]); + } else { + // Set color and weight for moving trajectories based on theme. + p.stroke( + document.documentElement.classList.contains("dark") + ? p.color(10, 170, 255, 250) + : p.color(0, 50, 255, 250) + ); + p.strokeWeight(1.5); + } + // Draw the trajectory as a continuous line. + p.beginShape(); + for (const pos of trajPts) + p.vertex(pos[0] * plotScales.plotScaleX, pos[1] * plotScales.plotScaleY); + // End drawing and reset line dash. + p.endShape(); + p.drawingContext.setLineDash([]); + p.pop(); + } } /** @@ -198,65 +318,93 @@ export function drawTrajectories(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function drawTrackMarkers(p, plotScales) { - const showDetails = toggleVelocity.checked; - const useStationary = toggleStationaryColor.checked; - const textColor = document.documentElement.classList.contains('dark') ? p.color(255) : p.color(0); - const localStationaryColor = stationaryColor(p); - const localMovingColor = movingColor(p); - - for (const track of appState.vizData.tracks) { - const log = track.historyLog.find(log => log.frameIdx === appState.currentFrame + 1); - if (log) { - const pos = (log.correctedPosition && log.correctedPosition[0] !== null) ? log.correctedPosition : log.predictedPosition; - if (pos && pos.length === 2 && pos[0] !== null && pos[1] !== null) { - const size = 5, - x = pos[0] * plotScales.plotScaleX, - y = pos[1] * plotScales.plotScaleY; - let velocityColor = p.color(255, 0, 255, 200); - - p.push(); - p.strokeWeight(2); - if (useStationary && log.isStationary === true) { - p.stroke(localStationaryColor); - p.noFill(); - p.rectMode(p.CENTER); - p.square(x, y, size * 1.5); - velocityColor = localStationaryColor; - } else { - let markerColor = p.color(0, 0, 255); - if (useStationary && log.isStationary === false) { - markerColor = localMovingColor; - velocityColor = localMovingColor; - } - p.stroke(markerColor); - p.line(x - size, y, x + size, y); - p.line(x, y - size, x, y + size); - } - p.pop(); + const showDetails = toggleVelocity.checked; + const useStationary = toggleStationaryColor.checked; + // Determine text color based on theme. + const textColor = document.documentElement.classList.contains("dark") + ? p.color(255) + : p.color(0); + // Get local color instances for stationary and moving objects. + const localStationaryColor = stationaryColor(p); + const localMovingColor = movingColor(p); + + // Iterate through each tracked object. + for (const track of appState.vizData.tracks) { + // Find the log entry for the current frame. + const log = track.historyLog.find( + (log) => log.frameIdx === appState.currentFrame + 1 + ); + if (log) { + const pos = + log.correctedPosition && log.correctedPosition[0] !== null + ? log.correctedPosition // Use corrected position if available. + : log.predictedPosition; + if (pos && pos.length === 2 && pos[0] !== null && pos[1] !== null) { + const size = 5, + x = pos[0] * plotScales.plotScaleX, + y = pos[1] * plotScales.plotScaleY; + let velocityColor = p.color(255, 0, 255, 200); + p.push(); + p.strokeWeight(2); + if (useStationary && log.isStationary === true) { + p.stroke(localStationaryColor); + p.noFill(); + p.rectMode(p.CENTER); + p.square(x, y, size * 1.5); + velocityColor = localStationaryColor; // Set velocity color to stationary. + } else { + let markerColor = p.color(0, 0, 255); + if (useStationary && log.isStationary === false) { + // If not stationary, use moving color. + markerColor = localMovingColor; + // Set velocity color to moving. + velocityColor = localMovingColor; + } + p.stroke(markerColor); + p.line(x - size, y, x + size, y); + p.line(x, y - size, x, y + size); + } + p.pop(); - if (showDetails && log.predictedVelocity && log.predictedVelocity[0] !== null) { - const [vx, vy] = log.predictedVelocity; - if (log.isStationary === false) { - p.push(); - p.stroke(velocityColor); - p.strokeWeight(2); - p.line(x, y, (pos[0] + vx) * plotScales.plotScaleX, (pos[1] + vy) * plotScales.plotScaleY); - p.pop(); - } - const speed = (p.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1); - const ttc = (log.ttc !== null && isFinite(log.ttc) && log.ttc < 100) ? `TTC: ${log.ttc.toFixed(1)}s` : ''; - const text = `ID: ${track.id} | ${speed} km/h\n${ttc}`; - p.push(); - p.fill(textColor); - p.noStroke(); - p.scale(1, -1); - p.textSize(12); - p.text(text, x + 10, -y); - p.pop(); - } - } + // Draw velocity vector and text details if enabled. + if ( + showDetails && + log.predictedVelocity && + log.predictedVelocity[0] !== null + ) { + const [vx, vy] = log.predictedVelocity; + if (log.isStationary === false) { + // Only draw velocity for moving objects. + p.push(); + p.stroke(velocityColor); + p.strokeWeight(2); + p.line( + x, + y, + (pos[0] + vx) * plotScales.plotScaleX, + (pos[1] + vy) * plotScales.plotScaleY + ); + p.pop(); + } // Calculate speed in km/h. + const speed = (p.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1); + // Format TTC (Time To Collision) if available and finite. + const ttc = + log.ttc !== null && isFinite(log.ttc) && log.ttc < 100 + ? `TTC: ${log.ttc.toFixed(1)}s` + : ""; + // Construct info text. + const text = `ID: ${track.id} | ${speed} km/h\n${ttc}`; + p.push(); + p.fill(textColor); + p.noStroke(); + p.scale(1, -1); + p.textSize(12); + p.text(text, x + 10, -y); + p.pop(); } + } } + } } /** @@ -265,83 +413,106 @@ export function drawTrackMarkers(p, plotScales) { * @param {object} plotScales - The calculated scales for plotting. */ export function handleCloseUpDisplay(p, plotScales) { - const frameData = appState.vizData.radarFrames[appState.currentFrame]; - if (!frameData || !frameData.pointCloud) return; - - const hoveredPoints = []; - const radius = 10; - - for (const pt of frameData.pointCloud) { - if (pt.x === null || pt.y === null) continue; - const screenX = (pt.x * plotScales.plotScaleX) + p.width / 2; - const screenY = p.height * 0.95 - (pt.y * plotScales.plotScaleY); - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); - if (d < radius) { - hoveredPoints.push({ - point: pt, - screenX: screenX, - screenY: screenY - }); - } + // Get current frame data. + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + if (!frameData || !frameData.pointCloud) return; + + const hoveredPoints = []; + const radius = 10; + + // Iterate through point cloud to find hovered points. + for (const pt of frameData.pointCloud) { + if (pt.x === null || pt.y === null) continue; + // Convert radar coordinates to screen coordinates. + const screenX = pt.x * plotScales.plotScaleX + p.width / 2; + const screenY = p.height * 0.95 - pt.y * plotScales.plotScaleY; // Y-axis is inverted for drawing. + const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + if (d < radius) { + hoveredPoints.push({ + point: pt, + screenX: screenX, + screenY: screenY, + }); } + } - if (hoveredPoints.length > 0) { - hoveredPoints.sort((a, b) => a.screenY - b.screenY); - - p.push(); - p.textSize(12); - const lineHeight = 15; - const boxPadding = 8; - let boxWidth = 0; - const infoStrings = []; - - for (const hovered of hoveredPoints) { - const pt = hovered.point; - const vel = pt.velocity !== null ? pt.velocity.toFixed(2) : 'N/A'; - const snr = pt.snr !== null ? pt.snr.toFixed(1) : 'N/A'; - const infoText = `X:${pt.x.toFixed(2)}, Y:${pt.y.toFixed(2)} | V:${vel}, SNR:${snr}`; - infoStrings.push(infoText); - boxWidth = Math.max(boxWidth, p.textWidth(infoText)); - } - - const boxHeight = (infoStrings.length * lineHeight) + (boxPadding * 2); - boxWidth += (boxPadding * 2); - - const xOffset = 20; - let boxX = p.mouseX + xOffset; - let boxY = p.mouseY - (boxHeight / 2); - - if (boxX + boxWidth > p.width) { - boxX = p.mouseX - boxWidth - xOffset; - } - boxY = p.constrain(boxY, 0, p.height - boxHeight); - - const highlightColor = p.color(46, 204, 113); - - for (let i = 0; i < hoveredPoints.length; i++) { - const hovered = hoveredPoints[i]; - p.noFill(); - p.stroke(highlightColor); - p.strokeWeight(2); - p.ellipse(hovered.screenX, hovered.screenY, 15, 15); - p.strokeWeight(1); - p.line(boxX + boxPadding, boxY + boxPadding + (i * lineHeight) + (lineHeight / 2), hovered.screenX, hovered.screenY); - } - - const bgColor = document.documentElement.classList.contains('dark') ? p.color(20, 20, 30, 255) : p.color(245, 245, 245, 255); - p.fill(bgColor); - p.stroke(highlightColor); - p.strokeWeight(1); - p.rect(boxX, boxY, boxWidth, boxHeight, 4); + // If points are hovered, display detailed info. + if (hoveredPoints.length > 0) { + // Sort points by Y-coordinate for consistent display. + hoveredPoints.sort((a, b) => a.screenY - b.screenY); - const textColor = document.documentElement.classList.contains('dark') ? p.color(230) : p.color(20); - p.fill(textColor); - p.noStroke(); - p.textAlign(p.LEFT, p.TOP); - for (let i = 0; i < infoStrings.length; i++) { - p.text(infoStrings[i], boxX + boxPadding, boxY + boxPadding + (i * lineHeight)); - } + p.push(); + p.textSize(12); + const lineHeight = 15; // Line height for text in the info box. + const boxPadding = 8; + let boxWidth = 0; + const infoStrings = []; + + for (const hovered of hoveredPoints) { + const pt = hovered.point; + const vel = pt.velocity !== null ? pt.velocity.toFixed(2) : "N/A"; + const snr = pt.snr !== null ? pt.snr.toFixed(1) : "N/A"; + const infoText = `X:${pt.x.toFixed(2)}, Y:${pt.y.toFixed( + 2 + )} | V:${vel}, SNR:${snr}`; + infoStrings.push(infoText); + boxWidth = Math.max(boxWidth, p.textWidth(infoText)); + } // Calculate box dimensions. + const boxHeight = infoStrings.length * lineHeight + boxPadding * 2; + boxWidth += boxPadding * 2; + + // Position the info box relative to the mouse. + const xOffset = 20; + let boxX = p.mouseX + xOffset; + let boxY = p.mouseY - boxHeight / 2; + + // Adjust box position to stay within canvas bounds. + if (boxX + boxWidth > p.width) { + boxX = p.mouseX - boxWidth - xOffset; + } + boxY = p.constrain(boxY, 0, p.height - boxHeight); + + // Highlight hovered points and draw connecting lines to the info box. + const highlightColor = p.color(46, 204, 113); + + for (let i = 0; i < hoveredPoints.length; i++) { + const hovered = hoveredPoints[i]; + p.noFill(); + p.stroke(highlightColor); + p.strokeWeight(2); + p.ellipse(hovered.screenX, hovered.screenY, 15, 15); + p.strokeWeight(1); + p.line( + boxX + boxPadding, + boxY + boxPadding + i * lineHeight + lineHeight / 2, + hovered.screenX, + hovered.screenY + ); + } - p.pop(); + // Draw the info box background and border. + const bgColor = document.documentElement.classList.contains("dark") + ? p.color(20, 20, 30, 255) + : p.color(245, 245, 245, 255); + p.fill(bgColor); + p.stroke(highlightColor); + p.strokeWeight(1); + p.rect(boxX, boxY, boxWidth, boxHeight, 4); + // Draw the text content inside the info box. + const textColor = document.documentElement.classList.contains("dark") + ? p.color(230) + : p.color(20); + p.fill(textColor); + p.noStroke(); + p.textAlign(p.LEFT, p.TOP); + for (let i = 0; i < infoStrings.length; i++) { + p.text( + infoStrings[i], + boxX + boxPadding, + boxY + boxPadding + i * lineHeight + ); } + + p.pop(); + } } diff --git a/steps/src/fileParsers.js b/steps/src/fileParsers.js index 898d0c9..d3ae467 100644 --- a/steps/src/fileParsers.js +++ b/steps/src/fileParsers.js @@ -1,88 +1,139 @@ - //--------------------CAN-LOG PARSER------------------------// - export function processCanLog(logContent, videoStartDate) { - // The function receives everything it needs as arguments. - // It no longer looks at the global state. - - if (!videoStartDate) { - // If the video isn't loaded, it can't do its job. - // It returns an object describing the problem. - return { error: "Please load the video file first to synchronize the CAN log.", rawCanLogText: logContent }; + // The function now receives all necessary data (logContent, videoStartDate) as arguments, + // making it a pure function that doesn't rely on global state. + if (!videoStartDate) { + // If videoStartDate is not provided, it means the video file hasn't been loaded yet. + // The CAN log cannot be synchronized without it, so an error is returned. + return { + // Error message to be displayed to the user. + error: "Please load the video file first to synchronize the CAN log.", + // The raw log content is returned so it can be stored and processed later + // once the videoStartDate becomes available. + rawCanLogText: logContent, + }; + } + + // This is a NEW, LOCAL variable, only for this function. + const canData = []; + const lines = logContent.split("\n"); + // Regular expression to parse CAN log lines. + // It captures time components (HH:MM:SS:ms), CAN ID, and data bytes. + const logRegex = + /(\d{2}):(\d{2}):(\d{2}):(\d{4})\s+Rx\s+\d+\s+0x([0-9a-fA-F]+)\s+s\s+\d+((?:\s+[0-9a-fA-F]{2})+)/; + // The specific CAN ID (0x30F) we are interested in for speed data. + const canIdToDecode = "30F"; + + for (const line of lines) { + const match = line.match(logRegex); + // Check if the line matches the regex and if the CAN ID is the one we want. + if (match && match[5].toUpperCase() === canIdToDecode) { + // Extract time components from the regex match. + const [h, m, s, ms] = [ + parseInt(match[1]), + parseInt(match[2]), + parseInt(match[3]), + parseInt(match[4].substring(0, 3)), + ]; + // Create a Date object for the CAN message timestamp. + // It uses the video's start date and then sets the time components from the log. + const msgDate = new Date(videoStartDate); + msgDate.setUTCHours(h, m, s, ms); + // Extract and parse data bytes from the regex match. + const dataBytes = match[6] + .trim() + .split(/\s+/) + .map((hex) => parseInt(hex, 16)); + // Check if there are enough data bytes to extract speed information. + if (dataBytes.length >= 2) { + // Decode the raw speed value from the first two data bytes. + // This specific decoding logic is based on the CAN message format. + const rawVal = (dataBytes[0] << 3) | (dataBytes[1] >> 5); + // Convert the raw value to km/h and format it to one decimal place. + const speed = (rawVal * 0.1).toFixed(1); + canData.push({ time: msgDate.getTime(), speed: speed }); + } } + } + // Sort the processed CAN data points by their timestamp. + canData.sort((a, b) => a.time - b.time); - // This is a NEW, LOCAL variable, only for this function. - const canData = []; - const lines = logContent.split('\n'); - const logRegex = /(\d{2}):(\d{2}):(\d{2}):(\d{4})\s+Rx\s+\d+\s+0x([0-9a-fA-F]+)\s+s\s+\d+((?:\s+[0-9a-fA-F]{2})+)/; - const canIdToDecode = '30F'; - - for (const line of lines) { - const match = line.match(logRegex); - if (match && match[5].toUpperCase() === canIdToDecode) { - const [h, m, s, ms] = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4].substring(0, 3))]; - const msgDate = new Date(videoStartDate); - msgDate.setUTCHours(h, m, s, ms); - const dataBytes = match[6].trim().split(/\s+/).map(hex => parseInt(hex, 16)); - if (dataBytes.length >= 2) { - const rawVal = (dataBytes[0] << 3) | (dataBytes[1] >> 5); - const speed = (rawVal * 0.1).toFixed(1); - canData.push({ time: msgDate.getTime(), speed: speed }); - } - } - } - // It sorts the LOCAL canData array. - canData.sort((a, b) => a.time - b.time); + console.log( + `Processed ${canData.length} CAN messages for ID ${canIdToDecode}.` + ); - console.log(`Processed ${canData.length} CAN messages for ID ${canIdToDecode}.`); - - // It returns the finished product in a structured object. - return { data: canData }; + // It returns the finished product in a structured object. + // The processed CAN data is returned under the 'data' key. + return { data: canData }; } - //--------------------JSON PARSER------------------------// -// Add this new function to src/fileParsers.js - -export function parseVisualizationJson(jsonString, radarStartTimeMs, videoStartDate) { - try { - const cleanJsonString = jsonString.replace(/\b(Infinity|NaN|-Infinity)\b/gi, 'null'); - const vizData = JSON.parse(cleanJsonString); - - if (!vizData.radarFrames || vizData.radarFrames.length === 0) { - return { error: 'Error: The JSON file does not contain any radar frames.' }; - } - - // Perform timestamp calculations - vizData.radarFrames.forEach(frame => { - frame.timestampMs = (radarStartTimeMs + frame.timestamp) - videoStartDate.getTime(); - }); +export function parseVisualizationJson( + jsonString, + radarStartTimeMs, + videoStartDate +) { + try { + // Replace Infinity, NaN, and -Infinity with "null" to prevent JSON.parse errors. + const cleanJsonString = jsonString.replace( + /\b(Infinity|NaN|-Infinity)\b/gi, + "null" + ); + // Parse the cleaned JSON string into a JavaScript object. + const vizData = JSON.parse(cleanJsonString); + + // Validate if the parsed data contains radar frames. + if (!vizData.radarFrames || vizData.radarFrames.length === 0) { + return { + error: "Error: The JSON file does not contain any radar frames.", + }; + } - // Calculate SNR range from the data - let snrValues = [], totalPoints = 0; - vizData.radarFrames.forEach(frame => { - if (frame.pointCloud && frame.pointCloud.length > 0) { - totalPoints += frame.pointCloud.length; - frame.pointCloud.forEach(p => { - if (p.snr !== null) snrValues.push(p.snr); - }); - } + // Perform timestamp calculations for each radar frame. + // The `timestampMs` for each frame is calculated relative to the video's start time, + // taking into account the `radarStartTimeMs` (extracted from JSON filename) + // and the `videoStartDate` (extracted from video filename). + // This ensures synchronization between radar data and video. + vizData.radarFrames.forEach((frame) => { + frame.timestampMs = + radarStartTimeMs + frame.timestamp - videoStartDate.getTime(); + }); + + // Calculate SNR range from the data + let snrValues = [], + totalPoints = 0; // Counter for total points across all frames. + vizData.radarFrames.forEach((frame) => { + if (frame.pointCloud && frame.pointCloud.length > 0) { + totalPoints += frame.pointCloud.length; + frame.pointCloud.forEach((p) => { + // Collect SNR values, ignoring nulls. + if (p.snr !== null) snrValues.push(p.snr); }); + } + }); - if (totalPoints === 0) { - console.warn('Warning: Loaded frames contain no point cloud data.'); - } - - const minSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0; - const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; - - // Return the finished data package - return { data: vizData, minSnr: minSnr, maxSnr: maxSnr }; - - } catch (error) { - console.error("JSON Parsing Error:", error); - return { error: 'Error parsing JSON file. Please check file format. Error: ' + error.message }; + // Warn if no point cloud data was found in the loaded frames. + if (totalPoints === 0) { + console.warn("Warning: Loaded frames contain no point cloud data."); } -} \ No newline at end of file + + // Determine the global minimum and maximum SNR values from the collected data. + // These values are used for scaling the SNR color legend. + // Default to 0 and 1 if no SNR values are found to prevent errors. + const minSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0; + const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; + + // Return the finished data package + // This object contains the processed visualization data, and the calculated min/max SNR. + return { data: vizData, minSnr: minSnr, maxSnr: maxSnr }; + } catch (error) { + console.error("JSON Parsing Error:", error); + return { + error: + "Error parsing JSON file. Please check file format. Error: " + + error.message, + }; + } +} diff --git a/steps/src/main.js b/steps/src/main.js index 30f7d33..19322c8 100644 --- a/steps/src/main.js +++ b/steps/src/main.js @@ -40,7 +40,7 @@ import { findLastCanIndexBefore, extractTimestampInfo, parseTimestamp, - throttle + throttle, } from "./utils.js"; // import state machine from './src/state.js'; import { appState } from "./state.js"; @@ -92,19 +92,21 @@ import { updateCanDisplay, updateDebugOverlay, } from "./dom.js"; -// import modal dialog logic from './src/modal.js'; +// Import modal dialog logic from './src/modal.js'. import { showModal } from "./modal.js"; -// import initialize theme from './src/theme.js'; +// Import theme initialization from './src/theme.js'. import { initializeTheme } from "./theme.js"; -// import caching logic from './src/db.js'; +// Import caching logic from './src/db.js'. import { initDB, saveFileToDB, loadFileFromDB } from "./db.js"; +// 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); } +// Event listener for loading JSON file. loadJsonBtn.addEventListener("click", () => jsonFileInput.click()); loadVideoBtn.addEventListener("click", () => videoFileInput.click()); loadCanBtn.addEventListener("click", () => canFileInput.click()); @@ -116,6 +118,7 @@ clearCacheBtn.addEventListener("click", async () => { window.location.reload(); } }); +// Event listener for JSON file input change. jsonFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -174,6 +177,7 @@ jsonFileInput.addEventListener("change", (event) => { }; reader.readAsText(file); }); +// Event listener for video file input change. videoFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -222,7 +226,7 @@ videoFileInput.addEventListener("change", (event) => { } }; }); - +// Event listener for CAN file input change. canFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; @@ -269,10 +273,12 @@ canFileInput.addEventListener("change", (event) => { }; reader.readAsText(file); }); +// Event listener for offset input change. offsetInput.addEventListener("input", () => { autoOffsetIndicator.classList.add("hidden"); localStorage.setItem("visualizerOffset", offsetInput.value); }); +// Event listener for apply SNR button click. applySnrBtn.addEventListener("click", () => { const newMin = parseFloat(snrMinInput.value), newMax = parseFloat(snrMaxInput.value); @@ -291,6 +297,7 @@ applySnrBtn.addEventListener("click", () => { appState.p5_instance.redraw(); } }); +// Event listener for play/pause button click. playPauseBtn.addEventListener("click", () => { if (!appState.vizData && !videoPlayer.src) return; appState.isPlaying = !appState.isPlaying; @@ -307,6 +314,7 @@ playPauseBtn.addEventListener("click", () => { if (videoPlayer.src) videoPlayer.pause(); } }); +// Event listener for stop button click. stopBtn.addEventListener("click", () => { videoPlayer.pause(); appState.isPlaying = false; @@ -318,18 +326,24 @@ stopBtn.addEventListener("click", () => { } if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); }); -timelineSlider.addEventListener('input', throttle((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(); -}, 16 )); // 50ms throttle delay +// Event listener for timeline slider input. +timelineSlider.addEventListener( + "input", + throttle((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(); + }, 16) +); // Throttle delay for smoother updates. +// Currently set at 16 ms to achieve smooth 60fps. +// Event listener for speed slider input. speedSlider.addEventListener("input", (event) => { const speed = parseFloat(event.target.value); videoPlayer.playbackRate = speed; @@ -337,7 +351,8 @@ speedSlider.addEventListener("input", (event) => { }); // ADD THE NEW TOGGLE TO THE ARRAY -const colorToggles = [ +// Array of color toggles. +const colorToggles = [ toggleSnrColor, toggleClusterColor, toggleInlierColor, @@ -353,14 +368,14 @@ colorToggles.forEach((t) => { if (appState.p5_instance) appState.p5_instance.redraw(); }); }); - +// Event listeners for various feature toggles. [ toggleVelocity, toggleEgoSpeed, toggleFrameNorm, toggleTracks, toggleDebugOverlay, - toggleDebug2Overlay + toggleDebug2Overlay, ].forEach((t) => { t.addEventListener("change", () => { if (appState.p5_instance) { @@ -371,10 +386,12 @@ colorToggles.forEach((t) => { ); appState.p5_instance.redraw(); } - if (t === toggleDebugOverlay || t === toggleDebug2Overlay) { updateDebugOverlay(videoPlayer.currentTime)}; + if (t === toggleDebugOverlay || t === toggleDebug2Overlay) { + updateDebugOverlay(videoPlayer.currentTime); + } }); }); - +// Event listener for close-up toggle. toggleCloseUp.addEventListener("change", () => { appState.isCloseUpMode = toggleCloseUp.checked; if (appState.p5_instance) { @@ -389,11 +406,12 @@ toggleCloseUp.addEventListener("change", () => { } } }); - +// Event listener for video ended event. videoPlayer.addEventListener("ended", () => { appState.isPlaying = false; playPauseBtn.textContent = "Play"; }); +// Event listener for keyboard arrow key presses to navigate frames. document.addEventListener("keydown", (event) => { if ( !appState.vizData || @@ -420,6 +438,7 @@ document.addEventListener("keydown", (event) => { appState.masterClockStart = performance.now(); } }); +// Calculates and sets the time offset between JSON and video timestamps. function calculateAndSetOffset() { const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename); const videoTimestampInfo = extractTimestampInfo(appState.videoFilename); @@ -453,10 +472,10 @@ function calculateAndSetOffset() { } } -// --- Application Initialization --- +// Application Initialization: Event listener for DOMContentLoaded. document.addEventListener("DOMContentLoaded", () => { initializeTheme(); - console.log("DEBUG: DOMContentLoaded fired. Starting session load."); + console.log("DEBUG: DOMContentLoaded fired. Starting session load."); // Log for debugging. initDB(() => { console.log("DEBUG: Database initialized."); @@ -469,8 +488,9 @@ document.addEventListener("DOMContentLoaded", () => { appState.canLogFilename = localStorage.getItem("canLogFilename"); // This is important: it sets videoStartDate if a video filename is cached - calculateAndSetOffset(); + calculateAndSetOffset(); // Calculate offset based on cached filenames. + // Promises to load files from IndexedDB. const videoPromise = new Promise((resolve) => loadFileFromDB("video", resolve) ); @@ -480,7 +500,7 @@ document.addEventListener("DOMContentLoaded", () => { const canLogPromise = new Promise((resolve) => loadFileFromDB("canLogText", resolve) ); - + // Once all files are loaded from DB, process them. Promise.all([videoPromise, jsonPromise, canLogPromise]) .then(([videoBlob, jsonString, canLogText]) => { console.log("DEBUG: All data fetched from IndexedDB."); @@ -488,7 +508,7 @@ document.addEventListener("DOMContentLoaded", () => { const processAllData = () => { console.log("DEBUG: Processing all loaded data."); - // 1. Process JSON (only if we have a video date) + // 1. Process JSON (only if video start date is available). if (jsonString && appState.videoStartDate) { const result = parseVisualizationJson( jsonString, @@ -506,7 +526,7 @@ document.addEventListener("DOMContentLoaded", () => { } } - // 2. Process CAN log (only if we have a video date) + // 2. Process CAN log (only if video start date is available). if (canLogText && appState.videoStartDate) { const result = processCanLog(canLogText, appState.videoStartDate); if (!result.error) { @@ -514,7 +534,7 @@ document.addEventListener("DOMContentLoaded", () => { } } - // 3. Update all UI elements now that data is processed + // 3. Update all UI elements now that data is processed. if (appState.vizData) { resetVisualization(); canvasPlaceholder.style.display = "none"; @@ -536,15 +556,14 @@ document.addEventListener("DOMContentLoaded", () => { } }; - // This is the main controller - // --- THIS IS THE CORRECTED CODE --- + // Main controller for processing data based on video availability. 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. + // If there's no video, process other data immediately. processAllData(); } }) diff --git a/steps/src/modal.js b/steps/src/modal.js index 7e9cc1b..0d2e2b0 100644 --- a/steps/src/modal.js +++ b/steps/src/modal.js @@ -1,33 +1,46 @@ - -import { modalText, modalCancelBtn, modalContainer, modalOverlay, modalContent, modalOkBtn } from './dom.js'; +import { + modalText, + modalCancelBtn, + modalContainer, + modalOverlay, + modalContent, + modalOkBtn, +} from "./dom.js"; // --- Custom Modal Logic --- // - let modalResolve = null; - export function showModal(message, isConfirm = false) { - return new Promise(resolve => { - modalText.textContent = message; - modalCancelBtn.classList.toggle('hidden', !isConfirm); - modalContainer.classList.remove('hidden'); - setTimeout(() => { - modalOverlay.classList.remove('opacity-0'); - modalContent.classList.remove('scale-95'); - } - , 10); - modalResolve = resolve; - }); - } - function hideModal(value) { - modalOverlay.classList.add('opacity-0'); - modalContent.classList.add('scale-95'); - setTimeout(() => { - modalContainer.classList.add('hidden'); - if (modalResolve) modalResolve(value); - }, 200); - } - - - //----------------------Modal Event Listeners----------------------// +// Variable to store the resolve function of the Promise, allowing the modal to return a value. +let modalResolve = null; +export function showModal(message, isConfirm = false) { + return new Promise((resolve) => { + // Set the message text for the modal. + modalText.textContent = message; + // Show/hide the cancel button based on whether it's a confirmation modal. + modalCancelBtn.classList.toggle("hidden", !isConfirm); + // Make the modal container visible. + modalContainer.classList.remove("hidden"); + // Add a slight delay for CSS transitions to take effect, making the modal appear smoothly. + setTimeout(() => { + modalOverlay.classList.remove("opacity-0"); + modalContent.classList.remove("scale-95"); + }, 10); + // Store the resolve function to be called when the modal is closed. + modalResolve = resolve; + }); +} +// Hides the modal and resolves the Promise with the given value. +function hideModal(value) { + modalOverlay.classList.add("opacity-0"); + modalContent.classList.add("scale-95"); + setTimeout(() => { + modalContainer.classList.add("hidden"); + if (modalResolve) modalResolve(value); + }, 200); +} - modalOkBtn.addEventListener('click', () => hideModal(true)); - modalCancelBtn.addEventListener('click', () => hideModal(false)); - modalOverlay.addEventListener('click', () => hideModal(false)); \ No newline at end of file +//----------------------Modal Event Listeners----------------------// +// Event listener for the "OK" button. Resolves the modal Promise with 'true'. +modalOkBtn.addEventListener("click", () => hideModal(true)); +// Event listener for the "Cancel" button. Resolves the modal Promise with 'false'. +modalCancelBtn.addEventListener("click", () => hideModal(false)); +// Event listener for clicking on the modal overlay (outside the content). Resolves the modal Promise with 'false'. +modalOverlay.addEventListener("click", () => hideModal(false)); diff --git a/steps/src/p5/radarSketch.js b/steps/src/p5/radarSketch.js index 84ba3d2..d52b410 100644 --- a/steps/src/p5/radarSketch.js +++ b/steps/src/p5/radarSketch.js @@ -1,126 +1,170 @@ +import { appState } from "../state.js"; import { - appState -} from '../state.js'; + RADAR_X_MAX, + // Define radar plot boundaries + RADAR_X_MIN, + RADAR_Y_MAX, + RADAR_Y_MIN, +} from "../constants.js"; +import { canvasContainer, toggleSnrColor, toggleTracks } from "../dom.js"; import { - RADAR_X_MAX, - RADAR_X_MIN, - RADAR_Y_MAX, - RADAR_Y_MIN -} from '../constants.js'; -import { - canvasContainer, - toggleSnrColor, - toggleTracks -} from '../dom.js'; -import { - drawStaticRegionsToBuffer, - drawAxes, - drawPointCloud, - drawTrajectories, - drawTrackMarkers, - snrColors, - handleCloseUpDisplay // BUG FIX 1: Import the close-up handler -} from '../drawUtils.js'; + drawStaticRegionsToBuffer, + drawAxes, + drawPointCloud, + // Import drawing utility functions + drawTrajectories, + drawTrackMarkers, + snrColors, + handleCloseUpDisplay, // BUG FIX 1: Import the close-up handler +} from "../drawUtils.js"; -export const radarSketch = function(p) { - let plotScales = { - plotScaleX: 1, - plotScaleY: 1 - }; - let staticBackgroundBuffer, snrLegendBuffer; - - function calculatePlotScales() { - const hPad = 0.05, - vPad = 0.05, - bOff = 0.05; - const aW = p.width * (1 - 2 * hPad); - const aH = p.height * (1 - bOff - vPad); - plotScales.plotScaleX = aW / (RADAR_X_MAX - RADAR_X_MIN); - plotScales.plotScaleY = aH / (RADAR_Y_MAX - RADAR_Y_MIN); - } +export const radarSketch = function (p) { + // Object to store calculated plot scales + let plotScales = { + plotScaleX: 1, + plotScaleY: 1, + }; + // p5.Graphics buffers for static elements to optimize drawing + let staticBackgroundBuffer, snrLegendBuffer; - p.setup = function() { - let canvas = p.createCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); - canvas.parent('canvas-container'); - staticBackgroundBuffer = p.createGraphics(p.width, p.height); - snrLegendBuffer = p.createGraphics(100, 450); + // Function to calculate scaling factors for radar coordinates to canvas pixels + function calculatePlotScales() { + // Padding and offset values for the plot area + const hPad = 0.05, + vPad = 0.05, + bOff = 0.05; + // Calculate available width and height for the plot + const aW = p.width * (1 - 2 * hPad); + const aH = p.height * (1 - bOff - vPad); + // Determine plot scales based on radar boundaries and available canvas space + plotScales.plotScaleX = aW / (RADAR_X_MAX - RADAR_X_MIN); + plotScales.plotScaleY = aH / (RADAR_Y_MAX - RADAR_Y_MIN); + } - calculatePlotScales(); - p.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); - drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); - p.noLoop(); - }; + p.setup = function () { + // Create the p5.js canvas and attach it to the specified DOM element + let canvas = p.createCanvas( + canvasContainer.offsetWidth, + canvasContainer.offsetHeight + ); + canvas.parent("canvas-container"); + // Initialize graphics buffers + staticBackgroundBuffer = p.createGraphics(p.width, p.height); + snrLegendBuffer = p.createGraphics(100, 450); - p.draw = function() { - p.background(document.documentElement.classList.contains('dark') ? p.color(55, 65, 81) : 255); - if (!appState.vizData) return; + calculatePlotScales(); + p.drawSnrLegendToBuffer(appState.globalMinSnr, appState.globalMaxSnr); + drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); + p.noLoop(); + // Disable continuous looping, redraw will be called manually + }; - p.image(staticBackgroundBuffer, 0, 0); + p.draw = function () { + // Set background color based on current theme (dark/light) + p.background( + document.documentElement.classList.contains("dark") + ? p.color(55, 65, 81) + : 255 + ); + // If no visualization data is loaded, stop drawing + if (!appState.vizData) return; - p.push(); - p.translate(p.width / 2, p.height * 0.95); - p.scale(1, -1); + // Draw the pre-rendered static background elements + p.image(staticBackgroundBuffer, 0, 0); - calculatePlotScales(); - drawAxes(p, plotScales); + // Apply transformations for radar coordinate system (origin at bottom-center, Y-axis inverted) + p.push(); + p.translate(p.width / 2, p.height * 0.95); + p.scale(1, -1); - const frameData = appState.vizData.radarFrames[appState.currentFrame]; - if (frameData) { - if (toggleTracks.checked) { - drawTrajectories(p, plotScales); - drawTrackMarkers(p, plotScales); - } - drawPointCloud(p, frameData.pointCloud, plotScales); - } - p.pop(); + // Recalculate plot scales (important for window resizing) + calculatePlotScales(); + // Draw coordinate axes + drawAxes(p, plotScales); - // BUG FIX 1: Call the close-up handler if the mode is active - if (appState.isCloseUpMode) { - handleCloseUpDisplay(p, plotScales); - } + // Get current frame data + const frameData = appState.vizData.radarFrames[appState.currentFrame]; + if (frameData) { + // Draw object trajectories and markers if enabled + if (toggleTracks.checked) { + drawTrajectories(p, plotScales); + drawTrackMarkers(p, plotScales); + } + // Draw the point cloud for the current frame + drawPointCloud(p, frameData.pointCloud, plotScales); + } + p.pop(); - if (toggleSnrColor.checked) { - p.image(snrLegendBuffer, 10, p.height - snrLegendBuffer.height - 10); - } - }; + // BUG FIX 1: Call the close-up handler if the mode is active + if (appState.isCloseUpMode) { + handleCloseUpDisplay(p, plotScales); + } - p.drawSnrLegendToBuffer = function(minV, maxV) { - const b = snrLegendBuffer; - const localSnrColors = snrColors(p); - b.clear(); - b.push(); - const lx = 10, - ly = 20, - lw = 15, - lh = 400; - for (let i = 0; i < lh; i++) { - const amt = b.map(i, 0, lh, 1, 0); - let c; - if (amt < 0.25) c = b.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); - else if (amt < 0.5) c = b.lerpColor(localSnrColors.c2, localSnrColors.c3, (amt - 0.25) / 0.25); - else if (amt < 0.75) c = b.lerpColor(localSnrColors.c3, localSnrColors.c4, (amt - 0.5) / 0.25); - else c = b.lerpColor(localSnrColors.c4, localSnrColors.c5, (amt - 0.75) / 0.25); - b.stroke(c); - b.line(lx, ly + i, lx + lw, ly + i); - } - b.fill(document.documentElement.classList.contains('dark') ? 255 : 0); - b.noStroke(); - b.textSize(10); - b.textAlign(b.LEFT, b.CENTER); - b.text(maxV.toFixed(1), lx + lw + 5, ly); - b.text(minV.toFixed(1), lx + lw + 5, ly + lh); - b.text("SNR", lx, ly - 10); - b.pop(); - }; + // Draw the SNR legend if enabled + if (toggleSnrColor.checked) { + p.image(snrLegendBuffer, 10, p.height - snrLegendBuffer.height - 10); + } + }; + // Function to draw the SNR legend to its buffer + p.drawSnrLegendToBuffer = function (minV, maxV) { + // Reference to the SNR legend buffer + const b = snrLegendBuffer; + const localSnrColors = snrColors(p); + b.clear(); + b.push(); + const lx = 10, + ly = 20, + lw = 15, + // Dimensions for the color bar + lh = 400; + for (let i = 0; i < lh; i++) { + const amt = b.map(i, 0, lh, 1, 0); + let c; + if (amt < 0.25) + c = b.lerpColor(localSnrColors.c1, localSnrColors.c2, amt / 0.25); + else if (amt < 0.5) + c = b.lerpColor( + localSnrColors.c2, + localSnrColors.c3, + (amt - 0.25) / 0.25 + ); + else if (amt < 0.75) + c = b.lerpColor( + localSnrColors.c3, + localSnrColors.c4, + (amt - 0.5) / 0.25 + ); + else + c = b.lerpColor( + localSnrColors.c4, + localSnrColors.c5, + // Interpolate colors based on position + (amt - 0.75) / 0.25 + ); + b.stroke(c); + b.line(lx, ly + i, lx + lw, ly + i); + } + // Set text color based on theme + b.fill(document.documentElement.classList.contains("dark") ? 255 : 0); + b.noStroke(); + b.textSize(10); + b.textAlign(b.LEFT, b.CENTER); + // Draw min/max SNR values and label + b.text(maxV.toFixed(1), lx + lw + 5, ly); + b.text(minV.toFixed(1), lx + lw + 5, ly + lh); + b.text("SNR", lx, ly - 10); + b.pop(); + }; - p.windowResized = function() { - p.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); - // BUG FIX 2: Re-create the buffer instead of resizing it - staticBackgroundBuffer = p.createGraphics(p.width, p.height); - calculatePlotScales(); - // Re-draw the static content to the new buffer - drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); - if (appState.vizData) p.redraw(); - }; + // Handle window resizing event + p.windowResized = function () { + p.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight); + // BUG FIX 2: Re-create the buffer instead of resizing it + staticBackgroundBuffer = p.createGraphics(p.width, p.height); + calculatePlotScales(); + // Re-draw the static content to the new buffer + drawStaticRegionsToBuffer(p, staticBackgroundBuffer, plotScales); + if (appState.vizData) p.redraw(); + }; }; diff --git a/steps/src/p5/speedGraphSketch.js b/steps/src/p5/speedGraphSketch.js index 0938746..2cccd23 100644 --- a/steps/src/p5/speedGraphSketch.js +++ b/steps/src/p5/speedGraphSketch.js @@ -1,170 +1,265 @@ //---Import APPSTATE VIDEOPLAYER and FindLastCanIndex---// -import { appState - -} from '../state.js'; - -import { videoPlayer, speedGraphContainer - -} from '../dom.js'; - -import { findLastCanIndexBefore - -} from '../utils.js'; - +import { appState } from "../state.js"; +import { videoPlayer, speedGraphContainer } from "../dom.js"; +import { findLastCanIndexBefore } from "../utils.js"; export const speedGraphSketch = function (p) { - let staticBuffer, minSpeed, maxSpeed, videoDuration; - const pad = { top: 20, right: 130, bottom: 30, left: 50 }; - - // This function is now attached to the p5 instance, making it public - // It's responsible for drawing the static background and data lines - p.drawStaticGraphToBuffer = function (canSpeedData, radarData) { - const b = staticBuffer; - b.clear(); - const isDark = document.documentElement.classList.contains('dark'); - b.background(isDark ? [55, 65, 81] : 255); - const gridColor = isDark ? 100 : 200; - const textColor = isDark ? 200 : 100; - - b.push(); - b.stroke(gridColor); - b.strokeWeight(1); - b.line(pad.left, pad.top, pad.left, b.height - pad.bottom); - b.line(pad.left, b.height - pad.bottom, b.width - pad.right, b.height - pad.bottom); - b.textAlign(b.RIGHT, b.CENTER); - b.noStroke(); - b.fill(textColor); - b.textSize(10); - for (let s = minSpeed; s <= maxSpeed; s += 10) { - const y = b.map(s, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); - b.text(s, pad.left - 8, y); - if (s === 0) { - b.strokeWeight(1.5); - b.stroke(isDark ? 150 : 180); - } else { - b.strokeWeight(1); - b.stroke(isDark ? 80 : 230); - } - b.line(pad.left + 1, y, b.width - pad.right, y); - b.noStroke(); - } - - b.fill(textColor); - b.text("km/h", pad.left - 8, pad.top - 8); - b.textAlign(b.CENTER, b.TOP); - b.noStroke(); - b.fill(isDark ? 180 : 150); - const tInt = Math.max(1, Math.floor(videoDuration / 10)); - for (let t = 0; t <= videoDuration; t += tInt) { const x = b.map(t, 0, videoDuration, pad.left, b.width - pad.right); b.text(Math.round(t), x, b.height - pad.bottom + 5); } - b.fill(textColor); - b.text("Time (s)", b.width / 2, b.height - pad.bottom + 18); - b.pop(); - - if (canSpeedData && canSpeedData.length > 0) { - b.noFill(); - b.stroke(0, 150, 255); - b.strokeWeight(1.5); - b.beginShape(); - for (const d of canSpeedData) { const relTime = (d.time - appState.videoStartDate.getTime()) / 1000; if (relTime >= 0 && relTime <= videoDuration) { const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); const y = b.map(d.speed, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); b.vertex(x, y); } } - b.endShape(); - } + // Declare variables for the static buffer, min/max speed for scaling, and video duration. + let staticBuffer, minSpeed, maxSpeed, videoDuration; + // Define padding for the graph to ensure elements are not drawn at the edges. + const pad = { top: 20, right: 130, bottom: 30, left: 50 }; - if (radarData && radarData.radarFrames) { - b.stroke(0, 200, 100); - b.drawingContext.setLineDash([5, 5]); - b.beginShape(); - for (const frame of radarData.radarFrames) { - const relTime = frame.timestampMs / 1000; - if (relTime >= 0 && relTime <= videoDuration) { - const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right); - const egoSpeedKmh = frame.egoVelocity[1] * 3.6; - const y = b.map(egoSpeedKmh, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); - b.vertex(x, y); - } - } - b.endShape(); - b.drawingContext.setLineDash([]); - } + /** + * Draws the static elements of the speed graph (axes, grid, labels, and data lines) + * to an off-screen buffer. This optimizes performance by not redrawing these elements + * every frame. + * @param {Array} canSpeedData - Array of CAN speed data points. + * @param {Object} radarData - Object containing radar frames with ego velocity. + */ + // This function is now attached to the p5 instance, making it public + // It's responsible for drawing the static background and data lines + p.drawStaticGraphToBuffer = function (canSpeedData, radarData) { + const b = staticBuffer; + b.clear(); + const isDark = document.documentElement.classList.contains("dark"); + b.background(isDark ? [55, 65, 81] : 255); + const gridColor = isDark ? 100 : 200; + const textColor = isDark ? 200 : 100; // Determine text color based on theme. - b.push(); - b.strokeWeight(2); - b.noStroke(); - b.fill(textColor); - b.textAlign(b.LEFT, b.CENTER); - b.stroke(0, 150, 255); - b.line(b.width - 120, pad.top + 10, b.width - 100, pad.top + 10); - b.noStroke(); - b.text("CAN Speed", b.width - 95, pad.top + 10); - b.stroke(0, 200, 100); - b.drawingContext.setLineDash([3, 3]); - b.line(b.width - 120, pad.top + 30, b.width - 100, pad.top + 30); - b.drawingContext.setLineDash([]); - b.noStroke(); - b.text("Ego Speed", b.width - 95, pad.top + 30); - b.pop(); - }; + // Push current drawing style settings onto a stack. + b.push(); + // Set stroke for grid lines. + b.stroke(gridColor); + // Set stroke weight for grid lines. + b.strokeWeight(1); + b.line(pad.left, pad.top, pad.left, b.height - pad.bottom); + b.line( + pad.left, + b.height - pad.bottom, + b.width - pad.right, + b.height - pad.bottom + ); // Draw Y and X axes. + // Set text alignment for Y-axis labels. + b.textAlign(b.RIGHT, b.CENTER); + b.noStroke(); + b.fill(textColor); + // Set text size for labels. + b.textSize(10); + // Draw horizontal grid lines and speed labels. + for (let s = minSpeed; s <= maxSpeed; s += 10) { + const y = b.map(s, minSpeed, maxSpeed, b.height - pad.bottom, pad.top); + b.text(s, pad.left - 8, y); + if (s === 0) { + b.strokeWeight(1.5); + b.stroke(isDark ? 150 : 180); + } else { + b.strokeWeight(1); + b.stroke(isDark ? 80 : 230); + } + b.line(pad.left + 1, y, b.width - pad.right, y); + b.noStroke(); + } + // Draw Y-axis unit label. + b.fill(textColor); + b.text("km/h", pad.left - 8, pad.top - 8); + // Set text alignment for X-axis labels. + b.textAlign(b.CENTER, b.TOP); + b.noStroke(); + b.fill(isDark ? 180 : 150); + // Calculate time interval for X-axis labels. + const tInt = Math.max(1, Math.floor(videoDuration / 10)); + for (let t = 0; t <= videoDuration; t += tInt) { + const x = b.map(t, 0, videoDuration, pad.left, b.width - pad.right); + b.text(Math.round(t), x, b.height - pad.bottom + 5); + } + b.fill(textColor); + b.text("Time (s)", b.width / 2, b.height - pad.bottom + 18); + // Restore previous drawing style settings. + b.pop(); - p.setup = function () { - let canvas = p.createCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight); - canvas.parent('speed-graph-container'); - staticBuffer = p.createGraphics(p.width, p.height); - p.noLoop(); - }; + // Draw CAN speed data line if available. + if (canSpeedData && canSpeedData.length > 0) { + b.noFill(); // Do not fill the shape. + b.stroke(0, 150, 255); + b.strokeWeight(1.5); + b.beginShape(); + for (const d of canSpeedData) { + const relTime = (d.time - appState.videoStartDate.getTime()) / 1000; + if (relTime >= 0 && relTime <= videoDuration) { + const x = b.map( + relTime, + 0, + videoDuration, + pad.left, + b.width - pad.right + ); + const y = b.map( + d.speed, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); + b.vertex(x, y); + } + } + b.endShape(); + } // End of CAN speed data drawing. - p.setData = function (canSpeedData, radarData, duration) { - if ((!canSpeedData || canSpeedData.length === 0) && !radarData) return; - videoDuration = duration; + // Draw radar ego speed data line if available. + if (radarData && radarData.radarFrames) { + b.stroke(0, 200, 100); + b.drawingContext.setLineDash([5, 5]); + b.beginShape(); + for (const frame of radarData.radarFrames) { + const relTime = frame.timestampMs / 1000; + if (relTime >= 0 && relTime <= videoDuration) { + const x = b.map( + relTime, + 0, + videoDuration, + pad.left, + b.width - pad.right + ); + const egoSpeedKmh = frame.egoVelocity[1] * 3.6; + const y = b.map( + egoSpeedKmh, + minSpeed, + maxSpeed, + b.height - pad.bottom, + pad.top + ); + b.vertex(x, y); + } + } + b.endShape(); + b.drawingContext.setLineDash([]); // Reset line dash to solid. + } // End of radar ego speed data drawing. - let speeds = []; - if (canSpeedData) { - speeds.push(...canSpeedData.map(d => parseFloat(d.speed))); - } - if (radarData && radarData.radarFrames) { - const egoSpeeds = radarData.radarFrames.map(frame => frame.egoVelocity[1] * 3.6); - speeds.push(...egoSpeeds); - } + // Draw legend for the graph lines. + b.push(); + b.strokeWeight(2); + b.noStroke(); + b.fill(textColor); + b.textAlign(b.LEFT, b.CENTER); + b.stroke(0, 150, 255); + b.line(b.width - 120, pad.top + 10, b.width - 100, pad.top + 10); + b.noStroke(); + b.text("CAN Speed", b.width - 95, pad.top + 10); + b.stroke(0, 200, 100); + b.drawingContext.setLineDash([3, 3]); + b.line(b.width - 120, pad.top + 30, b.width - 100, pad.top + 30); + b.drawingContext.setLineDash([]); + b.noStroke(); + b.text("Ego Speed", b.width - 95, pad.top + 30); + b.pop(); + }; + /** + * p5.js setup function. Initializes the canvas and static buffer. + */ + p.setup = function () { + let canvas = p.createCanvas( + speedGraphContainer.offsetWidth, + speedGraphContainer.offsetHeight + ); + canvas.parent("speed-graph-container"); + // Create an off-screen graphics buffer for static elements. + staticBuffer = p.createGraphics(p.width, p.height); + // Disable continuous looping; draw will be called manually. + p.noLoop(); + }; + /** + * Sets the data for the speed graph and recalculates min/max speed for scaling. + * @param {Array} canSpeedData - Array of CAN speed data points. + * @param {Object} radarData - Object containing radar frames with ego velocity. + * @param {number} duration - The total duration of the video in seconds. + */ + p.setData = function (canSpeedData, radarData, duration) { + if ((!canSpeedData || canSpeedData.length === 0) && !radarData) return; // Exit if no data. + videoDuration = duration; - minSpeed = speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0; - maxSpeed = speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10; - if (maxSpeed <= 0) maxSpeed = 10; - if (minSpeed >= 0) minSpeed = 0; + let speeds = []; + if (canSpeedData) { + speeds.push(...canSpeedData.map((d) => parseFloat(d.speed))); + } + if (radarData && radarData.radarFrames) { + const egoSpeeds = radarData.radarFrames.map( + (frame) => frame.egoVelocity[1] * 3.6 + ); + speeds.push(...egoSpeeds); + } - p.drawStaticGraphToBuffer(canSpeedData, radarData); - p.redraw(); - }; + // Calculate min and max speeds for Y-axis scaling, rounding to nearest 10. + minSpeed = + speeds.length > 0 ? Math.floor(Math.min(...speeds) / 10) * 10 : 0; + maxSpeed = + speeds.length > 0 ? Math.ceil(Math.max(...speeds) / 10) * 10 : 10; + // Ensure maxSpeed is at least 10 if all speeds are non-positive. + if (maxSpeed <= 0) maxSpeed = 10; + // Ensure minSpeed is 0 if all speeds are non-negative. + if (minSpeed >= 0) minSpeed = 0; - p.draw = function () { - if (!videoDuration) return; - p.image(staticBuffer, 0, 0); - drawTimeIndicator(); - }; + // Redraw the static graph elements to the buffer with new data. + p.drawStaticGraphToBuffer(canSpeedData, radarData); + // Request a redraw of the main canvas. + p.redraw(); + }; + /** + * p5.js draw function. Draws the static buffer and the dynamic time indicator. + */ + p.draw = function () { + if (!videoDuration) return; // Only draw if video duration is set. + p.image(staticBuffer, 0, 0); + drawTimeIndicator(); + }; - function drawTimeIndicator() { - const currentTime = videoPlayer.currentTime; - const x = p.map(currentTime, 0, videoDuration, pad.left, p.width - pad.right); - p.stroke(255, 0, 0, 150); - p.strokeWeight(1.5); - p.line(x, pad.top, x, p.height - pad.bottom); - const videoAbsTimeMs = appState.videoStartDate.getTime() + (currentTime * 1000); - const canIndex = findLastCanIndexBefore(videoAbsTimeMs, appState.canData); - if (canIndex !== -1) { - const canMsg = appState.canData[canIndex]; - const y = p.map(canMsg.speed, minSpeed, maxSpeed, p.height - pad.bottom, pad.top); - p.fill(255, 0, 0); - p.noStroke(); - p.ellipse(x, y, 8, 8); - } - } + function drawTimeIndicator() { + const currentTime = videoPlayer.currentTime; + const x = p.map( + currentTime, + 0, + videoDuration, + pad.left, + p.width - pad.right + ); // Map current time to X-coordinate. + // Draw the red time indicator line. + p.stroke(255, 0, 0, 150); + p.strokeWeight(1.5); + p.line(x, pad.top, x, p.height - pad.bottom); - p.windowResized = function () { - p.resizeCanvas(speedGraphContainer.offsetWidth, speedGraphContainer.offsetHeight); - // Instead of resizing the buffer, we re-create it - staticBuffer = p.createGraphics(p.width, p.height); - // And we must re-draw the static content to the new buffer - if ((appState.canData.length > 0 || appState.vizData) && videoDuration) { - p.drawStaticGraphToBuffer(appState.canData, appState.vizData); - } - p.redraw(); - }; - }; \ No newline at end of file + // Draw a circle on the CAN speed line at the current time. + const videoAbsTimeMs = + appState.videoStartDate.getTime() + currentTime * 1000; + const canIndex = findLastCanIndexBefore(videoAbsTimeMs, appState.canData); + if (canIndex !== -1) { + const canMsg = appState.canData[canIndex]; + const y = p.map( + canMsg.speed, + minSpeed, + maxSpeed, + p.height - pad.bottom, + pad.top + ); + p.fill(255, 0, 0); + p.noStroke(); // No stroke for the ellipse. + p.ellipse(x, y, 8, 8); + } + } + /** + * Handles window resizing. Resizes the canvas and recreates/redraws the static buffer. + */ + p.windowResized = function () { + p.resizeCanvas( + speedGraphContainer.offsetWidth, + speedGraphContainer.offsetHeight + ); + // Instead of resizing the buffer, we re-create it + staticBuffer = p.createGraphics(p.width, p.height); + // And we must re-draw the static content to the new buffer + if ((appState.canData.length > 0 || appState.vizData) && videoDuration) { + p.drawStaticGraphToBuffer(appState.canData, appState.vizData); + } + p.redraw(); + }; +}; diff --git a/steps/src/state.js b/steps/src/state.js index 4f5c43d..bc2c5e2 100644 --- a/steps/src/state.js +++ b/steps/src/state.js @@ -1,19 +1,38 @@ export const appState = { - - - vizData : null, - canData : [], - rawCanLogText : null, - videoStartDate : null, - radarStartTimeMs : 0, - isPlaying : false, - currentFrame : 0, - globalMinSnr : 0, globalMaxSnr : 1, - p5_instance : null, speedGraphInstance : null, - jsonFilename : '', videoFilename : '', canLogFilename : '', - isCloseUpMode : false, - masterClockStart : 0, - mediaTimeStart : 0, - lastSyncTime : 0, - -}; \ No newline at end of file + // Stores the parsed visualization data (radar frames, tracks, etc.) + vizData: null, + // Stores the processed CAN bus data (speed, time) + canData: [], + // Temporarily holds raw CAN log text if video start date is not yet available for processing + rawCanLogText: null, + // The Date object representing the start time of the video + videoStartDate: null, + // The timestamp (in milliseconds) of the first radar frame, extracted from the JSON filename + radarStartTimeMs: 0, + // Boolean indicating if the playback is currently active + isPlaying: false, + // The index of the currently displayed radar frame + currentFrame: 0, + // The global minimum SNR value across all radar frames, used for color scaling + globalMinSnr: 0, + // The global maximum SNR value across all radar frames, used for color scaling + globalMaxSnr: 1, + // Reference to the p5.js instance for the radar visualization + p5_instance: null, + // Reference to the p5.js instance for the speed graph visualization + speedGraphInstance: null, + // The filename of the loaded JSON file + jsonFilename: "", + // The filename of the loaded video file + videoFilename: "", + // The filename of the loaded CAN log file + canLogFilename: "", + // Boolean indicating if the close-up interaction mode is active + isCloseUpMode: false, + // Timestamp (from performance.now()) when the master clock started for synchronized playback + masterClockStart: 0, + // The media time (in seconds) of the video when the master clock started + mediaTimeStart: 0, + // Timestamp (from performance.now()) of the last synchronization check + lastSyncTime: 0, +}; diff --git a/steps/src/sync.js b/steps/src/sync.js index 152e27e..cee3cde 100644 --- a/steps/src/sync.js +++ b/steps/src/sync.js @@ -1,6 +1,14 @@ -import { appState } from './state.js'; -import { videoPlayer, speedSlider, offsetInput, stopBtn, updateFrame, updateCanDisplay, updateDebugOverlay } from './dom.js'; -import { findRadarFrameIndexForTime } from './utils.js'; +import { appState } from "./state.js"; +import { + videoPlayer, + speedSlider, + offsetInput, + stopBtn, + updateFrame, + updateCanDisplay, + updateDebugOverlay, +} from "./dom.js"; +import { findRadarFrameIndexForTime } from "./utils.js"; /** * The main animation loop that drives the synchronized playback. @@ -8,45 +16,61 @@ import { findRadarFrameIndexForTime } from './utils.js'; * finds the corresponding radar frame, and handles resynchronization with the video element. */ export function animationLoop() { - if (!appState.isPlaying) return; + if (!appState.isPlaying) return; - const playbackSpeed = parseFloat(speedSlider.value); - const elapsedRealTime = performance.now() - appState.masterClockStart; - const currentMediaTime = appState.mediaTimeStart + (elapsedRealTime / 1000) * playbackSpeed; + // Get the current playback speed from the slider + const playbackSpeed = parseFloat(speedSlider.value); + // Calculate the elapsed real time since the master clock started + const elapsedRealTime = performance.now() - appState.masterClockStart; + // Calculate the current media time based on the master clock, initial media time, elapsed real time, and playback speed + const currentMediaTime = + appState.mediaTimeStart + (elapsedRealTime / 1000) * playbackSpeed; - // Update radar frame based on the master clock - if (appState.vizData && appState.videoStartDate) { - const offsetMs = parseFloat(offsetInput.value) || 0; - const targetRadarTimeMs = (currentMediaTime * 1000); - const targetFrame = findRadarFrameIndexForTime(targetRadarTimeMs, appState.vizData); - if (targetFrame !== appState.currentFrame) { - updateFrame(targetFrame, false); - } + // Update radar frame based on the master clock + // Check if visualization data and video start date are available + if (appState.vizData && appState.videoStartDate) { + // Get the offset from the input field, default to 0 if not a valid number + const offsetMs = parseFloat(offsetInput.value) || 0; + // Calculate the target radar time in milliseconds + const targetRadarTimeMs = currentMediaTime * 1000; + // Find the index of the radar frame that corresponds to the target time + const targetFrame = findRadarFrameIndexForTime( + targetRadarTimeMs, + appState.vizData + ); + if (targetFrame !== appState.currentFrame) { + // Update the displayed frame if it's different from the current one + updateFrame(targetFrame, false); } + } - // Periodically check for drift between master clock and video element - const now = performance.now(); - if (now - appState.lastSyncTime > 500) { - const videoTime = videoPlayer.currentTime; - const drift = Math.abs(currentMediaTime - videoTime); - if (drift > 0.15) { // Resync if drift is > 150ms - console.warn(`Resyncing video. Drift was: ${drift.toFixed(3)}s`); - videoPlayer.currentTime = currentMediaTime; - } - appState.lastSyncTime = now; + // Periodically check for drift between master clock and video element + const now = performance.now(); + if (now - appState.lastSyncTime > 500) { + const videoTime = videoPlayer.currentTime; + const drift = Math.abs(currentMediaTime - videoTime); + // Resync if drift is > 150ms + if (drift > 0.15) { + // Resync if drift is > 150ms + console.warn(`Resyncing video. Drift was: ${drift.toFixed(3)}s`); + videoPlayer.currentTime = currentMediaTime; } + appState.lastSyncTime = now; + } - // Stop playback at the end of the video - if (currentMediaTime >= videoPlayer.duration) { - stopBtn.click(); - return; - } + // Stop playback at the end of the video + if (currentMediaTime >= videoPlayer.duration) { + stopBtn.click(); + return; + } - // Update other UI elements - updateCanDisplay(currentMediaTime); - updateDebugOverlay(currentMediaTime); - if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); + // Update CAN bus data display + updateCanDisplay(currentMediaTime); + // Update debug overlay information + updateDebugOverlay(currentMediaTime); + // Redraw the speed graph if an instance exists + if (appState.speedGraphInstance) appState.speedGraphInstance.redraw(); - // Request the next frame - requestAnimationFrame(animationLoop); + // Request the next frame + requestAnimationFrame(animationLoop); } diff --git a/steps/src/theme.js b/steps/src/theme.js index cae3052..1930f08 100644 --- a/steps/src/theme.js +++ b/steps/src/theme.js @@ -1,54 +1,58 @@ -import { appState } from './state.js'; -import { videoPlayer } from './dom.js'; -// --- DARK MODE: Step 3 - Add the JavaScript Logic --- -const themeToggleBtn = document.getElementById('theme-toggle'); -const darkIcon = document.getElementById('theme-toggle-dark-icon'); -const lightIcon = document.getElementById('theme-toggle-light-icon'); +import { appState } from "./state.js"; +import { videoPlayer } from "./dom.js"; +const themeToggleBtn = document.getElementById("theme-toggle"); +const darkIcon = document.getElementById("theme-toggle-dark-icon"); +const lightIcon = document.getElementById("theme-toggle-light-icon"); function setTheme(theme) { - if (theme === 'dark') { - document.documentElement.classList.add('dark'); - lightIcon.classList.remove('hidden'); - darkIcon.classList.add('hidden'); - localStorage.setItem('color-theme', 'dark'); - } else { - document.documentElement.classList.remove('dark'); - darkIcon.classList.remove('hidden'); - lightIcon.classList.add('hidden'); - localStorage.setItem('color-theme', 'light'); - } - - // Redraw the main radar plot - if (appState.p5_instance) appState.p5_instance.redraw(); + if (theme === "dark") { + document.documentElement.classList.add("dark"); + lightIcon.classList.remove("hidden"); + darkIcon.classList.add("hidden"); + localStorage.setItem("color-theme", "dark"); + } else { + document.documentElement.classList.remove("dark"); + darkIcon.classList.remove("hidden"); + lightIcon.classList.add("hidden"); + localStorage.setItem("color-theme", "light"); + } + + // Redraw the main radar plot to apply theme changes + if (appState.p5_instance) appState.p5_instance.redraw(); - // =================== THE FIX IS HERE =================== - if (appState.speedGraphInstance) { - // 1. Check if there's data to draw. - if ((appState.canData.length > 0 || appState.vizData) && videoPlayer.duration) { - // 2. Force it to take a new "photograph" with the new theme colors. - appState.speedGraphInstance.drawStaticGraphToBuffer(appState.canData, appState.vizData); - } - // 3. Display the new photograph. - appState.speedGraphInstance.redraw(); + // Redraw the speed graph to apply theme changes + if (appState.speedGraphInstance) { + // Check if there's data available to draw on the speed graph + if ( + (appState.canData.length > 0 || appState.vizData) && + videoPlayer.duration + ) { + // If data exists, redraw the static parts of the graph to a buffer + // This ensures the background and static elements reflect the new theme + appState.speedGraphInstance.drawStaticGraphToBuffer( + appState.canData, + appState.vizData + ); } - // ================= END OF FIX ========================= + // Request a redraw of the speed graph to display the updated buffer + appState.speedGraphInstance.redraw(); + } } - export function initializeTheme() { - const savedTheme = localStorage.getItem('color-theme'); - if (savedTheme) { - setTheme(savedTheme); + const savedTheme = localStorage.getItem("color-theme"); + if (savedTheme) { + setTheme(savedTheme); + } else { + // Default to light mode if no theme is saved + setTheme("light"); + } + + themeToggleBtn.addEventListener("click", () => { + if (document.documentElement.classList.contains("dark")) { + setTheme("light"); } else { - // Default to light mode if no theme is saved - setTheme('light'); + setTheme("dark"); } - - themeToggleBtn.addEventListener('click', () => { - if (document.documentElement.classList.contains('dark')) { - setTheme('light'); - } else { - setTheme('dark'); - } - }); + }); } diff --git a/steps/src/utils.js b/steps/src/utils.js index 5747d12..37ae08e 100644 --- a/steps/src/utils.js +++ b/steps/src/utils.js @@ -1,65 +1,126 @@ export function findRadarFrameIndexForTime(targetTimeMs, vizData) { - if (!vizData || vizData.radarFrames.length === 0) return -1; - let low = 0, high = vizData.radarFrames.length - 1, ans = 0; - while (low <= high) { - let mid = Math.floor((low + high) / 2); - if (vizData.radarFrames[mid].timestampMs <= targetTimeMs) { - ans = mid; low = mid + 1; - } - else { - high = mid - 1; - } + if (!vizData || vizData.radarFrames.length === 0) return -1; + // Initialize low, high, and answer variables for binary search + // 'ans' will store the index of the closest frame found so far + // 'low' and 'high' define the search range + let low = 0, + high = vizData.radarFrames.length - 1, + ans = 0; + // Perform binary search to find the radar frame whose timestamp is closest to, but not exceeding, the target time + while (low <= high) { + let mid = Math.floor((low + high) / 2); + // If the current frame's timestamp is less than or equal to the target time, + // it's a potential answer, and we try to find a more recent one in the right half. + if (vizData.radarFrames[mid].timestampMs <= targetTimeMs) { + ans = mid; + low = mid + 1; + } else { + // If the current frame's timestamp is greater than the target time, + // we need to look in the left half. + high = mid - 1; } - return ans; + } + // Return the index of the found radar frame. + return ans; } export function findLastCanIndexBefore(targetTime, canData) { - if (!canData || canData.length === 0) return -1; - let low = 0, high = canData.length - 1, ans = -1; - while (low <= high) { - let mid = Math.floor((low + high) / 2); - if (canData[mid].time <= targetTime) { - ans = mid; low = mid + 1; - } else { - high = mid - 1; + // Check for empty or invalid CAN data + if (!canData || canData.length === 0) return -1; - } + // Initialize low, high, and answer variables for binary search + // 'ans' will store the index of the last CAN data point found before the target time + // 'low' and 'high' define the search range + let low = 0, + high = canData.length - 1, + ans = -1; // Initialize ans to -1, indicating no suitable frame found yet. + while (low <= high) { + let mid = Math.floor((low + high) / 2); + if (canData[mid].time <= targetTime) { + ans = mid; + low = mid + 1; + } else { + high = mid - 1; } - return ans; + } + // Return the index of the found CAN data point. + return ans; } export function extractTimestampInfo(filename) { - if (!filename) return null; - let match = filename.match(/Tracks_(\d{8}_\d{6}\.\d{3})/); - if (match) return { timestampStr: match[1], format: 'json' }; - match = filename.match(/WIN_(\d{8})_(\d{2})_(\d{2})_(\d{2})/); - if (match) { - const timestamp = `${match[1]}_${match[2]}${match[3]}${match[4]}`; - return { timestampStr: timestamp, format: 'video' }; - } match = filename.match(/video_(\d{8}_\d{6})/); - if (match) return { - timestampStr: match[1], format: 'video' + // Return null if filename is not provided + if (!filename) return null; + // Try to match JSON filename pattern: "Tracks_YYYYMMDD_HHMMSS.ms" + // Example: Tracks_20231027_103000.123 + let match = filename.match(/Tracks_(\d{8}_\d{6}\.\d{3})/); + if (match) return { timestampStr: match[1], format: "json" }; + // Try to match video filename pattern (e.g., from GoPro): "WIN_YYYYMMDD_HH_MM_SS" + // Example: WIN_20231027_10_30_00 + match = filename.match(/WIN_(\d{8})_(\d{2})_(\d{2})_(\d{2})/); + if (match) { + const timestamp = `${match[1]}_${match[2]}${match[3]}${match[4]}`; + return { timestampStr: timestamp, format: "video" }; + } + // Try to match another common video filename pattern: "video_YYYYMMDD_HHMMSS" + // Example: video_20231027_103000 + match = filename.match(/video_(\d{8}_\d{6})/); + if (match) + return { + timestampStr: match[1], + format: "video", }; - - return null; + // If no pattern matches, return null + return null; } export function parseTimestamp(timestampStr, format) { - if (!timestampStr || !format) return null; - let day, month, year, hour, minute, second, millisecond = 0; - if (format === 'video') { - [year, month, day] = [timestampStr.substring(0, 4), timestampStr.substring(4, 6), timestampStr.substring(6, 8)]; - [hour, minute, second] = [timestampStr.substring(9, 11), timestampStr.substring(11, 13), timestampStr.substring(13, 15)]; - } - else if (format === 'json') { - [day, month, year] = [timestampStr.substring(0, 2), timestampStr.substring(2, 4), timestampStr.substring(4, 8)]; - [hour, minute, second, millisecond] = [timestampStr.substring(9, 11), timestampStr.substring(11, 13), timestampStr.substring(13, 15), parseInt(timestampStr.substring(16, 19))]; - } - else { - return null; - } - const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond)); - return isNaN(date.getTime()) ? null : date; + // Return null if timestamp string or format is not provided. + if (!timestampStr || !format) return null; + let day, + month, + year, + hour, + minute, + second, + millisecond = 0; + // Parse video timestamp format: YYYYMMDD_HH_MM_SS + // Example: 20231027_10_30_00 + if (format === "video") { + [year, month, day] = [ + timestampStr.substring(0, 4), + timestampStr.substring(4, 6), + timestampStr.substring(6, 8), + ]; + [hour, minute, second] = [ + timestampStr.substring(9, 11), + timestampStr.substring(11, 13), + timestampStr.substring(13, 15), + ]; + } + else if (format === "json") { + // Parse JSON timestamp format: DDMMYYYY_HHMMSS.ms + [day, month, year] = [ + timestampStr.substring(0, 2), + timestampStr.substring(2, 4), + timestampStr.substring(4, 8), + ]; + [hour, minute, second, millisecond] = [ + timestampStr.substring(9, 11), + timestampStr.substring(11, 13), + timestampStr.substring(13, 15), + parseInt(timestampStr.substring(16, 19)), + ]; + } else { + // Return null for unsupported formats + return null; + } // Create a Date object using UTC to avoid timezone issues + const date = new Date( + Date.UTC(year, month - 1, day, hour, minute, second, millisecond) + ); + + // Check if the created Date object is valid. + // If getTime() returns NaN, the date is invalid. + return isNaN(date.getTime()) ? null : date; } /** @@ -70,13 +131,19 @@ export function parseTimestamp(timestampStr, format) { * @returns {Function} Returns the new throttled function. */ export function throttle(func, delay) { - let lastCall = 0; - return function(...args) { - const now = new Date().getTime(); - if (now - lastCall < delay) { - return; - } - lastCall = now; - return func(...args); - }; -} \ No newline at end of file + // `lastCall` keeps track of the timestamp of the last successful invocation. + let lastCall = 0; + // Return a new function that, when called, will throttle the execution of the original function + return function (...args) { + // Get the current timestamp. + const now = new Date().getTime(); + + // If the time since the last call is less than the delay, do not execute the function + if (now - lastCall < delay) { + return; + } + // Otherwise, update the last call time and execute the original function + lastCall = now; + return func(...args); // Apply the original function with its arguments. + }; +}