15 changed files with 1715 additions and 1194 deletions
-
492steps/index.html
-
43steps/readme.md
-
9steps/src/constants.js
-
108steps/src/db.js
-
68steps/src/dom.js
-
721steps/src/drawUtils.js
-
197steps/src/fileParsers.js
-
83steps/src/main.js
-
73steps/src/modal.js
-
264steps/src/p5/radarSketch.js
-
407steps/src/p5/speedGraphSketch.js
-
53steps/src/state.js
-
96steps/src/sync.js
-
90steps/src/theme.js
-
181steps/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; |
||||
export const RADAR_Y_MAX = 60; |
|
||||
|
// Maximum Y-coordinate for the radar plot in meters.
|
||||
|
export const RADAR_Y_MAX = 60; |
||||
@ -1,55 +1,79 @@ |
|||||
|
|
||||
|
|
||||
// --- IndexedDB for Caching --- //
|
|
||||
|
// -------------------------- IndexedDB for Caching ----------------- //
|
||||
let db; |
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) { |
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) { |
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) { |
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); |
||||
|
}; |
||||
} |
} |
||||
@ -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.
|
|
||||
|
|
||||
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 }; |
|
||||
} |
|
||||
|
|
||||
// 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 }); |
|
||||
} |
|
||||
} |
|
||||
|
// 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 }); |
||||
|
} |
||||
} |
} |
||||
// It sorts the LOCAL canData array.
|
|
||||
canData.sort((a, b) => a.time - b.time); |
|
||||
|
} |
||||
|
// 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.
|
|
||||
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------------------------//
|
//--------------------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."); |
||||
} |
} |
||||
|
|
||||
|
// 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, |
||||
|
}; |
||||
|
} |
||||
} |
} |
||||
@ -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 => { |
|
||||
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)); |
|
||||
|
//----------------------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,126 +1,170 @@ |
|||||
|
import { appState } from "../state.js"; |
||||
import { |
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 { |
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(); |
||||
|
}; |
||||
}; |
}; |
||||
@ -1,170 +1,265 @@ |
|||||
//---Import APPSTATE VIDEOPLAYER and FindLastCanIndex---//
|
//---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) { |
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(); |
|
||||
}; |
|
||||
}; |
|
||||
|
// 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(); |
||||
|
}; |
||||
|
}; |
||||
@ -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'); |
|
||||
} else { |
|
||||
document.documentElement.classList.remove('dark'); |
|
||||
darkIcon.classList.remove('hidden'); |
|
||||
lightIcon.classList.add('hidden'); |
|
||||
localStorage.setItem('color-theme', 'light'); |
|
||||
} |
|
||||
|
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(); |
|
||||
|
// 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() { |
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 { |
} 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'); |
|
||||
} |
|
||||
}); |
|
||||
|
}); |
||||
} |
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue