mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-13 02:05:33 +00:00
feat(analytics): repeater metric scatter tab (#1760)
Repeater metric scatter tab. Closes #1763. --------- Co-authored-by: Waydroid Builder <build@waydroid.local>
This commit is contained in:
co-authored by
Waydroid Builder
parent
f49e3fcc26
commit
7402e8d9d9
+240
-1
@@ -121,6 +121,7 @@
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="my-repeaters">My Repeaters</button>
|
||||
<button class="tab-btn" data-tab="repeater-metrics">Repeater Metrics</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="rf-health">RF Health</button>
|
||||
@@ -139,7 +140,7 @@
|
||||
</div>`;
|
||||
|
||||
// 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 = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:420px" role="img" aria-label="Scatter plot of repeaters: ${esc(xAxis.label)} versus ${esc(yAxis.label)}"><title>Repeater metric scatter: ${esc(xAxis.label)} (x) vs ${esc(yAxis.label)} (y)</title>`;
|
||||
// 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 += `<line x1="${gx}" y1="${pad}" x2="${gx}" y2="${h-pad}" stroke="var(--border)" stroke-dasharray="2" opacity="0.5"/>`;
|
||||
svg += `<line x1="${pad}" y1="${gy}" x2="${w-pad}" y2="${gy}" stroke="var(--border)" stroke-dasharray="2" opacity="0.5"/>`;
|
||||
svg += `<text x="${gx}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc(_axisFmt(xAxis, xv))}</text>`;
|
||||
svg += `<text x="${pad-6}" y="${gy+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${esc(_axisFmt(yAxis, yv))}</text>`;
|
||||
}
|
||||
// Axis lines + titles.
|
||||
svg += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.75"/>`;
|
||||
svg += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.75"/>`;
|
||||
svg += `<text x="${pad + plotW/2}" y="${h-6}" text-anchor="middle" font-size="11" fill="var(--text-muted)">${esc(xAxis.label)}</text>`;
|
||||
svg += `<text x="14" y="${pad + plotH/2}" text-anchor="middle" font-size="11" fill="var(--text-muted)" transform="rotate(-90,14,${pad + plotH/2})">${esc(yAxis.label)}</text>`;
|
||||
// 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 += `<text x="${pad + plotW / 2}" y="${pad + plotH / 2}" text-anchor="middle" font-size="12" fill="var(--text-muted)">No repeaters have values for both selected metrics.</text></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 += `<text x="${w - pad}" y="${pad - 6}" text-anchor="end" font-size="9" fill="var(--text-muted)">showing ${sample.length} of ${plottable.length} points</text>`;
|
||||
}
|
||||
// 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 <title> 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)}</title>`;
|
||||
if (p.fav) svg += `<circle cx="${cx}" cy="${cy}" r="5.5" fill="none" stroke="${favColor}" stroke-width="1.75"/>`;
|
||||
svg += `<circle cx="${cx}" cy="${cy}" r="3" fill="${color}" opacity="0.8"/></a>`;
|
||||
});
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
async function renderRepeaterMetricsTab(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading repeater metrics…</div>';
|
||||
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 = `<div class="analytics-section">
|
||||
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> Repeater Metric Scatter</h3>
|
||||
<div class="text-muted" style="padding:24px">No repeater or room nodes in this view.</div>
|
||||
</div>`;
|
||||
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 <select> then just shows its first option.
|
||||
let xKey = localStorage.getItem('meshcore-repeater-scatter-x') || 'traffic';
|
||||
let yKey = localStorage.getItem('meshcore-repeater-scatter-y') || 'bridge';
|
||||
const opts = sel => REPEATER_METRIC_AXES.map(a => `<option value="${a.key}"${a.key === sel ? ' selected' : ''}>${esc(a.label)}</option>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-section">
|
||||
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> Repeater Metric Scatter</h3>
|
||||
<p class="text-muted">Each point is a repeater or room. Pick two metrics to compare — colors follow node role, favorites are ringed. Click a point for its analytics.</p>
|
||||
<p class="text-muted" style="font-size:12px;margin-top:-6px">Units — <strong>Traffic share</strong>: % of non-advert traffic relayed. <strong>Bridge score</strong>: 0–100% (network-normalized betweenness). <strong>Relays (1h/24h)</strong> and <strong>Adverts</strong>: packet counts. Axes auto-scale to the data.</p>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
|
||||
<label style="display:flex;gap:6px;align-items:center">X axis
|
||||
<select id="metricScatterX" class="analytics-time-window-select" aria-label="X axis metric">${opts(xKey)}</select>
|
||||
</label>
|
||||
<label style="display:flex;gap:6px;align-items:center">Y axis
|
||||
<select id="metricScatterY" class="analytics-time-window-select" aria-label="Y axis metric">${opts(yKey)}</select>
|
||||
</label>
|
||||
<span class="text-muted">${points.length} repeater${points.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div id="metricScatterPlot"></div>
|
||||
<div id="metricScatterLegend" style="display:flex;gap:16px;flex-wrap:wrap;margin-top:10px;font-size:12px"></div>
|
||||
</div>`;
|
||||
|
||||
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 `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="12" height="12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="${c}"/></svg>${esc(r)}</span>`;
|
||||
}).join('');
|
||||
const favCount = points.filter(p => p.fav).length;
|
||||
if (favCount) {
|
||||
lg += `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="5" fill="none" stroke="var(--text)" stroke-width="1.75"/></svg>Favorite (${favCount})</span>`;
|
||||
}
|
||||
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 = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load repeater metrics: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDistanceTab(el) {
|
||||
try {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*</.test(src),
|
||||
'tab bar has a "Repeater Metrics" button');
|
||||
assert(/case 'repeater-metrics':\s*await renderRepeaterMetricsTab\(el\)/.test(src),
|
||||
'renderTab dispatches repeater-metrics');
|
||||
assert(/AREA_FILTER_TABS[\s\S]{0,200}'repeater-metrics'/.test(src),
|
||||
'repeater-metrics participates in region/area filtering');
|
||||
|
||||
// --- Extract and execute the pure render pipeline with stub globals. ---
|
||||
const start = src.indexOf('const REPEATER_METRIC_AXES');
|
||||
const end = src.indexOf('async function renderRepeaterMetricsTab');
|
||||
if (start < 0 || end < 0) {
|
||||
console.error(' ✗ could not locate the REPEATER_METRIC_AXES … renderMetricScatter block');
|
||||
process.exit(1);
|
||||
}
|
||||
const block = src.slice(start, end);
|
||||
const esc = s => s ? String(s).replace(/&/g, '&').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 <Zwei>', 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('<svg') && svg.endsWith('</svg>') && !/NaN/.test(svg),
|
||||
'produces a valid <svg> 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 <title> 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);
|
||||
Reference in New Issue
Block a user