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 `
` 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 += `${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 = `';
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);