Browse Source
feat(zoom): Implement interactive high-fidelity zoom window
feat(zoom): Implement interactive high-fidelity zoom window
Adds a new zoom panel that provides a magnified, real-time view of the area under the cursor when "Close-Up Mode" is active. This feature enhances the tool's precision for detailed data analysis. Key features and fixes include: - Renders a high-fidelity, vector-based redraw of the scene, not a pixelated image. - Implemented dynamic zoom control via the mouse wheel when hovering over the main radar canvas. - The zoom sketch is fully decoupled from the main radar sketch to ensure stability and prevent UI freezes. - Includes a self-contained tooltip within the zoom window that correctly scales its size and text to match the zoom level. - The tooltip's position is now smart, dynamically moving to the least cluttered quadrant to avoid obstructing data points. - Connector lines and item highlighting are now fully functional and styled to match the main view's tooltip. Motion state added in the persistent overlaysrefactor/modularize
7 changed files with 1050 additions and 226 deletions
-
13steps/index.html
-
4steps/src/dom.js
-
724steps/src/drawUtils.js
-
27steps/src/main.js
-
83steps/src/p5/radarSketch.js
-
416steps/src/p5/zoomSketch.js
-
1steps/src/state.js
@ -0,0 +1,416 @@ |
|||
import { appState } from "../state.js"; |
|||
import { |
|||
drawAxes, |
|||
drawPointCloud, |
|||
drawTrajectories, |
|||
drawEgoVehicle, |
|||
drawTrackMarkers, |
|||
drawClusterCentroids, |
|||
drawRegionsOfInterest, |
|||
drawCovarianceEllipse, |
|||
clusterColors, // We need to import clusterColors for the tooltip
|
|||
} from "../drawUtils.js"; |
|||
import { |
|||
toggleTracks, |
|||
toggleClusterColor, |
|||
togglePredictedPos, |
|||
toggleCovariance, |
|||
} from "../dom.js"; |
|||
|
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch. |
|||
* It draws the tooltip relative to the hovered items and compensates for the zoom factor. |
|||
*/ |
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch with full features. |
|||
* It draws the tooltip in the least cluttered quadrant, has dynamic connectors, |
|||
* highlights items, and compensates for the zoom factor. |
|||
*/ |
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch with smart quadrant positioning. |
|||
*/ |
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch that "pushes" the tooltip |
|||
* 100 pixels away from the hovered items towards the least cluttered corner. |
|||
*/ |
|||
// function drawZoomTooltip(p, hoveredItems) {
|
|||
// if (!hoveredItems || hoveredItems.length === 0) return;
|
|||
|
|||
// // 1. Generate text content (this is unchanged)
|
|||
// const infoStrings = [];
|
|||
// for (const item of hoveredItems) {
|
|||
// let infoText = '';
|
|||
// const data = item.data;
|
|||
// switch (item.type) {
|
|||
// case 'point': infoText = `Point | X:${data.x.toFixed(2)}, Y:${data.y.toFixed(2)} | V:${data.velocity?.toFixed(2)}, SNR:${data.snr?.toFixed(1)}`; break;
|
|||
// case 'cluster': infoText = `Cluster ${data.id} | X:${data.x.toFixed(2)}, Y:${data.y.toFixed(2)} | rSpeed:${data.radialSpeed?.toFixed(2)}`; break;
|
|||
// case 'track': infoText = `Track ${item.trackId} | X:${data.correctedPosition[0].toFixed(2)}, Y:${data.correctedPosition[1].toFixed(2)}`; break;
|
|||
// case 'prediction': infoText = `Pred. for ${item.trackId} | X:${data.predictedPosition[0].toFixed(2)}, Y:${data.predictedPosition[1].toFixed(2)}`; break;
|
|||
// }
|
|||
// if (infoText) infoStrings.push({ text: infoText, color: item.color || null });
|
|||
// }
|
|||
|
|||
// // 2. Find the average screen position of hovered items. This is our anchor point.
|
|||
// const avgX = hoveredItems.reduce((acc, item) => acc + item.screenX, 0) / hoveredItems.length;
|
|||
// const avgY = hoveredItems.reduce((acc, item) => acc + item.screenY, 0) / hoveredItems.length;
|
|||
|
|||
// p.push();
|
|||
|
|||
// // 3. Compensate for zoom factor for all drawing operations (unchanged)
|
|||
// const zoomFactor = appState.zoomFactor || 6.0;
|
|||
// p.textSize(12 / zoomFactor);
|
|||
// p.strokeWeight(1 / zoomFactor);
|
|||
|
|||
// const lineHeight = 15 / zoomFactor;
|
|||
// const boxPadding = 8 / zoomFactor;
|
|||
|
|||
// let boxWidth = 0;
|
|||
// infoStrings.forEach(info => {
|
|||
// boxWidth = Math.max(boxWidth, p.textWidth(info.text));
|
|||
// });
|
|||
// const boxHeight = (infoStrings.length * lineHeight) + (boxPadding * 2);
|
|||
// boxWidth += (boxPadding * 2);
|
|||
|
|||
// // --- START: New "Push" Positioning Logic ---
|
|||
|
|||
// // Line 1: Define the push distance. We scale it by the zoomFactor so it's a consistent
|
|||
// // visual distance on the screen, regardless of zoom level.
|
|||
// const pushDistance = 100 / zoomFactor;
|
|||
|
|||
// // Line 2: Determine the horizontal direction. If the items are on the right, our direction is left (-1).
|
|||
// // If they are on the left, our direction is right (1).
|
|||
// const dirX = (avgX > appState.p5_instance.width / 2) ? -1 : 1;
|
|||
|
|||
// // Line 3: Determine the vertical direction. If the items are on the bottom, our direction is up (-1).
|
|||
// // If they are on the top, our direction is down (1).
|
|||
// const dirY = (avgY > appState.p5_instance.height / 2) ? -1 : 1;
|
|||
|
|||
// // Line 4: Create a p5.Vector object. This is like an arrow representing our direction (e.g., up and to the right).
|
|||
// const pushVector = p.createVector(dirX, dirY);
|
|||
|
|||
// // Line 5: Normalize the vector. This makes its length exactly 1, so it only represents a pure direction.
|
|||
// pushVector.normalize();
|
|||
|
|||
// // Line 6: Scale the vector. Now it's an arrow that is exactly `pushDistance` pixels long.
|
|||
// pushVector.mult(pushDistance);
|
|||
|
|||
// // Line 7: Calculate the tooltip's corner position by adding our push vector to the anchor point.
|
|||
// let boxX = avgX + pushVector.x;
|
|||
// let boxY = avgY + pushVector.y;
|
|||
|
|||
// // Line 8: Define where the connector line should attach to the box.
|
|||
// // If we pushed right, the connector attaches to the left side of the box.
|
|||
// let connectorAnchorX = (dirX > 0) ? boxX : boxX + boxWidth;
|
|||
|
|||
// // Line 9: If we pushed down, the connector attaches to the top side of the box.
|
|||
// let connectorAnchorY = (dirY > 0) ? boxY : boxY + boxHeight;
|
|||
|
|||
// // Line 10: Adjust the box's final position to account for its own size, so the *corner*
|
|||
// // of the box is at our calculated position, not its top-left.
|
|||
// if (dirX < 0) boxX -= boxWidth; // If we pushed left, shift the box left by its own width.
|
|||
// if (dirY < 0) boxY -= boxHeight; // If we pushed up, shift the box up by its own height.
|
|||
|
|||
// // --- END: New "Push" Positioning Logic ---
|
|||
|
|||
// // 4. Draw highlights, box, text, and connectors (this logic is now restored and complete)
|
|||
// const highlightColor = p.color(46, 204, 113);
|
|||
// hoveredItems.forEach(item => {
|
|||
// p.noFill(); p.stroke(highlightColor); p.strokeWeight(2 / zoomFactor);
|
|||
// p.ellipse(item.screenX, item.screenY, 15 / zoomFactor, 15 / zoomFactor);
|
|||
// });
|
|||
|
|||
// const bgColor = document.documentElement.classList.contains('dark') ? p.color(20, 20, 30, 220) : p.color(245, 245, 245, 220);
|
|||
// p.fill(bgColor); p.stroke(highlightColor); p.strokeWeight(1 / zoomFactor);
|
|||
// p.rect(boxX, boxY, boxWidth, boxHeight, 4 / zoomFactor);
|
|||
|
|||
// const defaultTextColor = document.documentElement.classList.contains('dark') ? p.color(230) : p.color(20);
|
|||
// p.noStroke(); p.textAlign(p.LEFT, p.TOP);
|
|||
// infoStrings.forEach((info, i) => {
|
|||
// p.fill(info.color || defaultTextColor);
|
|||
// p.text(info.text, boxX + boxPadding, boxY + boxPadding + (i * lineHeight));
|
|||
// });
|
|||
|
|||
// hoveredItems.forEach((item, i) => {
|
|||
// p.stroke(highlightColor); p.strokeWeight(1 / zoomFactor);
|
|||
// p.line(connectorAnchorX, connectorAnchorY, item.screenX, item.screenY);
|
|||
// });
|
|||
|
|||
// p.pop();
|
|||
// }
|
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch with smart quadrant positioning, |
|||
* individual dynamic connectors, and item highlighting. |
|||
*/ |
|||
/** |
|||
* A dedicated tooltip function for the zoom sketch with full features and customizations. |
|||
*/ |
|||
function drawZoomTooltip(p, hoveredItems, mainMouseX) { |
|||
if (!hoveredItems || hoveredItems.length === 0) return; |
|||
|
|||
// 1. Generate text content
|
|||
const infoStrings = []; |
|||
for (const item of hoveredItems) { |
|||
let infoText = ""; |
|||
const data = item.data; |
|||
switch (item.type) { |
|||
case "point": |
|||
infoText = `Point | X:${data.x.toFixed(2)}, Y:${data.y.toFixed( |
|||
2 |
|||
)} | V:${data.velocity?.toFixed(2)}, SNR:${data.snr?.toFixed(1)}`;
|
|||
break; |
|||
case "cluster": |
|||
infoText = `Cluster ${data.id} | X:${data.x.toFixed( |
|||
2 |
|||
)}, Y:${data.y.toFixed(2)} | rSpeed:${data.radialSpeed?.toFixed(2)}`;
|
|||
break; |
|||
case "track": |
|||
infoText = `Track ${ |
|||
item.trackId |
|||
} | X:${data.correctedPosition[0].toFixed( |
|||
2 |
|||
)}, Y:${data.correctedPosition[1].toFixed(2)}`;
|
|||
break; |
|||
case "prediction": |
|||
infoText = `Pred. for ${ |
|||
item.trackId |
|||
} | X:${data.predictedPosition[0].toFixed( |
|||
2 |
|||
)}, Y:${data.predictedPosition[1].toFixed(2)}`;
|
|||
break; |
|||
} |
|||
if (infoText) |
|||
infoStrings.push({ text: infoText, color: item.color || null }); |
|||
} |
|||
|
|||
// 2. Find the average screen position of hovered items
|
|||
const avgX = |
|||
hoveredItems.reduce((acc, item) => acc + item.screenX, 0) / |
|||
hoveredItems.length; |
|||
const avgY = |
|||
hoveredItems.reduce((acc, item) => acc + item.screenY, 0) / |
|||
hoveredItems.length; |
|||
|
|||
p.push(); |
|||
|
|||
// --- Start of Tweakable Parameters ---
|
|||
const zoomFactor = appState.zoomFactor || 6; |
|||
|
|||
// VISUALS: Adjust these numbers to change the tooltip's appearance
|
|||
const BASE_FONT_SIZE = 12; |
|||
const BASE_LINE_HEIGHT = 15; |
|||
const BASE_PADDING = 8; |
|||
const BASE_HIGHLIGHT_THICKNESS = 2; |
|||
const BASE_LINE_THICKNESS = 1; |
|||
const BASE_DISTANCE_OFFSET = 45; // <-- How far the tooltip is from the items
|
|||
|
|||
// COLORS
|
|||
const highlightColor = p.color(46, 204, 113); // Green for border and lines
|
|||
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); |
|||
// --- End of Tweakable Parameters ---
|
|||
|
|||
// Compensate for zoom factor
|
|||
p.textSize(BASE_FONT_SIZE / zoomFactor); |
|||
const lineHeight = BASE_LINE_HEIGHT / zoomFactor; |
|||
const boxPadding = BASE_PADDING / zoomFactor; |
|||
const xOffset = BASE_DISTANCE_OFFSET / zoomFactor; |
|||
|
|||
let boxWidth = 0; |
|||
infoStrings.forEach((info) => { |
|||
boxWidth = Math.max(boxWidth, p.textWidth(info.text)); |
|||
}); |
|||
const boxHeight = infoStrings.length * lineHeight + boxPadding * 2; |
|||
boxWidth += boxPadding * 2; |
|||
|
|||
// Smart Positioning Logic
|
|||
let boxX, connectorAnchorX; |
|||
if (mainMouseX > appState.p5_instance.width / 2) { |
|||
boxX = avgX - xOffset - boxWidth; |
|||
connectorAnchorX = boxX + boxWidth; |
|||
} else { |
|||
boxX = avgX + xOffset; |
|||
connectorAnchorX = boxX; |
|||
} |
|||
const boxY = avgY - boxHeight / 2; |
|||
|
|||
// Draw highlighting circles
|
|||
hoveredItems.forEach((item) => { |
|||
p.noFill(); |
|||
p.stroke(highlightColor); |
|||
p.strokeWeight(BASE_HIGHLIGHT_THICKNESS / zoomFactor); |
|||
p.ellipse(item.screenX, item.screenY, 15 / zoomFactor, 15 / zoomFactor); |
|||
}); |
|||
|
|||
// Draw the tooltip box
|
|||
p.fill(bgColor); |
|||
p.stroke(highlightColor); |
|||
p.strokeWeight(BASE_LINE_THICKNESS / zoomFactor); |
|||
p.rect(boxX, boxY, boxWidth, boxHeight, 4 / zoomFactor); |
|||
|
|||
// Draw the text (with italics for prediction)
|
|||
p.noStroke(); |
|||
p.textAlign(p.LEFT, p.TOP); |
|||
infoStrings.forEach((info, i) => { |
|||
p.fill(info.color || defaultTextColor); |
|||
if (hoveredItems[i].type === "prediction") { |
|||
p.textStyle(p.ITALIC); |
|||
} |
|||
p.text(info.text, boxX + boxPadding, boxY + boxPadding + i * lineHeight); |
|||
p.textStyle(p.NORMAL); // Reset to normal for the next line
|
|||
}); |
|||
|
|||
// Draw individual connector lines
|
|||
hoveredItems.forEach((item, i) => { |
|||
p.stroke(highlightColor); |
|||
p.strokeWeight(BASE_LINE_THICKNESS / zoomFactor); |
|||
const connectorAnchorY = |
|||
boxY + boxPadding + i * lineHeight + lineHeight / 2; |
|||
p.line(connectorAnchorX, connectorAnchorY, item.screenX, item.screenY); |
|||
}); |
|||
|
|||
p.pop(); |
|||
} |
|||
|
|||
export const zoomSketch = function (p) { |
|||
let plotScales = { plotScaleX: 1, plotScaleY: 1 }; |
|||
let lastUpdate = { mainMouseX: 0, mainMouseY: 0, hoveredItems: [] }; |
|||
let canvas = null; |
|||
const containerId = "zoom-canvas-container"; |
|||
|
|||
appState.zoomFactor = 4; // Set a default zoom factor in the global state
|
|||
|
|||
p.setup = function () { |
|||
p.noLoop(); |
|||
}; |
|||
|
|||
p.updateAndDraw = function (mainMouseX, mainMouseY, hoveredItems, scales) { |
|||
lastUpdate = { mainMouseX, mainMouseY, hoveredItems }; |
|||
plotScales = scales; |
|||
if (!canvas) { |
|||
const container = document.getElementById(containerId); |
|||
if (container && container.offsetWidth > 0) { |
|||
canvas = p.createCanvas(container.offsetWidth, container.offsetHeight); |
|||
canvas.parent(containerId); |
|||
} else { |
|||
return; |
|||
} |
|||
} |
|||
p.redraw(); |
|||
}; |
|||
|
|||
p.draw = function () { |
|||
if (!appState.vizData || !canvas) return; |
|||
|
|||
p.background( |
|||
document.documentElement.classList.contains("dark") |
|||
? p.color(55, 65, 81) |
|||
: 255 |
|||
); |
|||
|
|||
const { mainMouseX, mainMouseY, hoveredItems } = lastUpdate; |
|||
|
|||
p.push(); // Start zoom transformations
|
|||
p.translate( |
|||
p.width / 2 - mainMouseX * appState.zoomFactor, |
|||
p.height / 2 - mainMouseY * appState.zoomFactor |
|||
); |
|||
p.scale(appState.zoomFactor); |
|||
|
|||
// --- Redraw the scene from scratch ---
|
|||
if (appState.p5_instance && appState.p5_instance.getStaticBackground) { |
|||
p.image( |
|||
appState.p5_instance.getStaticBackground(), |
|||
0, |
|||
0, |
|||
appState.p5_instance.width, |
|||
appState.p5_instance.height |
|||
); |
|||
} |
|||
|
|||
p.push(); // Start radar transformations
|
|||
p.translate( |
|||
appState.p5_instance.width / 2, |
|||
appState.p5_instance.height * 0.95 |
|||
); |
|||
p.scale(1, -1); |
|||
|
|||
const frameData = appState.vizData.radarFrames[appState.currentFrame]; |
|||
drawAxes(p, plotScales); |
|||
drawEgoVehicle(p, plotScales); |
|||
if (frameData) { |
|||
drawTrackMarkers(p, plotScales); |
|||
drawRegionsOfInterest(p, frameData, plotScales); |
|||
if (toggleTracks.checked) { |
|||
drawTrajectories(p, plotScales); |
|||
} |
|||
drawPointCloud(p, frameData.pointCloud, plotScales); |
|||
if (toggleClusterColor.checked) { |
|||
drawClusterCentroids(p, frameData.clusters, plotScales); |
|||
} |
|||
if (togglePredictedPos.checked) { |
|||
for (const track of appState.vizData.tracks) { |
|||
const log = track.historyLog.find( |
|||
(log) => log.frameIdx === appState.currentFrame + 1 |
|||
); |
|||
if ( |
|||
log && |
|||
log.predictedPosition && |
|||
log.predictedPosition[0] !== null |
|||
) { |
|||
const pos = log.predictedPosition; |
|||
const x = pos[0] * plotScales.plotScaleX; |
|||
const y = pos[1] * plotScales.plotScaleY; |
|||
|
|||
p.push(); |
|||
p.stroke(255, 0, 0); // Red for predicted
|
|||
p.strokeWeight(2); |
|||
p.line(x - 4, y - 4, x + 4, y + 4); |
|||
p.line(x + 4, y - 4, x - 4, y + 4); |
|||
p.pop(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
p.pop(); // End radar transformations
|
|||
|
|||
// --- Call the new, self-contained tooltip function ---
|
|||
drawZoomTooltip(p, hoveredItems, mainMouseX); |
|||
|
|||
p.pop(); // End zoom transformations
|
|||
// --- START: DRAW TITLE OVERLAY ---
|
|||
// This code runs *after* the zoom transformations have been popped,
|
|||
// so it draws directly onto the canvas as a fixed UI element.
|
|||
p.push(); |
|||
const titleLabel = document.getElementById('toggle-close-up').parentElement; |
|||
const titleText = titleLabel ? titleLabel.textContent.trim() : 'Zoom Mode'; |
|||
const textColor = document.documentElement.classList.contains("dark") ? 220 : 80; |
|||
p.fill(textColor); |
|||
p.noStroke(); |
|||
p.textSize(16); |
|||
p.textAlign(p.LEFT, p.TOP); |
|||
p.textStyle(p.BOLD); |
|||
p.text(titleText, 10, 10); |
|||
p.pop(); |
|||
// --- END: DRAW TITLE OVERLAY ---
|
|||
|
|||
|
|||
// --- Draw Crosshairs ---
|
|||
p.stroke(255, 0, 0, 150); |
|||
p.strokeWeight(1.5); |
|||
p.line(p.width / 2 - 15, p.height / 2, p.width / 2 + 15, p.height / 2); |
|||
p.line(p.width / 2, p.height / 2 - 15, p.width / 2, p.height / 2 + 15); |
|||
}; |
|||
|
|||
p.windowResized = function () { |
|||
if (canvas) { |
|||
const container = document.getElementById(containerId); |
|||
if (container) { |
|||
p.resizeCanvas(container.offsetWidth, container.offsetHeight); |
|||
} |
|||
} |
|||
}; |
|||
}; |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue