Browse Source

✦ feat: enhance visualizations with smart labeling and density-based speed coloring

radar view (src/drawUtils.js):
   - Refactored drawTrackMarkers with a smart collision-resolution algorithm for floating labels.
   - Added leader lines and themed tooltips for track IDs and speed data to prevent overlapping.
   - Optimized velocity vectors with arrowheads and reduced thickness for better clarity.
   - Updated drawRegionsOfInterest to use ROI_CLOSE_Y_MAX constant.

  speed graph (src/p5/speedGraphSketch.js):
   - Implemented a MATLAB-style spectral color scheme (Blue-Cyan-Green-Yellow-Red) to visualize track density on the CAN speed
     line.
   - Added a vertical smooth gradient legend bar for track density.
   - Switched to robust 95th percentile normalization for density mapping to mitigate outlier noise.
   - Synchronized density calculations with the "Confirmed Only" toggle.
   - Updated the horizontal legend to reflect density-based coloring.

  zoom view (src/p5/zoomSketch.js):
   - Adjusted tooltip vertical offset for improved diagonal positioning.
   - Disabled internal track detail boxes to prioritize the specialized zoom tooltip system.
refactor/sync-centralize
RUSHIL AMBARISH KADU 3 months ago
parent
commit
086bef119d
  1. 229
      steps/src/drawUtils.js
  2. 141
      steps/src/p5/speedGraphSketch.js
  3. 6
      steps/src/p5/zoomSketch.js

229
steps/src/drawUtils.js

