15 changed files with 1715 additions and 1194 deletions
-
40steps/index.html
-
15steps/readme.md
-
7steps/src/constants.js
-
60steps/src/db.js
-
68steps/src/dom.js
-
311steps/src/drawUtils.js
-
111steps/src/fileParsers.js
-
63steps/src/main.js
-
59steps/src/modal.js
-
98steps/src/p5/radarSketch.js
-
179steps/src/p5/speedGraphSketch.js
-
53steps/src/state.js
-
40steps/src/sync.js
-
64steps/src/theme.js
-
119steps/src/utils.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; |
|||
// Maximum Y-coordinate for the radar plot in meters.
|
|||
export const RADAR_Y_MAX = 60; |
|||
@ -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.
|
|||
|
|||
// 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 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 }; |
|||
// 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'); |
|||
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'; |
|||
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) { |
|||
const [h, m, s, ms] = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4].substring(0, 3))]; |
|||
// 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); |
|||
const dataBytes = match[6].trim().split(/\s+/).map(hex => parseInt(hex, 16)); |
|||
// 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 }); |
|||
} |
|||
} |
|||
} |
|||
// It sorts the LOCAL canData array.
|
|||
// Sort the processed CAN data points by their timestamp.
|
|||
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.
|
|||
// 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) { |
|||
export function parseVisualizationJson( |
|||
jsonString, |
|||
radarStartTimeMs, |
|||
videoStartDate |
|||
) { |
|||
try { |
|||
const cleanJsonString = jsonString.replace(/\b(Infinity|NaN|-Infinity)\b/gi, 'null'); |
|||
// 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.' }; |
|||
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(); |
|||
// 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; |
|||
vizData.radarFrames.forEach(frame => { |
|||
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 => { |
|||
frame.pointCloud.forEach((p) => { |
|||
// Collect SNR values, ignoring nulls.
|
|||
if (p.snr !== null) snrValues.push(p.snr); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
// 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.'); |
|||
console.warn("Warning: Loaded frames contain no point cloud data."); |
|||
} |
|||
|
|||
// 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 }; |
|||
return { |
|||
error: |
|||
"Error parsing JSON file. Please check file format. Error: " + |
|||
error.message, |
|||
}; |
|||
} |
|||
} |
|||
@ -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 => { |
|||
// 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; |
|||
modalCancelBtn.classList.toggle('hidden', !isConfirm); |
|||
modalContainer.classList.remove('hidden'); |
|||
// 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); |
|||
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; |
|||
}); |
|||
} |
|||
function hideModal(value) { |
|||
modalOverlay.classList.add('opacity-0'); |
|||
modalContent.classList.add('scale-95'); |
|||
} |
|||
// 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'); |
|||
modalContainer.classList.add("hidden"); |
|||
if (modalResolve) modalResolve(value); |
|||
}, 200); |
|||
} |
|||
|
|||
|
|||
//----------------------Modal Event Listeners----------------------//
|
|||
} |
|||
|
|||
modalOkBtn.addEventListener('click', () => hideModal(true)); |
|||
modalCancelBtn.addEventListener('click', () => hideModal(false)); |
|||
modalOverlay.addEventListener('click', () => hideModal(false)); |
|||
//----------------------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)); |
|||
@ -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, |
|||
|
|||
// 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, |
|||
}; |
|||
@ -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'); |
|||
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'); |
|||
document.documentElement.classList.remove("dark"); |
|||
darkIcon.classList.remove("hidden"); |
|||
lightIcon.classList.add("hidden"); |
|||
localStorage.setItem("color-theme", "light"); |
|||
} |
|||
|
|||
// Redraw the main radar plot
|
|||
// Redraw the main radar plot to apply theme changes
|
|||
if (appState.p5_instance) appState.p5_instance.redraw(); |
|||
|
|||
// =================== THE FIX IS HERE ===================
|
|||
// Redraw the speed graph to apply theme changes
|
|||
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); |
|||
// 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 |
|||
); |
|||
} |
|||
// 3. Display the new photograph.
|
|||
// Request a redraw of the speed graph to display the updated buffer
|
|||
appState.speedGraphInstance.redraw(); |
|||
} |
|||
// ================= END OF FIX =========================
|
|||
} |
|||
|
|||
|
|||
export function initializeTheme() { |
|||
const savedTheme = localStorage.getItem('color-theme'); |
|||
const savedTheme = localStorage.getItem("color-theme"); |
|||
if (savedTheme) { |
|||
setTheme(savedTheme); |
|||
} else { |
|||
// Default to light mode if no theme is saved
|
|||
setTheme('light'); |
|||
setTheme("light"); |
|||
} |
|||
|
|||
themeToggleBtn.addEventListener('click', () => { |
|||
if (document.documentElement.classList.contains('dark')) { |
|||
setTheme('light'); |
|||
themeToggleBtn.addEventListener("click", () => { |
|||
if (document.documentElement.classList.contains("dark")) { |
|||
setTheme("light"); |
|||
} else { |
|||
setTheme('dark'); |
|||
setTheme("dark"); |
|||
} |
|||
}); |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue