mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 13:44:35 +00:00
feat: noise floor column chart with color-coded thresholds (#659)
## Noise Floor: Line Chart → Color-Coded Column Chart Implements M3a from the [RF Health Dashboard spec](https://github.com/Kpa-clawbot/CoreScope/issues/600#issuecomment-2784399622) — replacing the noise floor line chart with discrete color-coded columns. ### What changed **`public/analytics.js`** — replaced `rfNFLineChart()` with `rfNFColumnChart()`: - **Color-coded bars by threshold**: green (`< -100 dBm`), yellow (`-100 to -85 dBm`), red (`≥ -85 dBm`) - **Instant hover tooltips**: exact dBm value + UTC timestamp via native SVG `<title>` — no delay - **Column highlighting on hover**: CSS `:hover` with opacity change + border stroke - **Inline legend**: green/yellow/red threshold key in chart header - **Removed reference lines**: the `-100 warning` and `-85 critical` dashed lines are eliminated — threshold info is now encoded directly in bar color (data-ink ratio improvement) - **No gap detection**: column charts render discrete bars — each data point is an independent observation, so line-chart-style gap detection doesn't apply. Every sample gets a bar. - **Reboot markers**: vertical dashed lines with "reboot" labels at reboot timestamps (shared `rfRebootMarkers` helper, same as other RF charts) - **Division-by-zero guard**: constant values or single data points use a ±5 dBm window so bars render with visible height - **Sparklines unchanged**: fleet overview sparklines remain as polylines (correct at 140×24px scale) ### Why columns instead of lines A polyline connecting discrete 5-minute noise floor samples creates false visual continuity — it implies interpolation between measurements that doesn't exist. When readings jump between -115 and -95 irregularly, the line becomes a jagged mess. Column bars encode each sample as a discrete, independent observation: one bar = one measurement. ### Testing - 12 unit tests in `test-frontend-helpers.js` covering: SVG output, threshold color coding, tooltips, empty/single/constant data, legend rendering, reboot markers, shared time axis - All existing tests pass (packet-filter: 62, aging: 29, frontend-helpers: 490) ### No backend changes Pure frontend change — ~150 lines in `analytics.js`. Fixes #600 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
+52
-31
@@ -1881,6 +1881,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
window._analyticsRfNFColumnChart = rfNFColumnChart;
|
||||
}
|
||||
|
||||
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
|
||||
@@ -2932,7 +2933,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
// Render noise floor chart
|
||||
const nfEl = document.getElementById('rfDetailNFChart');
|
||||
if (nfEl && nfData.length > 1) {
|
||||
nfEl.innerHTML = rfNFLineChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
|
||||
nfEl.innerHTML = rfNFColumnChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
|
||||
} else if (nfEl) {
|
||||
nfEl.innerHTML = '<span class="text-muted">Not enough noise floor data</span>';
|
||||
}
|
||||
@@ -3196,7 +3197,13 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
return svg;
|
||||
}
|
||||
|
||||
function rfNFLineChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
|
||||
/**
|
||||
* Noise floor column chart — color-coded bars (green/yellow/red) by threshold.
|
||||
* Replaces the old line chart for better discrete-sample readability.
|
||||
* Thresholds: green (< -100 dBm), yellow (-100 to -85 dBm), red (≥ -85 dBm).
|
||||
*/
|
||||
function rfNFColumnChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
|
||||
if (!data || !data.length) return '<svg viewBox="0 0 1 1"></svg>';
|
||||
reboots = reboots || [];
|
||||
const pad = { top: 20, right: 40, bottom: 30, left: 55 };
|
||||
const cw = w - pad.left - pad.right;
|
||||
@@ -3207,34 +3214,33 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
const maxT = sharedMaxT != null ? sharedMaxT : Math.max(...data.map(d => new Date(d.t).getTime()));
|
||||
const minV = Math.min(...values);
|
||||
const maxV = Math.max(...values);
|
||||
const rangeV = maxV - minV || 1;
|
||||
// Guard against zero range (single data point or constant values):
|
||||
// use a ±5 dBm window so bars are visible and centered in the chart
|
||||
const rawRangeV = maxV - minV;
|
||||
const rangeV = rawRangeV || 10;
|
||||
const adjMinV = rawRangeV ? minV : minV - 5;
|
||||
const rangeT = maxT - minT || 1;
|
||||
|
||||
const sx = t => pad.left + ((t - minT) / rangeT) * cw;
|
||||
const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch;
|
||||
const sy = v => pad.top + ch - ((v - adjMinV) / rangeV) * ch;
|
||||
|
||||
const pts = data.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' ');
|
||||
// Column width: proportional to chart width / data points, min 2px, gap of 1px
|
||||
const colW = Math.max(2, Math.floor(cw / data.length) - 1);
|
||||
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor line chart"><title>Noise floor over time</title>`;
|
||||
const times = data.map(d => new Date(d.t).getTime());
|
||||
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor column chart"><title>Noise floor over time</title>`;
|
||||
|
||||
// Inline style for hover highlighting
|
||||
svg += `<style>.nf-bar{transition:opacity 0.05s}.nf-bar:hover{opacity:0.75;stroke:var(--text);stroke-width:1}</style>`;
|
||||
|
||||
// Chart title
|
||||
svg += `<text x="${pad.left}" y="12" font-size="10" fill="var(--text-muted)" font-weight="600">Noise Floor dBm</text>`;
|
||||
|
||||
// Reference lines
|
||||
const refLines = [-100, -85];
|
||||
const refLabels = ['-100 warning', '-85 critical'];
|
||||
refLines.forEach((ref, i) => {
|
||||
if (ref >= minV && ref <= maxV) {
|
||||
const y = sy(ref);
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="4,2"/>`;
|
||||
svg += `<text x="${w - pad.right + 2}" y="${(y + 3).toFixed(1)}" font-size="9" fill="var(--text-muted)">${refLabels[i]}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Y-axis labels
|
||||
// Y-axis labels + grid lines
|
||||
const yTicks = 5;
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const v = minV + (rangeV * i / yTicks);
|
||||
const v = adjMinV + (rangeV * i / yTicks);
|
||||
const y = sy(v);
|
||||
svg += `<text x="${pad.left - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="var(--text-muted)">${v.toFixed(0)}</text>`;
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--border)" stroke-width="0.3"/>`;
|
||||
@@ -3246,24 +3252,39 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
// X-axis labels
|
||||
svg += rfXAxisLabels(data, sx, h, pad);
|
||||
|
||||
// Data polyline
|
||||
svg += `<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/>`;
|
||||
// Color-coded columns
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const t = times[i];
|
||||
const v = data[i].v;
|
||||
const x = sx(t) - colW / 2;
|
||||
const y = sy(v);
|
||||
const barH = pad.top + ch - y;
|
||||
|
||||
// Hover tooltips
|
||||
svg += rfTooltipCircles(data, sx, sy, 'NF', ' dBm');
|
||||
// Threshold color: green < -100, yellow -100 to -85, red >= -85
|
||||
let color;
|
||||
if (v < -100) color = 'var(--success, #22c55e)';
|
||||
else if (v < -85) color = 'var(--warning, #eab308)';
|
||||
else color = 'var(--danger, #ef4444)';
|
||||
|
||||
// Direct labels: min and max points
|
||||
const times = data.map(d => new Date(d.t).getTime());
|
||||
const maxIdx = values.indexOf(maxV);
|
||||
const minIdx = values.indexOf(minV);
|
||||
svg += `<circle cx="${sx(times[maxIdx]).toFixed(1)}" cy="${sy(maxV).toFixed(1)}" r="3" fill="var(--danger, red)"/>`;
|
||||
svg += `<text x="${sx(times[maxIdx]).toFixed(1)}" y="${(sy(maxV) - 6).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--danger, red)">${maxV.toFixed(1)}</text>`;
|
||||
svg += `<circle cx="${sx(times[minIdx]).toFixed(1)}" cy="${sy(minV).toFixed(1)}" r="3" fill="var(--success, green)"/>`;
|
||||
svg += `<text x="${sx(times[minIdx]).toFixed(1)}" y="${(sy(minV) + 14).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--success, green)">${minV.toFixed(1)}</text>`;
|
||||
const ts = new Date(data[i].t).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
|
||||
const tip = `NF: ${v.toFixed(1)} dBm\n${ts}`;
|
||||
|
||||
svg += `<rect class="nf-bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${colW}" height="${Math.max(0, barH).toFixed(1)}" fill="${color}" rx="0.5"><title>${tip}</title></rect>`;
|
||||
}
|
||||
|
||||
// Y-axis label
|
||||
svg += `<text x="12" y="${(h / 2)}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">dBm</text>`;
|
||||
|
||||
// Legend
|
||||
const legendY = pad.top + 2;
|
||||
const legendX = w - pad.right - 140;
|
||||
svg += `<rect x="${legendX}" y="${legendY}" width="8" height="8" fill="var(--success, #22c55e)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 11}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">< -100</text>`;
|
||||
svg += `<rect x="${legendX + 48}" y="${legendY}" width="8" height="8" fill="var(--warning, #eab308)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 59}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">-100…-85</text>`;
|
||||
svg += `<rect x="${legendX + 105}" y="${legendY}" width="8" height="8" fill="var(--danger, #ef4444)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 116}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">≥ -85</text>`;
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -2079,6 +2079,151 @@ console.log('\n=== analytics.js: sortChannels ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== analytics.js: rfNFColumnChart =====
|
||||
console.log('\n=== analytics.js: rfNFColumnChart ===');
|
||||
{
|
||||
function makeAnalyticsSandbox2() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
ctx.registerPage = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.timeAgo = (iso) => iso ? 'x ago' : '—';
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.connectWS = () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.IATA_COORDS_GEO = {};
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) {
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx2 = makeAnalyticsSandbox2();
|
||||
const rfNFColumnChart = ctx2.window._analyticsRfNFColumnChart;
|
||||
|
||||
test('rfNFColumnChart is exposed', () => assert.ok(rfNFColumnChart, '_analyticsRfNFColumnChart must be exposed'));
|
||||
|
||||
test('returns SVG string with column bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:10:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('<svg'), 'should produce SVG');
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'should have column bars');
|
||||
assert.ok(svg.includes('Noise floor column chart'), 'should have aria label');
|
||||
});
|
||||
|
||||
test('color-codes bars by threshold', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 }, // green (< -100)
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 }, // yellow (-100 to -85)
|
||||
{ t: '2024-01-01T00:10:00Z', v: -80 }, // red (>= -85)
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('var(--success'), 'green bar for < -100');
|
||||
assert.ok(svg.includes('var(--warning'), 'yellow bar for -100 to -85');
|
||||
assert.ok(svg.includes('var(--danger'), 'red bar for >= -85');
|
||||
});
|
||||
|
||||
test('includes hover tooltips in bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -105 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('<title>NF: -105.0 dBm'), 'tooltip with dBm value');
|
||||
});
|
||||
|
||||
test('handles empty data gracefully', () => {
|
||||
const svg = rfNFColumnChart([], 700, 180, []);
|
||||
assert.ok(svg.includes('<svg'), 'should return empty SVG');
|
||||
});
|
||||
|
||||
test('handles single data point with visible bar', () => {
|
||||
const data = [{ t: '2024-01-01T00:00:00Z', v: -100 }];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'should render single bar');
|
||||
// Bar must have non-zero height (division-by-zero guard)
|
||||
const m = svg.match(/height="([\d.]+)"/);
|
||||
assert.ok(m && parseFloat(m[1]) > 0, 'single data point bar must have non-zero height');
|
||||
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
|
||||
});
|
||||
|
||||
test('handles constant values with visible bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:10:00Z', v: -95 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
const heights = [...svg.matchAll(/class="nf-bar"[^>]*height="([\d.]+)"/g)].map(m => parseFloat(m[1]));
|
||||
assert.strictEqual(heights.length, 3, 'should render 3 bars');
|
||||
assert.ok(heights.every(h => h > 0), 'all bars must have non-zero height');
|
||||
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
|
||||
});
|
||||
|
||||
test('includes legend', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -90 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('< -100'), 'legend has green label');
|
||||
assert.ok(svg.includes('-100…-85'), 'legend has yellow label');
|
||||
assert.ok(svg.includes('≥ -85'), 'legend has red label');
|
||||
});
|
||||
|
||||
test('no reference lines (removed per spec)', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(!svg.includes('-100 warning'), 'no -100 warning reference line');
|
||||
assert.ok(!svg.includes('-85 critical'), 'no -85 critical reference line');
|
||||
assert.ok(!svg.includes('stroke-dasharray="4,2"'), 'no dashed reference lines');
|
||||
});
|
||||
|
||||
test('renders all bars even with time gaps', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T06:00:00Z', v: -95 }, // 6h gap
|
||||
{ t: '2024-01-01T06:05:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
const barCount = (svg.match(/class="nf-bar"/g) || []).length;
|
||||
assert.strictEqual(barCount, 3, 'all 3 bars rendered despite time gap');
|
||||
});
|
||||
|
||||
test('respects shared time axis', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -100 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
];
|
||||
const minT = new Date('2023-12-31T00:00:00Z').getTime();
|
||||
const maxT = new Date('2024-01-02T00:00:00Z').getTime();
|
||||
const svg = rfNFColumnChart(data, 700, 180, [], minT, maxT);
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'renders with shared time axis');
|
||||
});
|
||||
|
||||
test('renders reboot markers when reboots provided', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -105 },
|
||||
{ t: '2024-01-01T01:00:00Z', v: -95 },
|
||||
];
|
||||
const reboots = [new Date('2024-01-01T00:30:00Z').getTime()];
|
||||
const svg = rfNFColumnChart(data, 700, 180, reboots);
|
||||
assert.ok(svg.includes('reboot'), 'should render reboot marker');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== CUSTOMIZE-V2.JS: core behavior =====
|
||||
console.log('\n=== customize-v2.js: core behavior ===');
|
||||
|
||||
Reference in New Issue
Block a user