@ -5,6 +5,7 @@ import {
MAX_TRAJECTORY_LENGTH,
ROI_TRACKS_Y_MIN,
ROI_CLOSE_Y_MIN,
ROI_CLOSE_Y_MAX,
} from "./constants.js";
import { appState } from "./state.js";
import {
@ -473,27 +474,37 @@ export function drawTrajectories(p, plotScales, scaleFactor = 1) {
}
}
export function drawTrackMarkers(p, plotScales, scaleFactor = 1) {
export function drawTrackMarkers(p, plotScales, scaleFactor = 1, showDetailsBox = true) {
try {
const showDetails = toggleVelocity.checked;
const useStationary = toggleStationaryColor.checked;
const textColor = document.documentElement.classList.contains("dark")
? p.color(255)
: p.color(0);
const localStationaryColor = stationaryColor(p);
const localMovingColor = movingColor(p);
// Optimization: Batch drawing commands
// We collect all text labels to draw them in a single pass at the end.
// This avoids switching between stroke/fill and push/pop for every track.
const textLabels = [];
// Style constants for the floating tooltips (matching zoomSketch)
const highlightColor = p.color(46, 204, 113);
const bgColor = document.documentElement.classList.contains("dark")
? p.color(20, 20, 30, 220)
: p.color(245, 245, 245, 220);
const defaultTextColor = document.documentElement.classList.contains("dark")
? p.color(230)
: p.color(20);
// Preparation for smart positioning
const labels = [];
// Adjust text size based on zoom (scaleFactor is roughly 1/zoom)
const textSize = 12 * scaleFactor;
const padding = 6 * scaleFactor;
const lineHeight = textSize * 1.2;
p.push();
p.strokeWeight(2 * scaleFactor);
// Set text size once for width measurement
p.textSize(textSize);
for (const track of appState.vizData.tracks) {
if (toggleConfirmedOnly.checked && track.isConfirmed === false) continue;
// Robust check for malformed tracks (same as drawTrajectories)
// Robust check for malformed tracks
if (!track || !track.historyLog || !Array.isArray(track.historyLog)) continue;
const log = track.historyLog.find(
@ -523,7 +534,7 @@ export function drawTrackMarkers(p, plotScales, scaleFactor = 1) {
p.line(x, y - size, x, y + size);
}
// --- Draw Velocity Vector & Collect Text ---
// --- Velocity Vector & Collect Label Data ---
if (
showDetails &&
log.predictedVelocity &&
@ -531,66 +542,176 @@ export function drawTrackMarkers(p, plotScales, scaleFactor = 1) {
) {
const [vx, vy] = log.predictedVelocity;
// Draw velocity line immediately (shares stroke context)
// Draw velocity line
if (log.isStationary === false) {
// Determine color again (optimization: could be refactored to avoid recalc)
let velocityColor = p.color(255, 0, 255, 200);
if (useStationary) velocityColor = localMovingColor;
p.stroke(velocityColor);
p.line(
x,
y,
(pos[0] + vx) * plotScales.plotScaleX,
(pos[1] + vy) * plotScales.plotScaleY
);
}
// Defer Text Drawing
const speed = (Math.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1);
let ttcText = "";
if ("tti" in log) {
const tti = log.tti;
if (typeof tti === "number" && isFinite(tti)) {
ttcText = `TTI: ${tti.toFixed(1)}s`;
}
} else if (log.ttc !== null && isFinite(log.ttc) && log.ttc < 100) {
ttcText = `TTC: ${log.ttc.toFixed(1)}s`;
}
const risk = getTrackRisk(track, log);
if (risk !== null) {
ttcText += ttcText ? ` | Risk: ${risk}` : `Risk: ${risk}`;
// Reduce thickness by 25% (2.0 -> 1.5)
p.strokeWeight(1.5 * scaleFactor);
// Make velocity line 30% smaller
const velScale = 0.7;
const vxScaled = vx * velScale;
const vyScaled = vy * velScale;
const endX = (pos[0] + vxScaled) * plotScales.plotScaleX;
const endY = (pos[1] + vyScaled) * plotScales.plotScaleY;
p.line(x, y, endX, endY);
// Draw arrow head
const arrowSize = 4 * scaleFactor;
const angle = Math.atan2(endY - y, endX - x);
p.push();
p.translate(endX, endY);
p.rotate(angle);
// Arrowhead wings
p.line(0, 0, -arrowSize, -arrowSize * 0.6);
p.line(0, 0, -arrowSize, arrowSize * 0.6);
p.pop();
}
const state = log.state !== undefined && log.state !== null ? log.state : track.state;
if (state !== undefined && state !== null) {
ttcText += ttcText ? ` | St: ${state}` : `St: ${state}`;
// --- Collect Text Data (Only if details box is enabled) ---
if (showDetailsBox) {
const speed = (Math.sqrt(vx * vx + vy * vy) * 3.6).toFixed(1);
let ttcText = "";
if ("tti" in log) {
const tti = log.tti;
if (typeof tti === "number" && isFinite(tti)) {
ttcText = `TTI: ${tti.toFixed(1)}s`;
}
} else if (log.ttc !== null && isFinite(log.ttc) && log.ttc < 100) {
ttcText = `TTC: ${log.ttc.toFixed(1)}s`;
}
const risk = getTrackRisk(track, log);
if (risk !== null) {
ttcText += ttcText ? ` | Risk: ${risk}` : `Risk: ${risk}`;
}
const state = log.state !== undefined && log.state !== null ? log.state : track.state;
if (state !== undefined && state !== null) {
ttcText += ttcText ? ` | St: ${state}` : `St: ${state}`;
}
const lines = [`ID: ${track.id} | ${speed} km/h`];
if (ttcText) lines.push(ttcText);
let maxW = 0;
for(let l of lines) maxW = Math.max(maxW, p.textWidth(l));
const w = maxW + padding * 2;
const h = lines.length * lineHeight + padding * 2;
labels.push({ x, y, w, h, lines });
}
const text = `ID: ${track.id} | ${speed} km/h\n${ttcText}`;
textLabels.push({ x, y, text });
}
}
}
}
p.pop(); // End shape drawing context
// --- Batch Draw Text ---
if (textLabels.length > 0) {
p.push();
p.fill(textColor);
p.noStroke();
p.textSize(12 * scaleFactor);
// Set alignment once
// Note: we handle the flip manually
// --- Smart Positioning & Drawing Labels ---
if (labels.length > 0) {
// Sort by Y descending (Top to Bottom in World Space)
// allowing us to stack labels downwards
labels.sort((a, b) => b.y - a.y);
for (const label of textLabels) {
const placedBoxes = [];
// Increased distance to 60 (3x previous 20)
const offsetDist = 60 * scaleFactor;
for (const label of labels) {
// Initial Position:
// If X < 0: Place to Left (x - offset - width)
// If X >= 0: Place to Right (x + offset)
let bx;
if (label.x < 0) {
bx = label.x - offsetDist - label.w;
} else {
bx = label.x + offsetDist;
}
// Vertical position (Top edge) starts at same Y as marker + offset (Diagonal Up)
let by = label.y + offsetDist;
// Collision Resolution (Greedy)
const maxAttempts = 20;
let attempts = 0;
let collision = true;
while(collision && attempts < maxAttempts) {
collision = false;
for (const pBox of placedBoxes) {
// Check intersection in World Space
// Box A (Current): [bx, bx+w] x [by-h, by]
// Box B (Placed): [pBox.x, pBox.x+pBox.w] x [pBox.y-pBox.h, pBox.y]
const Ax1 = bx, Ax2 = bx + label.w;
const Ay1 = by - label.h, Ay2 = by;
const Bx1 = pBox.x, Bx2 = pBox.x + pBox.w;
const By1 = pBox.y - pBox.h, By2 = pBox.y;
// Standard AABB Intersection
if (Ax1 < Bx2 && Ax2 > Bx1 && Ay1 < By2 && Ay2 > By1) {
// Collision! Move 'by' DOWN (decrease Y)
// Snap Top (by) to just below Placed Box Bottom (By1)
by = By1 - 5 * scaleFactor;
collision = true;
break; // Restart collision check against all
}
}
attempts++;
}
label.finalX = bx;
label.finalY = by;
placedBoxes.push(label);
}
// --- Draw Tooltips ---
for (const label of placedBoxes) {
p.push();
p.translate(label.x + 10 * scaleFactor, label.y);
p.scale(1, -1); // Flip text back up
p.text(label.text, 0, 0);
// 1. Draw Leader Line (World Space)
p.stroke(highlightColor);
p.strokeWeight(1 * scaleFactor);
// Draw to the closest side of the box
// If box is to the right, draw to Left Edge (finalX)
// If box is to the left, draw to Right Edge (finalX + w)
let boxSideX;
if (label.finalX > label.x) {
boxSideX = label.finalX; // Box is to the right
} else {
boxSideX = label.finalX + label.w; // Box is to the left
}
const boxCenterY = label.finalY - label.h / 2;
p.line(label.x, label.y, boxSideX, boxCenterY);
// 2. Draw Box & Text
// Translate to Top-Left of box
p.translate(label.finalX, label.finalY);
// Flip for text drawing (local +Y is Down)
p.scale(1, -1);
p.fill(bgColor);
p.stroke(highlightColor);
p.strokeWeight(1 * scaleFactor);
p.rect(0, 0, label.w, label.h, 4 * scaleFactor);
p.noStroke();
p.fill(defaultTextColor);
p.textAlign(p.LEFT, p.TOP);
for(let i=0; i<label.lines.length; i++) {
p.text(label.lines[i], padding, padding + i * lineHeight);
}
p.pop();
}
p.pop();
}
} catch (error) {
console.error("Error in drawTrackMarkers:", error);
@ -1028,7 +1149,7 @@ export function drawRegionsOfInterest(p, frameData, plotScales) {
left * plotScales.plotScaleX,
ROI_CLOSE_Y_MIN * plotScales.plotScaleY,
right * plotScales.plotScaleX,
(appState.radarYMax * 0.25) * plotScales.plotScaleY
ROI_CLOSE_Y_MAX * plotScales.plotScaleY
);
p.pop();

141
steps/src/p5/speedGraphSketch.js

@ -2,6 +2,7 @@
import { appState } from "../state.js";
import { videoPlayer, speedGraphContainer, playPauseBtn } from "../dom.js";
import { updateFrame, pausePlayback } from "../sync.js";
import { ttcColors } from "../drawUtils.js";
export const speedGraphSketch = function (p) {
let staticBuffer, minSpeed, maxSpeed, videoDuration;
@ -44,6 +45,62 @@ export const speedGraphSketch = function (p) {
b.background(isDark ? [55, 65, 81] : 255);
const gridColor = isDark ? 100 : 200;
const textColor = isDark ? 200 : 100;
// --- Step 1: Define Spectral Color Scheme (MATLAB Style) ---
// Anchors: Blue (0%) -> Cyan (25%) -> Green (50%) -> Yellow (75%) -> Red (100%)
const spectralAnchors = [
p.color(0, 0, 255), // Blue
p.color(0, 255, 255), // Cyan
p.color(0, 255, 0), // Green
p.color(255, 255, 0), // Yellow
p.color(255, 0, 0) // Red
];
function getSpectralColor(ratio) {
const amt = p.constrain(ratio, 0, 1);
if (amt <= 0.25) return p.lerpColor(spectralAnchors[0], spectralAnchors[1], amt / 0.25);
if (amt <= 0.50) return p.lerpColor(spectralAnchors[1], spectralAnchors[2], (amt - 0.25) / 0.25);
if (amt <= 0.75) return p.lerpColor(spectralAnchors[2], spectralAnchors[3], (amt - 0.50) / 0.25);
return p.lerpColor(spectralAnchors[3], spectralAnchors[4], (amt - 0.75) / 0.25);
}
// --- Step 2: Pre-calculate Track Density ---
const numFrames = radarData && radarData.radarFrames ? radarData.radarFrames.length : 0;
const trackCounts = new Uint16Array(numFrames).fill(0);
const confirmedOnly = document.getElementById("toggleConfirmedOnly")?.checked ?? true;
if (radarData && radarData.tracks && numFrames > 0) {
for (const track of radarData.tracks) {
// Only count tracks that would actually be visible in the confirmed view
if (confirmedOnly && track.isConfirmed === false) continue;
if (track.historyLog) {
for (const log of track.historyLog) {
if (log.frameIdx >= 0 && log.frameIdx < numFrames) {
trackCounts[log.frameIdx]++;
}
}
}
}
}
// Determine normalization factor using a robust metric (95th percentile)
// This prevents a single frame with 100 tracks (noise) from making the rest of the graph blue.
let normTracks = 1;
if (numFrames > 0) {
const sortedCounts = [...trackCounts].sort((a, b) => a - b);
// Use 95th percentile as the "High" anchor
const p95Index = Math.floor(numFrames * 0.95);
const p95Value = sortedCounts[p95Index];
const maxValue = sortedCounts[numFrames - 1];
// We'll normalize against p95, but ensure it's at least a reasonable number.
normTracks = Math.max(1, p95Value);
console.log(`[SpeedGraph] Density Info (Confirmed Only: ${confirmedOnly}):`);
console.log(` - Max tracks: ${maxValue}, 95th Percentile: ${p95Value}`);
console.log(` - Normalizing against: ${normTracks}`);
}
b.push();
b.stroke(gridColor);
@ -97,22 +154,68 @@ export const speedGraphSketch = function (p) {
b.text("Time (s)", (pad.left + (b.width - pad.right)) / 2, b.height - pad.bottom + 18);
b.pop();
// Draw CAN speed (solid blue)
// --- Density Legend Bar (Left Side) ---
// Smooth gradient representation of track density
const lx = 10;
const lw = 6;
const ly = pad.top;
const lh = b.height - pad.bottom - pad.top;
b.push();
b.noFill();
for (let i = 0; i < lh; i++) {
const ratio = b.map(i, 0, lh, 1, 0); // 1 at top (red), 0 at bottom (blue)
b.stroke(getSpectralColor(ratio));
b.line(lx, ly + i, lx + lw, ly + i);
}
b.pop();
// Legend Labels for the vertical bar
b.fill(textColor);
b.textSize(9);
b.textAlign(b.LEFT, b.TOP);
b.text(normTracks, lx + lw + 3, ly);
b.textAlign(b.LEFT, b.BOTTOM);
b.text("0", lx + lw + 3, ly + lh);
b.textAlign(b.LEFT, b.TOP);
b.text("Tracks", lx, ly + lh + 4);
// Draw CAN speed (Colored by Track Density)
if (radarData && radarData.radarFrames) {
b.strokeWeight(2.5); // Slightly thicker for better color visibility
b.noFill();
b.stroke(0, 150, 255);
b.strokeWeight(1.5);
b.beginShape();
for (const frame of radarData.radarFrames) {
if (frame.canVehSpeed_kmph === null || isNaN(frame.canVehSpeed_kmph)) continue;
let prevX = null;
let prevY = null;
for (let i = 0; i < radarData.radarFrames.length; i++) {
const frame = radarData.radarFrames[i];
if (frame.canVehSpeed_kmph === null || isNaN(frame.canVehSpeed_kmph)) {
prevX = null;
continue;
}
const relTime = frame.timestamp / 1000;
if (relTime >= 0 && relTime <= videoDuration) {
const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right);
const y = b.map(frame.canVehSpeed_kmph, minSpeed, maxSpeed, b.height - pad.bottom, pad.top);
b.vertex(x, y);
if (relTime < 0 || relTime > videoDuration) continue;
const x = b.map(relTime, 0, videoDuration, pad.left, b.width - pad.right);
const speed = frame.canVehSpeed_kmph;
const y = b.map(speed, minSpeed, maxSpeed, b.height - pad.bottom, pad.top);
if (prevX !== null) {
// Robust normalization: Ratio based on 95th percentile
const ratio = trackCounts[i] / normTracks;
b.stroke(getSpectralColor(ratio));
b.line(prevX, prevY, x, y);
}
prevX = x;
prevY = y;
}
b.endShape();
}
// Draw Ego speed (dashed green)
@ -140,7 +243,7 @@ export const speedGraphSketch = function (p) {
b.textSize(12);
b.textAlign(b.LEFT, b.CENTER);
const canLabel = "CAN Speed";
const canLabel = "CAN Speed (Color: Tracks Density)";
const egoLabel = "Ego Speed";
const segLen = 18;
@ -159,11 +262,17 @@ export const speedGraphSketch = function (p) {
const legendStartX = centerX - totalLegendWidth / 2;
const legendY = pad.top / 2; // vertically centered inside the top padding
// Draw CAN legend item
b.push();
b.stroke(0, 150, 255);
// Draw CAN legend item (Gradient Line to represent density range)
// We draw small segments of different colors to show the range
b.strokeWeight(2);
b.line(legendStartX, legendY + 6, legendStartX + segLen, legendY + 6);
const step = segLen / 5;
// Use spectralAnchors for the horizontal legend line
b.stroke(spectralAnchors[0]); b.line(legendStartX, legendY + 6, legendStartX + step, legendY + 6);
b.stroke(spectralAnchors[1]); b.line(legendStartX + step, legendY + 6, legendStartX + step*2, legendY + 6);
b.stroke(spectralAnchors[2]); b.line(legendStartX + step*2, legendY + 6, legendStartX + step*3, legendY + 6);
b.stroke(spectralAnchors[3]); b.line(legendStartX + step*3, legendY + 6, legendStartX + step*4, legendY + 6);
b.stroke(spectralAnchors[4]); b.line(legendStartX + step*4, legendY + 6, legendStartX + segLen, legendY + 6);
b.noStroke();
b.fill(textColor);
b.text(canLabel, legendStartX + segLen + gapBetweenSegAndLabel, legendY + 6);

6
steps/src/p5/zoomSketch.js

@ -114,6 +114,7 @@ function drawZoomTooltip(p, hoveredItems, mainMouseX, mainMouseY) {
const BASE_HIGHLIGHT_THICKNESS = 2;
const BASE_LINE_THICKNESS = 2;
const BASE_DISTANCE_OFFSET = 65; // <-- How far the tooltip is from the items
const BASE_VERTICAL_OFFSET = 40; // <-- Upward shift for diagonal effect
// COLORS
const highlightColor = p.color(46, 204, 113); // Green for border and lines
@ -130,6 +131,7 @@ function drawZoomTooltip(p, hoveredItems, mainMouseX, mainMouseY) {
const lineHeight = BASE_LINE_HEIGHT / zoomFactor;
const boxPadding = BASE_PADDING / zoomFactor;
const xOffset = BASE_DISTANCE_OFFSET / zoomFactor;
const yOffset = BASE_VERTICAL_OFFSET / zoomFactor;
let boxWidth = 0;
infoStrings.forEach((info) => {
@ -149,7 +151,7 @@ function drawZoomTooltip(p, hoveredItems, mainMouseX, mainMouseY) {
boxX = avgX + xOffset;
anchorOnRight = false;
}
let boxY = avgY - boxHeight / 2;
let boxY = avgY - boxHeight / 2 - yOffset;
// --- START: Boundary Constraint Logic ---
// Calculate the visible bounds in the current coordinate system (which is scaled and translated)
@ -291,7 +293,7 @@ export const zoomSketch = function (p) {
// drawEgoVehicle(p, plotScales);
if (frameData) {
drawTrackMarkers(p, plotScales, inverseZoom);
drawTrackMarkers(p, plotScales, inverseZoom, false);
drawRegionsOfInterest(p, frameData, plotScales);
if (toggleTracks.checked) {
drawTrajectories(p, plotScales, inverseZoom);

Loading…
Cancel
Save