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; |
export const MAX_TRAJECTORY_LENGTH = 50; |
||||
|
// Frames per second for the video playback.
|
||||
export const VIDEO_FPS = 30; |
export const VIDEO_FPS = 30; |
||||
|
|
||||
|
// Minimum X-coordinate for the radar plot in meters.
|
||||
export const RADAR_X_MIN = -20; |
export const RADAR_X_MIN = -20; |
||||
|
// Maximum X-coordinate for the radar plot in meters.
|
||||
export const RADAR_X_MAX = 20; |
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_MIN = 0; |
||||
|
// Maximum Y-coordinate for the radar plot in meters.
|
||||
export const RADAR_Y_MAX = 60; |
export const RADAR_Y_MAX = 60; |
||||
@ -1,88 +1,139 @@ |
|||||
|
|
||||
//--------------------CAN-LOG PARSER------------------------//
|
//--------------------CAN-LOG PARSER------------------------//
|
||||
|
|
||||
|
|
||||
export function processCanLog(logContent, videoStartDate) { |
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 (!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.
|
// This is a NEW, LOCAL variable, only for this function.
|
||||
const canData = []; |
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) { |
for (const line of lines) { |
||||
const match = line.match(logRegex); |
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) { |
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); |
const msgDate = new Date(videoStartDate); |
||||
msgDate.setUTCHours(h, m, s, ms); |
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) { |
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); |
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); |
const speed = (rawVal * 0.1).toFixed(1); |
||||
canData.push({ time: msgDate.getTime(), speed: speed }); |
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); |
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.
|
// It returns the finished product in a structured object.
|
||||
|
// The processed CAN data is returned under the 'data' key.
|
||||
return { data: canData }; |
return { data: canData }; |
||||
} |
} |
||||
|
|
||||
|
|
||||
//--------------------JSON PARSER------------------------//
|
//--------------------JSON PARSER------------------------//
|
||||
|
|
||||
// Add this new function to src/fileParsers.js
|
|
||||
|
|
||||
export function parseVisualizationJson(jsonString, radarStartTimeMs, videoStartDate) { |
|
||||
|
export function parseVisualizationJson( |
||||
|
jsonString, |
||||
|
radarStartTimeMs, |
||||
|
videoStartDate |
||||
|
) { |
||||
try { |
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); |
const vizData = JSON.parse(cleanJsonString); |
||||
|
|
||||
|
// Validate if the parsed data contains radar frames.
|
||||
if (!vizData.radarFrames || vizData.radarFrames.length === 0) { |
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
|
// 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) { |
if (frame.pointCloud && frame.pointCloud.length > 0) { |
||||
totalPoints += frame.pointCloud.length; |
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); |
if (p.snr !== null) snrValues.push(p.snr); |
||||
}); |
}); |
||||
} |
} |
||||
}); |
}); |
||||
|
|
||||
|
// Warn if no point cloud data was found in the loaded frames.
|
||||
if (totalPoints === 0) { |
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 minSnr = snrValues.length > 0 ? Math.min(...snrValues) : 0; |
||||
const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; |
const maxSnr = snrValues.length > 0 ? Math.max(...snrValues) : 1; |
||||
|
|
||||
// Return the finished data package
|
// 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 }; |
return { data: vizData, minSnr: minSnr, maxSnr: maxSnr }; |
||||
|
|
||||
} catch (error) { |
} catch (error) { |
||||
console.error("JSON Parsing Error:", 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 --- //
|
// --- 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; |
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(() => { |
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; |
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(() => { |
setTimeout(() => { |
||||
modalContainer.classList.add('hidden'); |
|
||||
|
modalContainer.classList.add("hidden"); |
||||
if (modalResolve) modalResolve(value); |
if (modalResolve) modalResolve(value); |
||||
}, 200); |
}, 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 = { |
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) { |
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 { |
} 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(); |
if (appState.p5_instance) appState.p5_instance.redraw(); |
||||
|
|
||||
// =================== THE FIX IS HERE ===================
|
|
||||
|
// Redraw the speed graph to apply theme changes
|
||||
if (appState.speedGraphInstance) { |
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(); |
appState.speedGraphInstance.redraw(); |
||||
} |
} |
||||
// ================= END OF FIX =========================
|
|
||||
} |
} |
||||
|
|
||||
|
|
||||
export function initializeTheme() { |
export function initializeTheme() { |
||||
const savedTheme = localStorage.getItem('color-theme'); |
|
||||
|
const savedTheme = localStorage.getItem("color-theme"); |
||||
if (savedTheme) { |
if (savedTheme) { |
||||
setTheme(savedTheme); |
setTheme(savedTheme); |
||||
} else { |
} else { |
||||
// Default to light mode if no theme is saved
|
// 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 { |
} else { |
||||
setTheme('dark'); |
|
||||
|
setTheme("dark"); |
||||
} |
} |
||||
}); |
}); |
||||
} |
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue