feat(map): Important Links overlay (rebase of #1771 onto master) (#1928)

Continues #1771 by @ArcanConsulting. Both commits are theirs, authorship
unchanged; I only rebased them onto current master. Opening it here
rather than force-pushing to someone else's branch.

## Why the rebase was needed

#1771 went CONFLICTING through no fault of its author: #1760 landed
first and both PRs append a line to `test-all.sh` at the same spot. That
was the entire conflict.

## What I changed

One line, and it is the conflict resolution: `test-all.sh` now runs
**both** test files rather than either.

```
node test-repeater-metric-scatter.js   # from #1760
node test-top-routes-overlay.js        # from this PR
```

Nothing else was touched. `public/map.js` and
`test-issue-1329-map-controls-accordion-e2e.js` are byte-for-byte as the
author wrote them.

## Verification on the rebased tree

| | result |
|---|---|
| `test-top-routes-overlay.js` (this PR's own) | 20 passed, 0 failed |
| `test-repeater-metric-scatter.js` (#1760's, must still pass) | 31
passed, 0 failed |
| `test-frontend-helpers.js` | 627 passed, 0 failed |

## The one review point that still stands

From my review on #1771, unchanged by the rebase and not something I
fixed on the author's behalf: `test-top-routes-overlay.js` extracts the
ranking core by `indexOf`-slicing `public/map.js` between the literals
`const TOP_ROUTES_AXES` and `function clearTopRoutes`, then `new
Function`s the result. There is a guard assertion for the rename case,
which is thoughtful, but it still breaks on any reordering of map.js and
it tests a string rather than the module.

Two PRs in this same queue do it properly and are worth copying: #1821
exports `applyObserverFilter` through `_packetsTestAPI`, and #1912 puts
`hashPrefixInfo` on `window`.

Happy to take that as a follow-up rather than block the overlay on it.

@ArcanConsulting — this is your work and the credit is yours. Say the
word and I will close this and hand the rebase back, or push it to your
branch instead if you would rather #1771 stayed the vehicle.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE

---------

Co-authored-by: Arcan Consulting - Michael J. Arcan <github@arcan-it.de>
This commit is contained in:
efiten
2026-09-02 11:07:22 +02:00
committed by GitHub
co-authored by Arcan Consulting - Michael J. Arcan
parent e8f32df4dc
commit 0fd22039cb
4 changed files with 279 additions and 2 deletions
+173 -1
View File
@@ -17,6 +17,10 @@
let geoFilterLayer = null;
let affinityLayer = null;
let affinityData = null;
let topRoutesLayer = null;
let topRoutesEdges = null; // cached neighbor-graph edges for the Important Links overlay
let topRoutesRenderTimer = null; // debounce for the Top-N slider
let topRoutesAccentCache = ''; // cached --accent, invalidated on theme-refresh
let userHasMoved = false;
let controlsCollapsed = false;
@@ -222,6 +226,23 @@
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
<label id="mcAffinityDebugLabel" for="mcAffinityDebug" style="display:none"><input type="checkbox" id="mcAffinityDebug"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Affinity Debug</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Important Links</legend>
<label for="mcTopRoutes"><input type="checkbox" id="mcTopRoutes"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> Show important links</label>
<div id="mcTopRoutesOpts" style="display:none;padding-left:20px;margin-top:4px">
<label for="mcTopRoutesRankBy" style="font-size:11px;color:var(--text-muted)">Rank by</label>
<select id="mcTopRoutesRankBy" style="width:100%;margin:2px 0 6px">
<option value="usefulness">Usefulness (composite)</option>
<option value="bridge">Bridge</option>
<option value="redundancy">Redundancy</option>
<option value="traffic">Traffic share</option>
<option value="affinity">Affinity only</option>
</select>
<label for="mcTopRoutesN" style="font-size:11px;color:var(--text-muted)">Top <span id="mcTopRoutesNVal">50</span> links</label>
<input type="range" id="mcTopRoutesN" min="10" max="200" step="10" value="50" style="width:100%">
<div id="mcTopRoutesHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:4px">No links to show — endpoints may lack GPS, or the chosen axis has no scores yet (the #672 scores need a server that ships them).</div>
</div>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Last Heard</legend>
<label for="mcLastHeard" class="sr-only">Filter by last heard time</label>
@@ -507,6 +528,38 @@
});
})();
// Important Links overlay (#672 / D) — public, B-weighted top routes.
(function initTopRoutes() {
const cb = document.getElementById('mcTopRoutes');
if (!cb) return;
const opts = document.getElementById('mcTopRoutesOpts');
const rankBy = document.getElementById('mcTopRoutesRankBy');
const nSlider = document.getElementById('mcTopRoutesN');
const nVal = document.getElementById('mcTopRoutesNVal');
const savedAxis = localStorage.getItem('meshcore-top-routes-axis');
if (savedAxis && rankBy) rankBy.value = savedAxis;
const savedN = localStorage.getItem('meshcore-top-routes-n');
if (savedN && nSlider) { nSlider.value = savedN; if (nVal) nVal.textContent = savedN; }
cb.addEventListener('change', e => {
if (opts) opts.style.display = e.target.checked ? '' : 'none';
if (e.target.checked) loadTopRoutes(); else clearTopRoutes();
});
if (rankBy) rankBy.addEventListener('change', e => {
localStorage.setItem('meshcore-top-routes-axis', e.target.value);
if (cb.checked) renderTopRoutes();
});
if (nSlider) nSlider.addEventListener('input', e => {
if (nVal) nVal.textContent = e.target.value;
localStorage.setItem('meshcore-top-routes-n', e.target.value);
// Debounce: dragging the slider fires 'input' rapidly; redraw at most
// ~every 80ms so a large top-N doesn't lag the map.
if (cb.checked) {
clearTimeout(topRoutesRenderTimer);
topRoutesRenderTimer = setTimeout(renderTopRoutes, 80);
}
});
})();
// Hash Labels toggle
const hashLabelEl = document.getElementById('mcHashLabels');
if (hashLabelEl) {
@@ -1328,6 +1381,11 @@
renderMarkers();
// Keep the Important Links overlay in sync on a full node reload: re-fetch
// the neighbor-graph edges (not just re-render the stale cache) so the
// overlay tracks the current graph + scores.
if (topRoutesEdges && document.getElementById('mcTopRoutes')?.checked) loadTopRoutes();
// Restore heatmap if previously enabled
if (localStorage.getItem('meshcore-map-heatmap') === 'true') {
toggleHeatmap(true);
@@ -2104,9 +2162,123 @@
}
// ─── End Affinity Debug ────────────────────────────────────────────────────
// ─── Important Links (B-weighted top-routes overlay, issue #672 / D) ─────────
// A public, user-facing overlay (NOT the API-key-gated Affinity Debug above)
// that draws the most IMPORTANT affinity links on the map, weighted by the
// #672 repeater-usefulness axes. It joins the loaded `nodes` array (coords +
// per-node usefulness/bridge/redundancy/traffic scores from /api/nodes) with
// the public neighbor-graph edges, ranks by a chosen axis, and draws the
// top-N as weighted polylines so terrain-level chokepoints (the sole link
// across a valley) stand out geographically.
const TOP_ROUTES_AXES = {
usefulness: 'usefulness_score',
bridge: 'bridge_score',
redundancy: 'redundancy_score',
traffic: 'traffic_share_score',
};
// computeTopRouteEdges is the pure ranking core (no DOM/Leaflet): join edges
// with node coords + the chosen axis score, compute per-edge importance, and
// return the top-N drawable edges (both endpoints geo-located). Importance =
// edge affinity × the mean of the two endpoints' axis score; for axis
// 'affinity' it is the raw edge affinity. Edges with a missing endpoint coord,
// or zero importance (e.g. a non-repeater endpoint with no axis score), are
// dropped. Exported shape kept simple for behavioral testing.
function computeTopRouteEdges(edges, nodeList, axis, topN) {
const pos = {}, score = {};
const scoreField = TOP_ROUTES_AXES[axis]; // undefined for 'affinity'
(nodeList || []).forEach(n => {
if (!n || !n.public_key) return;
const k = n.public_key.toLowerCase();
if (n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)) pos[k] = [n.lat, n.lon];
if (scoreField) score[k] = (n[scoreField] != null ? n[scoreField] : 0);
});
const scored = [];
(edges || []).forEach(e => {
const a = (e.source || '').toLowerCase();
const b = (e.target || '').toLowerCase();
const pa = pos[a], pb = pos[b];
if (!pa || !pb) return; // need both endpoints on the map
const edgeStrength = e.score != null ? e.score : 0;
const importance = scoreField
? edgeStrength * (((score[a] || 0) + (score[b] || 0)) / 2)
: edgeStrength;
if (!(importance > 0)) return;
scored.push({ a, b, pa, pb, importance, edge: e });
});
scored.sort((x, y) => y.importance - x.importance);
return scored.slice(0, Math.max(0, Math.floor(Number(topN) || 0)));
}
function clearTopRoutes() {
if (topRoutesLayer) { map.removeLayer(topRoutesLayer); topRoutesLayer = null; }
}
async function loadTopRoutes() {
try {
const data = await api('/analytics/neighbor-graph?min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF });
topRoutesEdges = (data && data.edges) || [];
renderTopRoutes();
} catch (err) {
console.warn('[top-routes] failed to load neighbor graph:', err);
const cb = document.getElementById('mcTopRoutes');
if (cb) cb.checked = false;
}
}
// topRoutesAccent caches --accent (read once, invalidated on theme-refresh)
// so the slider re-render loop doesn't hit getComputedStyle every frame.
function topRoutesAccent() {
if (!topRoutesAccentCache) {
topRoutesAccentCache = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#4a9eff';
}
return topRoutesAccentCache;
}
function renderTopRoutes() {
if (!map || !topRoutesEdges) return;
clearTopRoutes();
const axis = (document.getElementById('mcTopRoutesRankBy') || {}).value || 'usefulness';
const topN = parseInt((document.getElementById('mcTopRoutesN') || {}).value, 10) || 50;
const top = computeTopRouteEdges(topRoutesEdges, nodes, axis, topN);
// Empty-state hint: the toggle is on but nothing rendered (no geo-located
// endpoints, or the chosen axis has no scores yet).
const hint = document.getElementById('mcTopRoutesHint');
if (hint) hint.style.display = top.length ? 'none' : '';
topRoutesLayer = L.layerGroup();
if (top.length) {
const maxImp = top[0].importance || 1;
const nameByPk = {};
nodes.forEach(n => { if (n && n.public_key) nameByPk[n.public_key.toLowerCase()] = n.name || n.public_key.slice(0, 8); });
const accent = topRoutesAccent();
top.forEach(t => {
const rel = maxImp > 0 ? t.importance / maxImp : 0;
const line = L.polyline([t.pa, t.pb], {
color: accent,
weight: 1 + rel * 6, // 17px ∝ importance
opacity: 0.25 + rel * 0.55 // 0.250.8 ∝ importance
});
const e = t.edge;
line.bindPopup('<b>Important link</b><br>' +
escapeHtml(nameByPk[t.a] || t.a.slice(0, 8)) + ' ↔ ' + escapeHtml(nameByPk[t.b] || t.b.slice(0, 8)) + '<br>' +
'Importance (' + escapeHtml(axis) + '): ' + t.importance.toFixed(3) + '<br>' +
'Affinity: ' + (e.score != null ? e.score.toFixed(3) : '—') +
(e.avg_snr != null ? '<br>Avg SNR: ' + e.avg_snr.toFixed(1) + ' dB' : ''));
topRoutesLayer.addLayer(line);
});
}
topRoutesLayer.addTo(map);
}
// ─── End Important Links ─────────────────────────────────────────────────────
registerPage('map', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
_themeRefreshHandler = () => {
if (markerLayer) renderMarkers();
// Re-read --accent on theme change; redraw the overlay if it's on.
topRoutesAccentCache = '';
if (topRoutesEdges && document.getElementById('mcTopRoutes')?.checked) renderTopRoutes();
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
+1
View File
@@ -17,6 +17,7 @@ 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-top-routes-overlay.js
node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
+10 -1
View File
@@ -143,7 +143,16 @@ async function run() {
const cs = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
// Check that section content (e.g., labels) is visible on desktop.
const allInputs = panel.querySelectorAll('input[type=checkbox], select, button');
// Exclude controls inside a collapsed progressive-disclosure container
// (inline style="display:none" — the geo-filter and affinity-debug
// toggles, and #1771's "Important Links" rank-by select, which a
// checkbox reveals on demand). Those are hidden by an explicit toggle,
// NOT by the mobile accordion this desktop check guards against: the
// accordion collapses via the `.mc-collapsed > *:not(legend){display:none}`
// CSS rule (mobile media query, class-based, no inline style), so a
// desktop accordion regression is still counted here and caught.
const allInputs = Array.from(panel.querySelectorAll('input[type=checkbox], select, button'))
.filter(el => !el.closest('[style*="display:none"], [style*="display: none"]'));
let visible = 0;
allInputs.forEach(el => {
const r = el.getBoundingClientRect();
+95
View File
@@ -0,0 +1,95 @@
/**
* "Important Links" map overlay (issue #672 / D) a public, B-weighted
* top-routes layer in public/map.js. It joins the loaded nodes (coords +
* the #672 usefulness/bridge/redundancy/traffic scores from /api/nodes) with
* the public neighbor-graph edges, ranks edges by a chosen axis, and draws the
* top-N weighted polylines so geographic chokepoints stand out.
*
* Two layers of coverage:
* - structural pins (file-grep) for the DOM wiring that needs Leaflet/DOM
* (toggle, rank-by select, slider, load/clear/render handlers);
* - BEHAVIORAL tests that execute the pure ranking core computeTopRouteEdges
* against fixtures and assert on importance, ordering, top-N and skips.
*/
'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', 'map.js'), 'utf8');
console.log('\n=== overlay wiring (structural — needs Leaflet/DOM) ===');
assert(/id="mcTopRoutes"[^>]*>\s*<svg/.test(src) || /<input type="checkbox" id="mcTopRoutes">/.test(src),
'controls template has the #mcTopRoutes toggle');
['usefulness', 'bridge', 'redundancy', 'traffic', 'affinity'].forEach(ax => {
assert(new RegExp('value="' + ax + '"').test(src), 'rank-by select offers "' + ax + '"');
});
assert(/id="mcTopRoutesN"/.test(src) && /type="range"/.test(src), 'top-N slider present');
assert(/function loadTopRoutes[\s\S]{0,400}\/analytics\/neighbor-graph/.test(src),
'loadTopRoutes fetches the public neighbor-graph endpoint');
assert(/checked\) loadTopRoutes\(\); else clearTopRoutes\(\)/.test(src),
'toggle wires loadTopRoutes/clearTopRoutes');
assert(/renderTopRoutes\(\)/.test(src) && /topRoutesLayer = L\.layerGroup\(\)/.test(src),
'renderTopRoutes builds a dedicated layer group');
// --- extract & execute the pure ranking core ---
const start = src.indexOf('const TOP_ROUTES_AXES');
const end = src.indexOf('function clearTopRoutes');
if (start < 0 || end < 0) { console.error(' ✗ could not locate the ranking core'); process.exit(1); }
const block = src.slice(start, end);
// Guard the indexOf-based slice: fail loudly (not silently) if the function is
// renamed out of the extracted block.
assert(block.includes('function computeTopRouteEdges'), 'extracted block contains computeTopRouteEdges');
const M = new Function(block + '\nreturn { TOP_ROUTES_AXES, computeTopRouteEdges };')();
console.log('\n=== ranking core (behavioral) ===');
const nodes = [
{ public_key: 'AA', lat: 50.0, lon: 7.0, usefulness_score: 0.9, bridge_score: 0.1 },
{ public_key: 'BB', lat: 50.1, lon: 7.1, usefulness_score: 0.8, bridge_score: 0.9 },
{ public_key: 'CC', lat: 50.2, lon: 7.2, usefulness_score: 0.1, bridge_score: 0.1 },
{ public_key: 'DD', lat: null, lon: null, usefulness_score: 0.9 }, // no GPS
{ public_key: 'FF', lat: 50.4, lon: 7.4, usefulness_score: 0 }, // zero score
{ public_key: 'GG', lat: 50.5, lon: 7.5, usefulness_score: 0 }, // zero score
];
const edges = [
{ source: 'AA', target: 'BB', score: 0.5 },
{ source: 'AA', target: 'CC', score: 0.8 },
{ source: 'BB', target: 'CC', score: 0.3 },
{ source: 'AA', target: 'DD', score: 0.9 }, // DD has no GPS → skipped
{ source: 'FF', target: 'GG', score: 0.6 }, // both zero usefulness → skipped on usefulness axis
];
const u = M.computeTopRouteEdges(edges, nodes, 'usefulness', 50);
const key = t => t.a + '-' + t.b;
assert(key(u[0]) === 'aa-bb' && Math.abs(u[0].importance - 0.425) < 1e-9,
'usefulness: top link is AA↔BB, importance = edge.score × mean(endpoint scores)');
assert(u.length === 3, 'usefulness: GPS-less (AA-DD) and zero-score (FF-GG) edges dropped → 3 remain');
assert(!u.some(t => t.a === 'aa' && t.b === 'dd'), 'edge with a GPS-less endpoint is skipped');
assert(!u.some(t => t.a === 'ff' || t.b === 'gg'), 'zero-importance edge skipped on a score axis');
assert(u.every((t, i) => i === 0 || u[i - 1].importance >= t.importance), 'edges sorted by importance desc');
console.log('\n=== axis choice changes the ranking ===');
const b = M.computeTopRouteEdges(edges, nodes, 'bridge', 50);
// usefulness order: AA-BB, AA-CC, BB-CC. bridge order: AA-BB, BB-CC, AA-CC (swap).
assert(key(b[1]) === 'bb-cc' && key(u[1]) === 'aa-cc',
'switching axis (usefulness→bridge) reorders the 2nd-ranked link');
console.log('\n=== affinity-only axis + top-N ===');
const aff = M.computeTopRouteEdges(edges, nodes, 'affinity', 50);
assert(key(aff[0]) === 'aa-cc' && Math.abs(aff[0].importance - 0.8) < 1e-9,
'affinity axis: importance is the raw edge affinity (top = AA↔CC at 0.8)');
assert(aff.some(t => t.a === 'ff' && t.b === 'gg'),
'zero-score nodes still link on the affinity axis (no endpoint weighting)');
const capped = M.computeTopRouteEdges(edges, nodes, 'usefulness', 2);
assert(capped.length === 2 && key(capped[0]) === 'aa-bb',
'top-N caps the result to N highest-importance links');
console.log('\n────────────────────────────────────────');
console.log(` ${passed} passed, ${failed} failed`);
if (failed) process.exit(1);