From 5063919dafb1dcf72989489bb7f07c8f882c3d40 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 26 Jul 2026 19:42:14 +0200 Subject: [PATCH] feat: richer SNR history view -- axis labels, click-to-expand modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dborup: "det ville være cool wih more history... jeg vil bare gerne se mere" -- not more retention, more detail in the existing view. - neighborSnrSparkline now labels min/max dB directly on the sparkline, readable without hovering. - Clicking a sparkline (any row with data) opens a modal with a bigger Chart.js line chart showing both SNR and heard_secs_ago on dual y-axes, with native hover tooltips. Reuses the existing generic .modal-overlay/ .modal CSS (previously only exercised by the channel-add modal). - Metrics are cached per-pubkey when the sparkline loads, so opening the modal needs no second fetch. - Click delegation is wired once at module load (not per-render), since renderDetail rewrites #obsDetailContent's innerHTML on every load. --- public/observer-detail.js | 79 +++++++++++++++++++-- test-observer-direct-neighbors-panel.js | 94 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/public/observer-detail.js b/public/observer-detail.js index 4bd93a82..f8108c9a 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -233,10 +233,18 @@ window.ObserverDetailNaiveBanner = { const y = h - 2 - ((v - min) / range) * (h - 4); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); - return 'SNR trend'; + // Min/max labels directly on the sparkline -- dborup asked to see more + // than just the bare line without having to hover. + const labels = '' + max.toFixed(1) + '' + + '' + min.toFixed(1) + ''; + return 'SNR trend' + labels + ''; } window.neighborSnrSparkline = neighborSnrSparkline; + // Cached by pubkey when the sparkline loads, so clicking to open the + // expanded chart (openNeighborSnrModal) doesn't need a second fetch. + const _neighborMetricsCache = {}; + // Fetched per-row, after the Direct Neighbors table is in the DOM -- // mirrors the RF Health grid's loadRFSparkline pattern (async, non-fatal // on failure, since the sparkline is a nice-to-have, not core content). @@ -248,12 +256,15 @@ window.ObserverDetailNaiveBanner = { if (!container) continue; try { const data = await api('/observers/' + encodeURIComponent(observerId) + '/neighbors/' + encodeURIComponent(n.pubkey) + '/metrics'); - const values = (data.metrics || []).map(function(m) { return m.snr; }).filter(function(v) { return v != null; }); + const metrics = data.metrics || []; + _neighborMetricsCache[n.pubkey] = { metrics: metrics, label: n.name || n.pubkey }; + const values = metrics.map(function(m) { return m.snr; }).filter(function(v) { return v != null; }); + const clickable = values.length > 0 ? ' class="nb-spark-clickable" data-nb-pubkey="' + escapeHtml(n.pubkey) + '" style="cursor:pointer" title="Click for a larger chart"' : ''; if (values.length > 1) { const latest = values[values.length - 1]; - container.outerHTML = neighborSnrSparkline(values, 80, 20) + ' ' + latest.toFixed(1) + ' dB'; + container.outerHTML = '' + neighborSnrSparkline(values, 80, 20) + ' ' + latest.toFixed(1) + ' dB'; } else if (values.length === 1) { - container.outerHTML = '' + values[0].toFixed(1) + ' dB'; + container.outerHTML = '' + values[0].toFixed(1) + ' dB'; } else { container.outerHTML = 'no data'; } @@ -264,6 +275,66 @@ window.ObserverDetailNaiveBanner = { } window.loadNeighborSnrSparklines = loadNeighborSnrSparklines; + // Click-to-expand: a bigger Chart.js chart with both SNR and + // heard_secs_ago (dual y-axis) and native hover tooltips -- dborup asked + // for more than the bare sparkline gives. Delegated on the panel + // container (set up once in renderDetail) so it survives re-renders. + let _neighborSnrModalChart = null; + function openNeighborSnrModal(pubkey) { + const cached = _neighborMetricsCache[pubkey]; + if (!cached) return; + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = '' + + ''; + document.body.appendChild(overlay); + function close() { + if (_neighborSnrModalChart) { _neighborSnrModalChart.destroy(); _neighborSnrModalChart = null; } + overlay.remove(); + } + overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); }); + overlay.querySelector('.modal-close').addEventListener('click', close); + function onKeydown(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKeydown); } + } + document.addEventListener('keydown', onKeydown); + + const canvas = overlay.querySelector('#nbSnrModalChart'); + _neighborSnrModalChart = new Chart(canvas, { + type: 'line', + data: { + labels: cached.metrics.map(function(m) { return new Date(m.timestamp).toLocaleString(); }), + datasets: [ + { label: 'SNR (dB)', data: cached.metrics.map(function(m) { return m.snr; }), borderColor: 'var(--accent)', yAxisID: 'y', tension: 0.2 }, + { label: 'Heard (s ago)', data: cached.metrics.map(function(m) { return m.heardSecsAgo; }), borderColor: 'var(--status-yellow)', yAxisID: 'y1', tension: 0.2 }, + ], + }, + options: { + scales: { + y: { position: 'left', title: { display: true, text: 'SNR (dB)' } }, + y1: { position: 'right', title: { display: true, text: 'Heard (s ago)' }, grid: { drawOnChartArea: false } }, + }, + plugins: { tooltip: { mode: 'index', intersect: false } }, + }, + }); + return overlay; + } + window.openNeighborSnrModal = openNeighborSnrModal; + + // Delegated once at module load (not per-render) -- renderDetail rewrites + // #obsDetailContent's innerHTML on every load, which would silently drop + // a listener bound to the table itself. + document.addEventListener('click', function(e) { + const el = e.target.closest('[data-nb-pubkey]'); + if (el) openNeighborSnrModal(el.getAttribute('data-nb-pubkey')); + }); + function renderDetail(obs, analytics, obsSkew, neighborsData) { const el = document.getElementById('obsDetailContent'); if (!el) return; diff --git a/test-observer-direct-neighbors-panel.js b/test-observer-direct-neighbors-panel.js index dbaaba96..30575444 100644 --- a/test-observer-direct-neighbors-panel.js +++ b/test-observer-direct-neighbors-panel.js @@ -176,6 +176,14 @@ test('renders an SVG polyline for 2+ points', () => { assert.ok(html.includes(' { + // dborup: "jeg vil bare gerne se mere" -- min/max readable without hover. + const html = ctx.window.neighborSnrSparkline([1, 5, 3], 80, 20); + assert.ok(html.includes(' labels'); + assert.ok(html.includes('>5.0<'), 'expected the max value labeled'); + assert.ok(html.includes('>1.0<'), 'expected the min value labeled'); +}); + console.log('\n=== #1865 follow-up — SNR sparkline async loader ==='); test('loads and injects a sparkline + latest value on success', async () => { @@ -224,6 +232,92 @@ test('a failed fetch degrades to a neutral dash, not a thrown error', async () = assert.ok(el.outerHTML.length > 0, 'expected a fallback rendered, not left blank/thrown'); }); +test('a sparkline with data is marked clickable (data-nb-pubkey), for the expand-to-modal feature', async () => { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [{ timestamp: 't1', snr: 5 }, { timestamp: 't2', snr: 8 }] }), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(el.outerHTML.includes('data-nb-pubkey="abc123"')); +}); + +test('the "no data" state is not marked clickable (nothing to expand)', async () => { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [] }), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(!el.outerHTML.includes('data-nb-pubkey')); +}); + +console.log('\n=== #1865 follow-up — SNR history expand-to-modal ==='); + +// Minimal fake DOM sufficient for openNeighborSnrModal: createElement / +// body.appendChild / querySelector / addEventListener, nothing more. +function fakeModalDom() { + function makeEl() { + const listeners = {}; + const el = { + _html: '', + set innerHTML(v) { this._html = v; }, + get innerHTML() { return this._html; }, + setAttribute() {}, + className: '', + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, + removeEventListener() {}, + _fire(type, evt) { (listeners[type] || []).forEach((fn) => fn(evt || {})); }, + querySelector() { return makeEl(); }, + remove() {}, + }; + return el; + } + const body = { appendChild() {} }; + return { createElement: () => makeEl(), body, addEventListener() {}, removeEventListener() {} }; +} + +test('clicking a cached sparkline opens a modal with a dual-axis SNR/heard-secs-ago chart', async () => { + let lastChartConfig = null; + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [ + { timestamp: '2026-07-26T10:00:00Z', snr: 5, heardSecsAgo: 60 }, + { timestamp: '2026-07-26T11:00:00Z', snr: 8, heardSecsAgo: 90 }, + ] }), + { 'nb-spark-abc123': mockElement() } + ); + apiCtx.document = fakeModalDom(); + apiCtx.Chart = function(canvas, config) { lastChartConfig = config; return { destroy() {} }; }; + vm.createContext(apiCtx); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + + // Populate the metrics cache the way loadNeighborSnrSparklines would -- + // getElementById isn't wired to the fake modal DOM here, so call the + // loader against the richer document directly to exercise the real path. + apiCtx.document.getElementById = () => mockElement(); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123', name: 'Repeater A' }] }); + + apiCtx.window.openNeighborSnrModal('abc123'); + assert.ok(lastChartConfig, 'expected Chart to be constructed'); + assert.strictEqual(lastChartConfig.data.datasets.length, 2, 'expected an SNR dataset and a heard_secs_ago dataset'); + assert.strictEqual(lastChartConfig.data.datasets[0].yAxisID, 'y'); + assert.strictEqual(lastChartConfig.data.datasets[1].yAxisID, 'y1'); + assert.ok(lastChartConfig.options.scales.y1.title.text.toLowerCase().includes('heard'), 'expected the second axis labeled for heard_secs_ago'); +}); + +test('openNeighborSnrModal is a no-op for a pubkey with no cached data', () => { + let chartCalled = false; + const apiCtx = makeLoaderCtx(() => Promise.resolve({ metrics: [] }), {}); + apiCtx.document = fakeModalDom(); + apiCtx.Chart = function() { chartCalled = true; return { destroy() {} }; }; + vm.createContext(apiCtx); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + apiCtx.window.openNeighborSnrModal('never-fetched-pubkey'); + assert.ok(!chartCalled, 'must not construct a chart with no data to show'); +}); + Promise.all(pending).then(() => { console.log(`\n${passed} passed, ${failed} failed\n`); if (failed > 0) process.exit(1);