From 7402e8d9d9ad98eddf76f326e15ff82c2b139976 Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Wed, 2 Sep 2026 09:11:00 +0200 Subject: [PATCH] feat(analytics): repeater metric scatter tab (#1760) Repeater metric scatter tab. Closes #1763. --------- Co-authored-by: Waydroid Builder --- public/analytics.js | 241 +++++++++++++++++++++++++++++++- test-a11y-axe-1668.js | 2 + test-all.sh | 1 + test-repeater-metric-scatter.js | 157 +++++++++++++++++++++ 4 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 test-repeater-metric-scatter.js diff --git a/public/analytics.js b/public/analytics.js index 0e1a4658..cb853114 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -121,6 +121,7 @@ + @@ -139,7 +140,7 @@ `; // Tabs where the area filter is meaningful (transmitter GPS attribution) - const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'my-repeaters', 'clock-health']); + const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'my-repeaters', 'repeater-metrics', 'clock-health']); function setAreaFilterVisibility(tab) { const el = document.getElementById('analyticsAreaFilter'); @@ -286,6 +287,7 @@ case 'subpaths': await renderSubpaths(el); break; case 'nodes': await renderNodesTab(el); break; case 'my-repeaters': await renderMyRepeatersTab(el); break; + case 'repeater-metrics': await renderRepeaterMetricsTab(el); break; case 'distance': await renderDistanceTab(el); break; case 'neighbor-graph': await renderNeighborGraphTab(el); break; case 'rf-health': await renderRFHealthTab(el); break; @@ -2593,6 +2595,243 @@ } } // === MY REPEATERS BLOCK END (test harness slices the renderer block to here) === + // ===================== REPEATER METRIC SCATTER ===================== + // Each point is a repeater (or room); the axes are two selectable metrics + // that /api/nodes already attaches to repeater/room rows — the two #672 + // usefulness axes that exist today (traffic_share_score, bridge_score) + // plus relay activity (relay_count_1h/24h) and advert_count. This view + // only PLOTS those metrics; nothing is computed client-side. + const REPEATER_METRIC_AXES = [ + { key: 'traffic', label: 'Traffic share', score: true, get: p => p.traffic }, + { key: 'bridge', label: 'Bridge score', score: true, get: p => p.bridge }, + { key: 'relay1h', label: 'Relays (1h)', score: false, get: p => p.relay1h }, + { key: 'relay24h', label: 'Relays (24h)', score: false, get: p => p.relay24h }, + { key: 'adverts', label: 'Adverts', score: false, get: p => p.adverts }, + ]; + + // _niceCeil rounds v up to a "nice" 1/2/5 x 10^n value for an axis ceiling + // so the plot fills its area instead of squashing tiny scores against 0. + function _niceCeil(v) { + if (!(v > 0)) return 1; + const pow = Math.pow(10, Math.floor(Math.log10(v))); + const f = v / pow; + const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; + return nice * pow; + } + + // Scores (0..1) render as percentages; count axes render as integers + // (keyed on axis kind, not on whether a given tick value happens to be + // whole — niceCeil maxima don't always divide into 5 integer steps). + function _axisFmt(axis, v) { + if (v == null) return '—'; + if (!axis.score) return String(Math.round(v)); + // Drop the trailing ".0" on whole-percent gridlines (0%, 20%, 40%); keep a + // decimal only when the value actually needs it. + const pct = v * 100; + return (Number.isInteger(pct) ? pct.toFixed(0) : pct.toFixed(1)) + '%'; + } + + // Build a concrete axis (domain [0, niceCeil(max)]) from a registry entry and + // a precomputed data max — factored out so draw() can resolve both axes in a + // SINGLE pass over the points instead of one full pass per axis. + function _axisFromMax(axis, max) { + return { key: axis.key, label: axis.label, score: axis.score, get: axis.get, min: 0, max: _niceCeil(max) }; + } + + // Resolve a metric key to a concrete axis with a domain computed from the + // points actually being plotted. An unknown key falls back to the first axis — + // the single source of axis-key validation (callers need not pre-check). + function _resolveAxis(axisKey, points) { + const axis = REPEATER_METRIC_AXES.find(a => a.key === axisKey) || REPEATER_METRIC_AXES[0]; + let max = 0; + points.forEach(p => { const v = axis.get(p); if (v != null && v > max) max = v; }); + return _axisFromMax(axis, max); + } + + // Map /api/nodes rows to plottable points. Pure and factored out of + // renderRepeaterMetricsTab so the repeater/room filter and the fallback + // chains (traffic_share_score → usefulness_score → null; name → pubkey + // prefix → '?') are unit-testable (#1760 review). + function _toScatterPoints(nodes, favs) { + return nodes + .filter(n => n.role === 'repeater' || n.role === 'room') + .map(n => ({ + pk: n.public_key, + name: n.name || (n.public_key ? n.public_key.slice(0, 12) : '?'), + role: n.role, + fav: favs.has(n.public_key), + // #1456: prefer traffic_share_score, fall back to usefulness_score. + traffic: n.traffic_share_score != null ? n.traffic_share_score : (n.usefulness_score != null ? n.usefulness_score : null), + bridge: n.bridge_score != null ? n.bridge_score : null, + relay1h: n.relay_count_1h != null ? n.relay_count_1h : null, + relay24h: n.relay_count_24h != null ? n.relay_count_24h : null, + adverts: n.advert_count != null ? n.advert_count : null, + })); + } + + function renderMetricScatter(points, xAxis, yAxis) { + const w = 620, h = 380, pad = 60; + const plotW = w - pad * 2, plotH = h - pad * 2; + const xOf = v => pad + (v - xAxis.min) / (xAxis.max - xAxis.min || 1) * plotW; + const yOf = v => h - pad - (v - yAxis.min) / (yAxis.max - yAxis.min || 1) * plotH; + let svg = `Repeater metric scatter: ${esc(xAxis.label)} (x) vs ${esc(yAxis.label)} (y)`; + // Gridlines + tick labels (5 steps per axis). + for (let i = 0; i <= 5; i++) { + const gx = pad + plotW * i / 5; + const gy = h - pad - plotH * i / 5; + const xv = xAxis.min + (xAxis.max - xAxis.min) * i / 5; + const yv = yAxis.min + (yAxis.max - yAxis.min) * i / 5; + svg += ``; + svg += ``; + svg += `${esc(_axisFmt(xAxis, xv))}`; + svg += `${esc(_axisFmt(yAxis, yv))}`; + } + // Axis lines + titles. + svg += ``; + svg += ``; + svg += `${esc(xAxis.label)}`; + svg += `${esc(yAxis.label)}`; + // Only points with BOTH axis values present are plottable. + const plottable = points.filter(p => xAxis.get(p) != null && yAxis.get(p) != null); + if (!plottable.length) { + svg += `No repeaters have values for both selected metrics.`; + return svg; + } + // Cap plotted points to keep the SVG snappy (~2000 stays smooth in a + // headless browser), but keep ALL favorites unconditionally and stride + // only the non-favorites into the remaining budget — so the legend's + // favorite count can never be a lie (#1760 review). + const POINT_CAP = 2000; + let sample = plottable; + if (plottable.length > POINT_CAP) { + let favs = plottable.filter(p => p.fav); + const others = plottable.filter(p => !p.fav); + // Favorites are kept ahead of non-favorites, but even they are strided + // if they ALONE exceed the cap — so a pathological favorite count can't + // blow past POINT_CAP (the "showing N of M" disclosure stays honest). + if (favs.length > POINT_CAP) { + favs = favs.filter((_, i) => i % Math.ceil(favs.length / POINT_CAP) === 0); + } + const budget = Math.max(0, POINT_CAP - favs.length); + const strided = (budget > 0 && others.length > budget) + ? others.filter((_, i) => i % Math.ceil(others.length / budget) === 0) + : others.slice(0, budget); + sample = favs.concat(strided); + } + // Sampling disclosure (graphical integrity): if we strided the points + // down, say so in-plot rather than silently hiding the rest. + if (sample.length < plottable.length) { + svg += `showing ${sample.length} of ${plottable.length} points`; + } + // Draw favorites last so their rings are never hidden behind other dots. + const ordered = sample.slice().sort((a, b) => (a.fav === b.fav ? 0 : a.fav ? 1 : -1)); + // Favorite ring uses the neutral foreground colour, NOT a status colour: + // statusYellow means "degraded" elsewhere in the dashboard, so reusing it + // here would cross semantic wires. + const favColor = 'var(--text)'; + ordered.forEach(p => { + const cx = xOf(xAxis.get(p)).toFixed(1); + const cy = yOf(yAxis.get(p)).toFixed(1); + const color = (window.ROLE_COLORS && window.ROLE_COLORS[p.role]) || 'var(--text-muted)'; + // ' · ' separators, not '\n': SVG tooltips collapse whitespace, + // so newlines would render as a run-on string. + const tip = `${p.name} · ${xAxis.label}: ${_axisFmt(xAxis, xAxis.get(p))} · ${yAxis.label}: ${_axisFmt(yAxis, yAxis.get(p))}`; + // tabindex="-1": with up to 2000 points we keep them clickable but out + // of the sequential keyboard tab order (the Nodes table is the + // keyboard-navigable surface for per-node drill-down). + svg += `<a href="#/nodes/${encodeURIComponent(p.pk)}/analytics" tabindex="-1"><title>${esc(tip)}`; + if (p.fav) svg += ``; + svg += ``; + }); + svg += ''; + return svg; + } + + async function renderRepeaterMetricsTab(el) { + el.innerHTML = '
Loading repeater metrics…
'; + try { + const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); + const resp = await fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }); + const nodes = resp.nodes || resp; + const favs = new Set(typeof getFavorites === 'function' ? getFavorites() : []); + const points = _toScatterPoints(nodes, favs); + + if (!points.length) { + el.innerHTML = `
+

Repeater Metric Scatter

+
No repeater or room nodes in this view.
+
`; + return; + } + + // Restore the last-picked axes (default Traffic share x Bridge score). A + // stale/unknown stored key is tolerated by _resolveAxis's own fallback + // (single validation path); the ${opts(xKey)} + + + ${points.length} repeater${points.length === 1 ? '' : 's'} + +
+
+ `; + + const plotEl = el.querySelector('#metricScatterPlot'); + const legendEl = el.querySelector('#metricScatterLegend'); + const xSel = el.querySelector('#metricScatterX'); + const ySel = el.querySelector('#metricScatterY'); + + function draw() { + // Both axes share the same point set, so compute their maxima in one + // pass instead of a full pass per _resolveAxis call. + const xA = REPEATER_METRIC_AXES.find(a => a.key === xSel.value) || REPEATER_METRIC_AXES[0]; + const yA = REPEATER_METRIC_AXES.find(a => a.key === ySel.value) || REPEATER_METRIC_AXES[0]; + let xMax = 0, yMax = 0; + points.forEach(p => { + const xv = xA.get(p); if (xv != null && xv > xMax) xMax = xv; + const yv = yA.get(p); if (yv != null && yv > yMax) yMax = yv; + }); + const xAxis = _axisFromMax(xA, xMax), yAxis = _axisFromMax(yA, yMax); + plotEl.innerHTML = renderMetricScatter(points, xAxis, yAxis); + // Legend: each role present, plus the favorite-ring marker. + const roles = Array.from(new Set(points.map(p => p.role))); + let lg = roles.map(r => { + const c = (window.ROLE_COLORS && window.ROLE_COLORS[r]) || 'var(--text-muted)'; + return `${esc(r)}`; + }).join(''); + const favCount = points.filter(p => p.fav).length; + if (favCount) { + lg += `Favorite (${favCount})`; + } + legendEl.innerHTML = lg; + } + + // One wiring body for both axes — a behaviour change can't miss one + // of the two listeners (#1760 review). + const wireAxis = (sel, storageKey) => sel.addEventListener('change', () => { + try { localStorage.setItem(storageKey, sel.value); } catch (e) { /* ignore */ } + draw(); + }); + wireAxis(xSel, 'meshcore-repeater-scatter-x'); + wireAxis(ySel, 'meshcore-repeater-scatter-y'); + draw(); + } catch (e) { + el.innerHTML = `
Failed to load repeater metrics: ${esc(e.message)}
`; + } + } async function renderDistanceTab(el) { try { diff --git a/test-a11y-axe-1668.js b/test-a11y-axe-1668.js index ecb5e083..f260ff5f 100644 --- a/test-a11y-axe-1668.js +++ b/test-a11y-axe-1668.js @@ -79,6 +79,7 @@ const ROUTES = [ '/analytics?tab=scopes', '/analytics?tab=prefix-tool', '/analytics?tab=my-repeaters', + '/analytics?tab=repeater-metrics', '/audio-lab', ]; @@ -94,6 +95,7 @@ const REGISTERED_ANALYTICS_TABS = [ 'overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance', 'neighbor-graph', 'rf-health', 'clock-health', 'roles', 'prefix-tool', 'scopes', 'my-repeaters', + 'repeater-metrics', ]; const THEMES = ['dark', 'light']; diff --git a/test-all.sh b/test-all.sh index 914fcee0..c2e8e56c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -16,6 +16,7 @@ node test-issue-1065-gesture-hints-gates.js node test-frontend-helpers.js node test-fetch-all-nodes-pagination.js node test-my-repeaters-dashboard.js +node test-repeater-metric-scatter.js node test-url-state.js node test-perf-go-runtime.js node test-channel-psk-ux.js diff --git a/test-repeater-metric-scatter.js b/test-repeater-metric-scatter.js new file mode 100644 index 00000000..dd81fc48 --- /dev/null +++ b/test-repeater-metric-scatter.js @@ -0,0 +1,157 @@ +/** + * Repeater Metric Scatter — a new analytics tab plotting each repeater + * (or room) as a point with two selectable usefulness metrics on the axes. + * + * The metrics are NOT computed here: /api/nodes already attaches + * traffic_share_score, bridge_score and relay_count_1h/24h to repeater/room + * rows; advert_count comes from the base node row. This tab only plots them. + * + * Two layers of coverage: + * - structural pins (file-grep) for the tab WIRING that can't run without a + * DOM/app (tab button, dispatch, area-filter registration); + * - BEHAVIORAL tests that actually execute the pure render pipeline + * (REPEATER_METRIC_AXES … renderMetricScatter) against stub globals and + * assert on the produced SVG — real behaviour, not "source contains X". + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const src = fs.readFileSync(path.join(__dirname, 'public', 'analytics.js'), 'utf8'); + +console.log('\n=== tab wiring (structural — not executable without a DOM) ==='); +assert(/data-tab="repeater-metrics"[^>]*>\s*Repeater Metrics\s* s ? String(s).replace(/&/g, '&').replace(//g, '>') : ''; +const statusYellow = () => '#eab308'; +const window = { ROLE_COLORS: { repeater: '#3b82f6', room: '#a855f7' } }; +const M = new Function('esc', 'statusYellow', 'window', + block + '\nreturn { REPEATER_METRIC_AXES, _niceCeil, _axisFmt, _resolveAxis, _toScatterPoints, renderMetricScatter };')(esc, statusYellow, window); + +console.log('\n=== axis math (behavioral) ==='); +assert(M._niceCeil(0) === 1 && M._niceCeil(0.07) === 0.1 && M._niceCeil(37) === 50, + '_niceCeil rounds up to 1/2/5×10ⁿ'); +assert(M._niceCeil(-1) === 1 && M._niceCeil(NaN) === 1 && M._niceCeil(-0.5) === 1, + '_niceCeil guards NaN/negative/zero → 1 (the !(v>0) contract)'); +assert(M._axisFmt({ score: true }, 0.1234) === '12.3%' && M._axisFmt({ score: false }, 5) === '5', + '_axisFmt renders scores as % and counts as integers'); +assert(M._axisFmt({ score: true }, 0.2) === '20%' && M._axisFmt({ score: true }, 0) === '0%', + '_axisFmt drops the trailing .0 on whole-percent gridlines'); +assert(M.REPEATER_METRIC_AXES.map(a => a.key).join(',') === 'traffic,bridge,relay1h,relay24h,adverts', + 'axis registry exposes the five repeater metrics'); +// Unknown/stale stored key (e.g. leftover localStorage) falls back to the +// FIRST axis instead of throwing or returning undefined. +const bogus = M._resolveAxis('bogus', [{ traffic: 0.4 }]); +assert(bogus.key === 'traffic' && bogus.max > 0, + "_resolveAxis('bogus') falls back to the traffic axis with a real domain"); + +console.log('\n=== node→point mapping (behavioral — _toScatterPoints) ==='); +const favSet = new Set(['FAV0000000000000']); +const mapped = M._toScatterPoints([ + { public_key: 'FAV0000000000000', name: 'Fav Rptr', role: 'repeater', traffic_share_score: 0.5, bridge_score: 0.2, relay_count_1h: 1, relay_count_24h: 2, advert_count: 3 }, + { public_key: 'USEFULNESS000000', name: 'Old Server', role: 'room', usefulness_score: 0.07 }, + { public_key: 'NOSCORES00000000', name: 'Bare', role: 'repeater' }, + { public_key: 'NAMELESS00000000ABCDEF', role: 'repeater' }, + { public_key: 'COMPANION0000000', name: 'Phone', role: 'companion', traffic_share_score: 0.9 }, + { role: 'repeater' }, +], favSet); +assert(mapped.length === 5 && !mapped.some(p => p.role === 'companion'), + 'non-repeater/room roles are filtered out'); +assert(mapped[0].traffic === 0.5 && mapped[0].fav === true, + 'traffic_share_score is preferred and favorites are flagged'); +assert(mapped[0].bridge === 0.2 && mapped[0].relay1h === 1 && mapped[0].relay24h === 2 && mapped[0].adverts === 3, + 'bridge/relay/advert counts map onto their renamed point fields (advert_count → adverts)'); +assert(mapped[1].traffic === 0.07 && mapped[1].fav === false, + 'missing traffic_share_score falls back to usefulness_score'); +assert(mapped[2].traffic === null && mapped[2].bridge === null && mapped[2].relay1h === null, + 'rows without scores map to null (not 0/undefined) so plots can skip them'); +assert(mapped[3].name === 'NAMELESS0000', + 'nameless node falls back to a 12-char pubkey prefix'); +assert(mapped[4].name === '?' && mapped[4].pk === undefined, + 'node with neither name nor pubkey falls back to "?"'); + +console.log('\n=== scatter render (behavioral — executes renderMetricScatter) ==='); +const pts = [ + { pk: 'AA', name: 'Rptr Eins', role: 'repeater', fav: true, traffic: 0.42, bridge: 0.1, relay1h: 12, relay24h: 200, adverts: 50 }, + { pk: 'BB', name: 'Raum ', role: 'room', fav: false, traffic: 0.05, bridge: 0.8, relay1h: 0, relay24h: 3, adverts: 5 }, + { pk: 'CC', name: 'Rptr Drei', role: 'repeater', fav: false, traffic: null, bridge: 0.3, relay1h: 4, relay24h: 40, adverts: 9 }, +]; +const xa = M._resolveAxis('traffic', pts), ya = M._resolveAxis('bridge', pts); +const svg = M.renderMetricScatter(pts, xa, ya); +assert(svg.startsWith('') && !/NaN/.test(svg), + 'produces a valid with no NaN coordinates'); +assert((svg.match(/href="#\/nodes\//g) || []).length === 2, + 'plots only points with BOTH axis values (CC has null traffic → skipped)'); +assert(/href="#\/nodes\/AA\/analytics"/.test(svg), + 'each point links to its per-node analytics'); +assert(/fill="#3b82f6"/.test(svg) && /fill="#a855f7"/.test(svg), + 'point fill follows node role colour'); +assert(/Raum <Zwei>/.test(svg), + 'point names are HTML-escaped in the tooltip'); + +console.log('\n=== round-1 fixes (regression guards) ==='); +// Favorite ring uses the neutral foreground colour, never statusYellow. +assert(/stroke="var\(--text\)"/.test(svg) && !/stroke="#eab308"/.test(svg), + 'favorite ring uses var(--text), not statusYellow()'); +// Tooltip uses ' · ' separators, not newlines (SVG collapses ws). +assert(/Rptr Eins · /.test(svg) && !/Rptr Eins\n/.test(svg), + "tooltip uses ' · ' separators, not '\\n'"); +// Points are out of the keyboard tab order. +assert(/tabindex="-1"/.test(svg), 'points carry tabindex="-1" (keyboard tab order)'); +// Unknown role falls back to var(--text-muted), not a hardcoded hex. +const unk = M.renderMetricScatter([{ pk: 'Z', name: 'z', role: 'mystery', fav: false, traffic: 0.5, bridge: 0.5 }], M._resolveAxis('traffic', pts), M._resolveAxis('bridge', pts)); +assert(/fill="var\(--text-muted\)"/.test(unk) && !/#6b7280/.test(unk), + 'unknown-role point falls back to var(--text-muted)'); + +console.log('\n=== round-2 fixes (behavioral regression guards) ==='); +// MAJOR: favorites are kept UNCONDITIONALLY through sampling; cap ~2000. +const many = []; +for (let i = 0; i < 2500; i++) many.push({ pk: 'p' + i, name: 'n' + i, role: 'repeater', fav: (i % 500 === 0), traffic: i / 2500, bridge: (i % 7) / 7 }); +const favCount = many.filter(p => p.fav).length; +const bigSvg = M.renderMetricScatter(many, M._resolveAxis('traffic', many), M._resolveAxis('bridge', many)); +assert(/showing \d+ of 2500 points/.test(bigSvg), + 'sampling >2000 points is disclosed in-plot ("showing N of 2500 points")'); +const plotted = (bigSvg.match(/href="#\/nodes\//g) || []).length; +assert(plotted <= 2000 + favCount && plotted > 1000, 'plotted point count respects the ~2000 cap'); +// every favorite pubkey must still appear (the legend must not lie) +const allFavsShown = many.filter(p => p.fav).every(p => bigSvg.includes('#/nodes/' + p.pk + '/analytics')); +assert(allFavsShown, 'ALL favorites survive sampling (legend count stays truthful)'); +// Round-3 nit: even an all-favorites set beyond the cap must be strided so the +// cap can't be bypassed. +const allFav = []; +for (let i = 0; i < 2500; i++) allFav.push({ pk: 'q' + i, name: 'n' + i, role: 'repeater', fav: true, traffic: i / 2500, bridge: 0.5 }); +const allFavSvg = M.renderMetricScatter(allFav, M._resolveAxis('traffic', allFav), M._resolveAxis('bridge', allFav)); +const allFavPlotted = (allFavSvg.match(/href="#\/nodes\//g) || []).length; +assert(allFavPlotted <= 2000 && allFavPlotted > 1000, + '2500 favorites are themselves strided to respect the cap (no bypass)'); +assert(!/showing \d+ of \d+ points/.test(svg), + 'no sampling disclosure for a small point set'); +// NIT: empty-axis selection shows a message, not a blank plot. +const empties = [{ pk: 'E', name: 'e', role: 'repeater', fav: false, traffic: null, bridge: null }]; +const emptySvg = M.renderMetricScatter(empties, M._resolveAxis('traffic', empties), M._resolveAxis('bridge', empties)); +assert(/No repeaters have values/.test(emptySvg), + 'empty-axis selection renders an explicit "no values" message'); + +console.log('\n────────────────────────────────────────'); +console.log(` ${passed} passed, ${failed} failed`); +if (failed) process.exit(1);