diff --git a/steps/src/drawUtils.js b/steps/src/drawUtils.js index cde2852..8d79d14 100644 --- a/steps/src/drawUtils.js +++ b/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 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); diff --git a/steps/src/p5/zoomSketch.js b/steps/src/p5/zoomSketch.js index e6953be..e7dfe05 100644 --- a/steps/src/p5/zoomSketch.js +++ b/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);