feat: richer SNR history view -- axis labels, click-to-expand modal

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.
This commit is contained in:
dborup committed 2026-07-26 19:42:14 +02:00
1 parent 27628fbc3a
commit 5063919daf
2 files changed
+169 -4

No files matched your search

+75 -4
View File
@@ -233,10 +233,18 @@ window.ObserverDetailNaiveBanner = {
const y = h - 2 - ((v - min) / range) * (h - 4);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
return '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:' + w + 'px;height:' + h + 'px" role="img" aria-label="SNR trend"><title>SNR trend</title><polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>';
// Min/max labels directly on the sparkline -- dborup asked to see more
// than just the bare line without having to hover.
const labels = '<text x="' + w + '" y="8" text-anchor="end" font-size="7" fill="var(--text-muted)">' + max.toFixed(1) + '</text>'
+ '<text x="' + w + '" y="' + (h - 1) + '" text-anchor="end" font-size="7" fill="var(--text-muted)">' + min.toFixed(1) + '</text>';
return '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:' + w + 'px;height:' + h + 'px" role="img" aria-label="SNR trend, ' + min.toFixed(1) + ' to ' + max.toFixed(1) + ' dB"><title>SNR trend</title><polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="1.5"/>' + labels + '</svg>';
}
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) + ' <span class="text-muted" style="font-size:10px">' + latest.toFixed(1) + ' dB</span>';
container.outerHTML = '<span' + clickable + '>' + neighborSnrSparkline(values, 80, 20) + ' <span class="text-muted" style="font-size:10px">' + latest.toFixed(1) + ' dB</span></span>';
} else if (values.length === 1) {
container.outerHTML = '<span class="text-muted" style="font-size:10px">' + values[0].toFixed(1) + ' dB</span>';
container.outerHTML = '<span' + clickable + '><span class="text-muted" style="font-size:10px">' + values[0].toFixed(1) + ' dB</span></span>';
} else {
container.outerHTML = '<span class="text-muted" style="font-size:10px">no data</span>';
}
@@ -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 = ''
+ '<div class="modal" style="max-width:min(90vw,680px)">'
+ '<button type="button" class="modal-close" aria-label="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>'
+ '<h3>' + escapeHtml(cached.label) + ' — SNR History</h3>'
+ '<canvas id="nbSnrModalChart" role="img" aria-label="SNR and heard-seconds-ago history"></canvas>'
+ '</div>';
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;
+94
View File
@@ -176,6 +176,14 @@ test('renders an SVG polyline for 2+ points', () => {
assert.ok(html.includes('<polyline'));
});
test('labels the min/max dB values directly on the sparkline', () => {
// 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('<text'), 'expected min/max <text> 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);