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 = ``;
+ 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 += `${esc(tip)}`;
+ if (p.fav) svg += ``;
+ svg += ``;
+ });
+ svg += '';
+ return svg;
+ }
+
+ async function renderRepeaterMetricsTab(el) {
+ el.innerHTML = '