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 '';
+ // 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 '';
}
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 = ''
+ + '
'
+ + ''
+ + '
' + escapeHtml(cached.label) + ' — SNR History
'
+ + ''
+ + '';
+ 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);