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