From e0e9aaa32486fa6aeabd103d843e049517c11aef Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 7 Apr 2026 21:40:14 -0700 Subject: [PATCH] feat: noise floor column chart with color-coded thresholds (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` — 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> --- public/analytics.js | 83 +++++++++++++--------- test-frontend-helpers.js | 145 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 31 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index a8b8f9ec..3b65a695 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -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`; + const times = data.map(d => new Date(d.t).getTime()); + + let svg = `Noise floor over time`; + + // Inline style for hover highlighting + svg += ``; // Chart title svg += `Noise Floor dBm`; - // 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 += ``; - svg += `${refLabels[i]}`; - } - }); - - // 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 += `${v.toFixed(0)}`; svg += ``; @@ -3246,24 +3252,39 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ // X-axis labels svg += rfXAxisLabels(data, sx, h, pad); - // Data polyline - svg += ``; + // 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 += ``; - svg += `${maxV.toFixed(1)}`; - svg += ``; - svg += `${minV.toFixed(1)}`; + const ts = new Date(data[i].t).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC'); + const tip = `NF: ${v.toFixed(1)} dBm\n${ts}`; + + svg += `${tip}`; + } // Y-axis label svg += `dBm`; + // Legend + const legendY = pad.top + 2; + const legendX = w - pad.right - 140; + svg += ``; + svg += `< -100`; + svg += ``; + svg += `-100…-85`; + svg += ``; + svg += `≥ -85`; + svg += ''; return svg; } diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 04b13307..6e8e1a8c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -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(' { + 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('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 ===');