mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 16:52:42 +00:00
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:
1 parent
27628fbc3a
commit
5063919daf
2 files changed
+169
-4
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user