From d5d4311e0e6f1b0a9e77b7c3f77ec636a58c904d Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Sat, 16 May 2026 16:28:04 +0300 Subject: [PATCH] Web: sync graph hover by timestamp instead of relative position When hovering over a sparkline, the same X fraction was applied to all other canvases to compute the highlighted point index. Because different series can have different numbers of valid samples (some values are filtered out), the same fraction mapped to different indices and therefore different timestamps on each graph. Fix by reading the uptime_secs timestamp (point[0]) of the hovered point on the source canvas and finding the closest-timestamp point in each other series, instead of using the fractional position. Also de-duplicate the near-identical onmousemove/ontouchstart and onmouseleave/ontouchend handlers into syncHoverByX() and syncHoverClear() helpers, reducing the block from 34 lines to 16. --- src/helpers/web/WebPanelServer.cpp | 46 +++++++++++------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 94bf8445..12111332 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1998,40 +1998,28 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( }; canvas._updateHover = updateHover; canvas._points = points; - canvas.onmousemove = (event) => { - const rect = canvas.getBoundingClientRect(); - const width = rect.width || 1; - const x = Math.max(0, Math.min(width, event.clientX - rect.left)); - const fraction = x / width; + function syncHoverByX(srcCanvas, clientX) { + const rect = srcCanvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width || 1, clientX - rect.left)); + const srcIdx = Math.max(0, Math.min(points.length - 1, Math.round(x / (rect.width || 1) * (points.length - 1)))); + const hoveredTs = points[srcIdx][0]; document.querySelectorAll('#statsTrends canvas').forEach(c => { if (!c._updateHover || !c._points) return; - const idx = Math.max(0, Math.min(c._points.length - 1, Math.round(fraction * (c._points.length - 1)))); - c._updateHover(idx, c === canvas); + if (c === srcCanvas) { c._updateHover(srcIdx, true); return; } + let bestIdx = 0, bestDiff = Infinity; + c._points.forEach((pt, i) => { const d = Math.abs(pt[0] - hoveredTs); if (d < bestDiff) { bestDiff = d; bestIdx = i; } }); + c._updateHover(bestIdx, false); }); - }; - canvas.onmouseleave = () => { + } + function syncHoverClear(srcCanvas) { document.querySelectorAll('#statsTrends canvas').forEach(c => { - if (c._updateHover) c._updateHover(null, c === canvas); + if (c._updateHover) c._updateHover(null, c === srcCanvas); }); - }; - canvas.ontouchstart = (event) => { - const touch = event.touches && event.touches[0]; - if (!touch) return; - const rect = canvas.getBoundingClientRect(); - const width = rect.width || 1; - const x = Math.max(0, Math.min(width, touch.clientX - rect.left)); - const fraction = x / width; - document.querySelectorAll('#statsTrends canvas').forEach(c => { - if (!c._updateHover || !c._points) return; - const idx = Math.max(0, Math.min(c._points.length - 1, Math.round(fraction * (c._points.length - 1)))); - c._updateHover(idx, c === canvas); - }); - }; - canvas.ontouchend = () => { - document.querySelectorAll('#statsTrends canvas').forEach(c => { - if (c._updateHover) c._updateHover(null, c === canvas); - }); - }; + } + canvas.onmousemove = (e) => syncHoverByX(canvas, e.clientX); + canvas.onmouseleave = () => syncHoverClear(canvas); + canvas.ontouchstart = (e) => { const t = e.touches && e.touches[0]; if (t) syncHoverByX(canvas, t.clientX); }; + canvas.ontouchend = () => syncHoverClear(canvas); } function setTrendCardState(key, title, value) { const card = document.getElementById("trend-" + key);