From f7000992ca89011ddd80b78618d84e59abf1050f Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sun, 5 Apr 2026 13:08:05 -0700 Subject: [PATCH] fix(rf-health): auto-scale airtime Y-axis + hover tooltips (#600) (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses user feedback on #600 — two improvements to RF Health detail panel charts: ### 1. Auto-scale airtime Y-axis Previously fixed 0-100% which made low-activity nodes unreadable (e.g. 0.1% TX barely visible). Now auto-scales to the actual data range with 20% headroom (minimum 1%), matching how the noise floor chart already works. ### 2. Hover tooltips on all chart data points Invisible SVG `` elements with native `` tooltips on every data point across all 4 charts: - **Noise floor**: `NF: -112.3 dBm` + UTC timestamp - **Airtime**: `TX: 2.1%` or `RX: 8.3%` + UTC timestamp - **Error rate**: `Err: 0.05%` + UTC timestamp - **Battery**: `Batt: 3.85V` + UTC timestamp Uses native browser SVG tooltips — zero dependencies, accessible, no JS event handlers. ### Design rationale (Tufte) - Auto-scaling increases data-ink ratio by eliminating wasted vertical space - Tooltips provide detail-on-demand without cluttering the chart with labels on every point ### Spec update Added M2 feedback improvements section to `docs/specs/rf-health-dashboard.md`. --------- Co-authored-by: you <you@example.com> --- docs/specs/rf-health-dashboard.md | 4 +++ public/analytics.js | 45 +++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/specs/rf-health-dashboard.md b/docs/specs/rf-health-dashboard.md index 031fcf7c..eac18166 100644 --- a/docs/specs/rf-health-dashboard.md +++ b/docs/specs/rf-health-dashboard.md @@ -431,6 +431,10 @@ Note: No hardcoded duty cycle limit line on charts. Duty cycle regulations vary - All charts time-aligned, sharing X-axis, reboot markers spanning all charts - Tests: delta computation, reboot handling, counter reset, gap insertion, downsampling, error rate calculation +#### M2 feedback improvements (post-M2) +- **Auto-scale airtime Y-axis**: clamp to min/max of actual data values (20% headroom, min 1%) instead of fixed 0-100%, matching noise floor chart behavior. Increases data-ink ratio for low-activity nodes. +- **Hover tooltips on all chart data points**: invisible SVG circles with `<title>` elements on every data point across all 4 charts (noise floor, airtime, error rate, battery). Shows exact value + UTC timestamp on hover. Detail-on-demand without cluttering the chart. + ### M3: Pattern detection - Implement after operators have used raw charts (M1–M2) and provided feedback - Jammer detection (NF spike + RX drop) diff --git a/public/analytics.js b/public/analytics.js index e1dfcadc..86c1d22d 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2945,6 +2945,20 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ } // Shared helper: render X-axis time labels + function rfTooltipCircles(data, sx, sy, label, unit, formatV) { + let svg = ''; + formatV = formatV || (v => v.toFixed(1)); + data.forEach(d => { + const t = new Date(d.t); + const x = sx(t.getTime()); + const y = sy(d.v); + const ts = t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC'); + const tip = `${label}: ${formatV(d.v)}${unit}\n${ts}`; + svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="transparent" stroke="none" pointer-events="all"><title>${tip}`; + }); + return svg; + } + function rfXAxisLabels(data, sx, h, pad) { let svg = ''; const xTicks = Math.min(6, data.length); @@ -2967,18 +2981,26 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ const minT = sharedMinT, maxT = sharedMaxT; const rangeT = maxT - minT || 1; + // Auto-scale Y-axis to data range (20% headroom, min 1%) + let dataMax = 0; + for (let i = 0; i < txData.length; i++) { if (txData[i].v > dataMax) dataMax = txData[i].v; } + for (let i = 0; i < rxData.length; i++) { if (rxData[i].v > dataMax) dataMax = rxData[i].v; } + const yMax = Math.max(dataMax * 1.2, 1); + const sx = t => pad.left + ((t - minT) / rangeT) * cw; - const sy = v => pad.top + ch - (v / 100) * ch; // 0-100% + const sy = v => pad.top + ch - (v / yMax) * ch; let svg = `Airtime %`; // Chart title svg += `Airtime %`; - // Y-axis: 0, 25, 50, 75, 100 - for (let pct = 0; pct <= 100; pct += 25) { - const y = sy(pct); - svg += `${pct}`; + // Y-axis: 5 ticks from 0 to yMax + const yTicks = 4; + for (let i = 0; i <= yTicks; i++) { + const v = yMax * i / yTicks; + const y = sy(v); + svg += `${v.toFixed(1)}`; svg += ``; } @@ -3019,6 +3041,10 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ const allData = txData.length >= rxData.length ? txData : rxData; svg += rfXAxisLabels(allData, sx, h, pad); + // Hover tooltips + svg += rfTooltipCircles(txData, sx, sy, 'TX', '%'); + svg += rfTooltipCircles(rxData, sx, sy, 'RX', '%'); + svg += ''; return svg; } @@ -3068,6 +3094,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // X-axis labels svg += rfXAxisLabels(errData, sx, h, pad); + // Hover tooltips + svg += rfTooltipCircles(errData, sx, sy, 'Err', '%', v => v.toFixed(2)); + svg += ''; return svg; } @@ -3126,6 +3155,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // X-axis labels svg += rfXAxisLabels(battData, sx, h, pad); + // Hover tooltips + svg += rfTooltipCircles(battData, sx, sy, 'Batt', 'V', v => (v/1000).toFixed(2)); + svg += ''; return svg; } @@ -3183,6 +3215,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // Data polyline svg += ``; + // Hover tooltips + svg += rfTooltipCircles(data, sx, sy, 'NF', ' dBm'); + // Direct labels: min and max points const times = data.map(d => new Date(d.t).getTime()); const maxIdx = values.indexOf(maxV);