From 7344c59ba2b34559ed6141510498dbd835943d66 Mon Sep 17 00:00:00 2001 From: rakadu1 Date: Mon, 3 Nov 2025 16:41:31 +0530 Subject: [PATCH] feat(zoom): Implement advanced zoom controls and UX improvements This commit introduces a suite of major enhancements to the "Zoom Mode" functionality, focusing on user experience, interaction smoothness, and bug fixes. It refines the data display logic, adds interactive visual feedback, and implements a more intelligent auto-hide behavior. #### 1. Mouse Smoothing for Zoom Navigation To address jittery mouse movements at high magnification, a linear interpolation (lerp) filter has been implemented. - **`radarSketch.js`**: A "smoothed" mouse coordinate is now calculated on each frame when zoom mode is active. This smoothed position is used for all zoom-related logic, including positioning the zoom window's view and detecting hovered items. - **`drawUtils.js`**: The `handleCloseUpDisplay` function has been updated to accept the smoothed coordinates, ensuring that hover detection is perfectly synchronized with the smoothed visual feedback. This results in a much more fluid and controllable navigation experience in the zoom window. #### 2. Dynamic and Visual Zoom Feedback The zoom interaction has been made more intuitive with several visual aids. - **Variable Hover Radius**: The hover detection radius is now inversely proportional to the zoom factor. This provides a larger, more forgiving selection area when zoomed out and a smaller, more precise area when zoomed in. The formula `constrain(80 / zoomFactor, 5, 25)` is used to keep the radius within a usable range. - **Zoom Area Rectangle**: A semi-transparent, dashed red rectangle is now drawn on the main radar canvas, centered on the smoothed mouse position. This rectangle visually represents the exact area being magnified in the zoom window. - **Debug Circle**: For tuning and visualization, a temporary purple circle is drawn on both the main radar canvas and within the zoom sketch. This circle's size dynamically matches the current hover radius, making it easy to see the selection area at any zoom level. #### 3. Intelligent Auto-Hide with Countdown The behavior of the zoom window when the user stops hovering over points has been significantly improved. - **Grace Period**: A 2-second grace period has been added. When the user stops hovering, the zoom window remains active and continues to follow the mouse for 2 seconds before any closing action begins. - **Visual Countdown**: After the grace period, a 3-second countdown is initiated. The zoom window displays a "Closing in 3... 2... 1..." message, clearly communicating its state to the user. - **State Management**: The logic in `radarSketch.js` and `state.js` was refactored to correctly manage the delay timer (`zoomHideDelayTimeout`) and the countdown interval (`zoomCountdownInterval`), ensuring the grace period works as intended and the UI updates smoothly. #### 4. Bug Fixes and Data Consistency - **Data Synchronization**: Corrected a critical bug where tooltips and rendered markers were using data from different frames. All drawing and tooltip logic in `radarSketch.js`, `zoomSketch.js`, and `drawUtils.js` has been unified to use data exclusively from the `appState.currentFrame`. - **Console Warning Fix**: Resolved a persistent `CanvasTextAlign` error in the console caused by an incorrect `textAlign(p.LEFT - 2)` call in `zoomSketch.js`. These changes culminate in a more robust, intuitive, and polished zoom feature that is both more powerful for analysis and more pleasant to use. --- steps/src/drawUtils.js | 16 +++--- steps/src/p5/radarSketch.js | 109 ++++++++++++++++++++++++++++-------- steps/src/p5/zoomSketch.js | 40 +++++++++++++ steps/src/state.js | 4 +- 4 files changed, 136 insertions(+), 33 deletions(-) diff --git a/steps/src/drawUtils.js b/steps/src/drawUtils.js index 3ff0d6c..5805f47 100644 --- a/steps/src/drawUtils.js +++ b/steps/src/drawUtils.js @@ -522,7 +522,7 @@ export function drawTrackMarkers(p, plotScales) { * @param {p5} p - The p5 instance. * @param {object} plotScales - The calculated scales for plotting. */ -export function handleCloseUpDisplay(p, plotScales) { +export function handleCloseUpDisplay(p, plotScales, mouseX, mouseY) { // --- Step 1: Gather Hovered Items --- const frameData = appState.vizData.radarFrames[appState.currentFrame]; if (!frameData) return []; // Return empty array if no data @@ -544,7 +544,7 @@ export function handleCloseUpDisplay(p, plotScales) { if (pt.x === null || pt.y === null) continue; const screenX = pt.x * plotScales.plotScaleX + p.width / 2; const screenY = p.height * 0.95 - pt.y * plotScales.plotScaleY; - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + const d = p.dist(mouseX, mouseY, screenX, screenY); // Use smoothed values if (d < radius) { // Add the index 'i' to the object we push hoveredItems.push({ @@ -568,7 +568,7 @@ export function handleCloseUpDisplay(p, plotScales) { if (cluster.x === null || cluster.y === null) continue; const screenX = cluster.x * plotScales.plotScaleX + p.width / 2; const screenY = p.height * 0.95 - cluster.y * plotScales.plotScaleY; - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + const d = p.dist(mouseX, mouseY, screenX, screenY); // Use smoothed values if (d < radius) { const color = cluster.id > 0 @@ -599,7 +599,7 @@ export function handleCloseUpDisplay(p, plotScales) { const pos = currentLog.correctedPosition; const screenX = pos[0] * plotScales.plotScaleX + p.width / 2; const screenY = p.height * 0.95 - pos[1] * plotScales.plotScaleY; - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + const d = p.dist(mouseX, mouseY, screenX, screenY); // Use smoothed values if (d < radius) { hoveredItems.push({ type: "track", @@ -622,7 +622,7 @@ export function handleCloseUpDisplay(p, plotScales) { const pos = currentLog.predictedPosition; const screenX = pos[0] * plotScales.plotScaleX + p.width / 2; const screenY = p.height * 0.95 - pos[1] * plotScales.plotScaleY; - const d = p.dist(p.mouseX, p.mouseY, screenX, screenY); + const d = p.dist(mouseX, mouseY, screenX, screenY); // Use smoothed values if (d < radius) { hoveredItems.push({ type: "prediction", @@ -727,11 +727,11 @@ export function handleCloseUpDisplay(p, plotScales) { const xOffset = 20; let boxX, lineAnchorX; - if (p.mouseX + xOffset + boxWidth > p.width) { - boxX = p.mouseX - boxWidth - xOffset; + if (mouseX + xOffset + boxWidth > p.width) { // Use smoothed values + boxX = mouseX - boxWidth - xOffset; lineAnchorX = boxX + boxWidth; } else { - boxX = p.mouseX + xOffset; + boxX = mouseX + xOffset; lineAnchorX = boxX; } let boxY = p.mouseY - boxHeight / 2; diff --git a/steps/src/p5/radarSketch.js b/steps/src/p5/radarSketch.js index dcf22d4..c41dbc0 100644 --- a/steps/src/p5/radarSketch.js +++ b/steps/src/p5/radarSketch.js @@ -39,6 +39,12 @@ export const radarSketch = function (p) { // p5.Graphics buffers for static elements to optimize drawing let staticBackgroundBuffer, snrLegendBuffer, trackLegendBuffer; + // --- START: Mouse Smoothing Variables --- + let smoothedMouseX = 0; + let smoothedMouseY = 0; + let isFirstFrame = true; // Flag to initialize smoothed position + // --- END: Mouse Smoothing Variables --- + // Helper function to allow other sketches to access the static background p.getStaticBackground = function () { return staticBackgroundBuffer; @@ -221,10 +227,28 @@ export const radarSketch = function (p) { // BUG FIX 1: Call the close-up handler if the mode is active // --- Zoom and Tooltip Logic --- - const COOLING_PERIOD_MS = 2000; + const COOLING_PERIOD_MS = 2000; // Set to 3 seconds for the countdown const zoomPanel = document.getElementById("zoom-panel"); if (appState.isCloseUpMode) { - const hoveredItems = handleCloseUpDisplay(p, plotScales); + // --- START: Mouse Smoothing Logic --- + // On the first frame of zoom, snap the smoothed position to the real mouse position. + if (isFirstFrame) { + smoothedMouseX = p.mouseX; + smoothedMouseY = p.mouseY; + isFirstFrame = false; + } + + // The smoothing factor. A smaller value (e.g., 0.1) means more smoothing. + // This can be adjusted to feel more or less responsive. + const smoothingFactor = 0.5; + + // Linearly interpolate the smoothed position towards the actual mouse position. + smoothedMouseX = p.lerp(smoothedMouseX, p.mouseX, smoothingFactor); + smoothedMouseY = p.lerp(smoothedMouseY, p.mouseY, smoothingFactor); + + // Use the smoothed coordinates for all subsequent zoom-related calculations. + const hoveredItems = handleCloseUpDisplay(p, plotScales, smoothedMouseX, smoothedMouseY); + // --- END: Mouse Smoothing Logic --- // --- START: Draw Zoom Area Rectangle & Debug Circle --- const zoomWindow = document.getElementById("zoom-canvas-container"); @@ -242,7 +266,7 @@ export const radarSketch = function (p) { p.strokeWeight(1); // Reduced thickness. p.drawingContext.setLineDash([5, 3]); // Dashed line. p.rectMode(p.CENTER); - p.rect(p.mouseX, p.mouseY, sourceWidth, sourceHeight); + p.rect(smoothedMouseX, smoothedMouseY, sourceWidth, sourceHeight); // Use smoothed values p.drawingContext.setLineDash([]); // Reset line dash p.pop(); } @@ -254,14 +278,18 @@ export const radarSketch = function (p) { p.noFill(); p.stroke(148, 0, 211, 150); // Deep purple, semi-transparent. p.strokeWeight(1); - p.drawingContext.setLineDash([5,3]) - p.ellipse(p.mouseX, p.mouseY, hoverRadius * 2, hoverRadius * 2); + p.ellipse(smoothedMouseX, smoothedMouseY, hoverRadius * 2, hoverRadius * 2); // Use smoothed values p.pop(); // --- END: Draw Zoom Area Rectangle & Debug Circle --- if (hoveredItems.length > 0) { - clearTimeout(appState.zoomHoverTimeout); // Cancel the timer - appState.zoomHoverTimeout = null; + // If we are hovering, cancel any existing countdown. + clearTimeout(appState.zoomHideDelayTimeout); + appState.zoomHideDelayTimeout = null; + clearInterval(appState.zoomCountdownInterval); + appState.zoomCountdownInterval = null; + appState.zoomCountdown = null; + if (zoomPanel.style.display !== "block") { zoomPanel.style.display = "block"; } @@ -270,41 +298,74 @@ export const radarSketch = function (p) { appState.zoomSketchInstance.updateAndDraw ) { appState.zoomSketchInstance.updateAndDraw( - p.mouseX, - p.mouseY, + smoothedMouseX, // Use smoothed values + smoothedMouseY, // Use smoothed values hoveredItems, plotScales ); } } else if (zoomPanel.style.display === "block") { - // --- THIS BLOCK IS THE FIX --- - // If NOT hovering, but the panel is still visible: - - // 1. Continue to update the zoom sketch's position to follow the mouse. - // We pass an empty array for hoveredItems, so no tooltip is drawn. + // --- START: FIX for Grace Period Freeze --- + // If NOT hovering, but the panel is still visible, we must continue + // to update the zoom sketch so it follows the mouse. + // We pass an empty array for hoveredItems, so no tooltip is drawn. if ( appState.zoomSketchInstance && appState.zoomSketchInstance.updateAndDraw ) { appState.zoomSketchInstance.updateAndDraw( - p.mouseX, - p.mouseY, - [], // Pass empty array + smoothedMouseX, // Use smoothed values + smoothedMouseY, // Use smoothed values + [], // Pass empty array to hide tooltips plotScales ); } - + // --- END: FIX for Grace Period Freeze --- // 2. If a "hide" timer isn't already running, start one. - if (!appState.zoomHoverTimeout) { - appState.zoomHoverTimeout = setTimeout(() => { - console.log("Cooling period ended. Hiding zoom panel."); - zoomPanel.style.display = "none"; - appState.zoomHoverTimeout = null; - }, COOLING_PERIOD_MS); + if (!appState.zoomHideDelayTimeout && !appState.zoomCountdownInterval) { + // Start a 2-second delay before the countdown begins. + appState.zoomHideDelayTimeout = setTimeout(() => { + appState.zoomHideDelayTimeout = null; // Clear the delay timer ID + // Now, start the actual 3-second countdown interval. + appState.zoomCountdown = Math.floor(COOLING_PERIOD_MS / 1000); + appState.zoomCountdownInterval = setInterval(() => { + appState.zoomCountdown--; + if (appState.zoomCountdown <= 0) { + // When countdown finishes, hide panel and clear interval. + clearInterval(appState.zoomCountdownInterval); + appState.zoomCountdownInterval = null; + appState.zoomCountdown = null; + zoomPanel.style.display = "none"; + } else { + // Force a redraw of the zoom sketch to show the new countdown value. + // This call is still needed inside the interval to update the countdown text. + if (appState.zoomSketchInstance && appState.zoomSketchInstance.updateAndDraw) { + // Pass empty hoveredItems to show the countdown text. + appState.zoomSketchInstance.updateAndDraw( + smoothedMouseX, + smoothedMouseY, + [], + plotScales); + } + } + }, 1000); + }, 1000); // 1000ms = 1 second delay } } } else { + // --- START: Cleanup Logic --- + // When zoom mode is turned off, ensure all timers are cleared. + if (appState.zoomHideDelayTimeout) { + clearTimeout(appState.zoomHideDelayTimeout); + appState.zoomHideDelayTimeout = null; + } + if (appState.zoomCountdownInterval) { + clearInterval(appState.zoomCountdownInterval); + appState.zoomCountdownInterval = null; + } + // --- END: Cleanup Logic --- zoomPanel.style.display = "none"; + isFirstFrame = true; // Reset for the next time zoom mode is enabled } // --- Legend Drawing --- // Draw the SNR legend if enabled diff --git a/steps/src/p5/zoomSketch.js b/steps/src/p5/zoomSketch.js index 43fc1bd..d9ff4f4 100644 --- a/steps/src/p5/zoomSketch.js +++ b/steps/src/p5/zoomSketch.js @@ -301,6 +301,22 @@ export const zoomSketch = function (p) { // --- Call the new, self-contained tooltip function --- drawZoomTooltip(p, hoveredItems, mainMouseX); + // --- START: Draw Purple Debug Circle --- + // This circle represents the hover radius, drawn in the zoomed coordinate space. + // The formula must match the one in drawUtils.js. + const hoverRadius = p.constrain(80 / appState.zoomFactor, 5, 25); + p.push(); + p.noFill(); + p.stroke(148, 0, 211, 150); // Deep purple, semi-transparent. + // The stroke weight is divided by the zoom factor to keep it thin. + p.strokeWeight(1 / appState.zoomFactor); + p.drawingContext.setLineDash([5 / appState.zoomFactor, 3 / appState.zoomFactor]); + // The circle is drawn at the mouse position from the main canvas. + p.ellipse(mainMouseX, mainMouseY, hoverRadius * 2, hoverRadius * 2); + p.drawingContext.setLineDash([]); + p.pop(); + // --- END: Draw Purple Debug Circle --- + p.pop(); // End zoom transformations // --- START: DRAW TITLE OVERLAY --- // This code runs *after* the zoom transformations have been popped, @@ -320,6 +336,30 @@ export const zoomSketch = function (p) { p.pop(); // --- END: DRAW TITLE OVERLAY --- + // --- START: Draw Countdown Overlay --- + if (appState.zoomCountdown !== null && appState.zoomCountdown > 0) { + p.push(); + // Semi-transparent black background for readability + p.fill(0, 0, 0, 150); + p.noStroke(); + p.rectMode(p.CENTER); + p.rect(p.width / 2, p.height / 2, p.width, p.height); + + // Draw the text + p.fill(255); + p.textAlign(p.CENTER, p.CENTER); + p.textSize(18); + p.textStyle(p.NORMAL); + p.text( + "Hover over points again to resume the display", + p.width / 2, + p.height / 2 - 15 + ); + p.text(`Closing in ${appState.zoomCountdown}...`, p.width / 2, p.height / 2 + 15); + p.pop(); + } + // --- END: Draw Countdown Overlay --- + // --- Draw Crosshairs --- p.stroke(255, 0, 0, 150); p.strokeWeight(1.5); diff --git a/steps/src/state.js b/steps/src/state.js index 13c9801..18f9e96 100644 --- a/steps/src/state.js +++ b/steps/src/state.js @@ -1,5 +1,7 @@ export const appState = { - zoomHoverTimeout: null, // timeout for hovering over the GOD MODE + zoomHideDelayTimeout: null, // Timeout before the hide countdown begins + zoomCountdown: null, // Holds the number of seconds left before zoom hides + zoomCountdownInterval: null, // The interval timer for the countdown isRawOnlyMode: false, // <-- ADD THIS LINE // Stores the parsed visualization data (radar frames, tracks, etc.)