diff --git a/public/map.js b/public/map.js
index 4a1d06f2..b43fe1ca 100644
--- a/public/map.js
+++ b/public/map.js
@@ -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 @@
Click a node marker to set the reference node
Affinity Debug
+
+ Important Links
+ Show important links
+
+
Rank by
+
+ Usefulness (composite)
+ Bridge
+ Redundancy
+ Traffic share
+ Affinity only
+
+
Top 50 links
+
+
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).
+
+
Last Heard
Filter by last heard time
@@ -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, // 1–7px ∝ importance
+ opacity: 0.25 + rel * 0.55 // 0.25–0.8 ∝ importance
+ });
+ const e = t.edge;
+ line.bindPopup('Important link ' +
+ escapeHtml(nameByPk[t.a] || t.a.slice(0, 8)) + ' ↔ ' + escapeHtml(nameByPk[t.b] || t.b.slice(0, 8)) + ' ' +
+ 'Importance (' + escapeHtml(axis) + '): ' + t.importance.toFixed(3) + ' ' +
+ 'Affinity: ' + (e.score != null ? e.score.toFixed(3) : '—') +
+ (e.avg_snr != null ? ' 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);
},
diff --git a/test-all.sh b/test-all.sh
index 86371b37..898ed20f 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -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
diff --git a/test-issue-1329-map-controls-accordion-e2e.js b/test-issue-1329-map-controls-accordion-e2e.js
index b5aa91a2..e3acb384 100644
--- a/test-issue-1329-map-controls-accordion-e2e.js
+++ b/test-issue-1329-map-controls-accordion-e2e.js
@@ -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();
diff --git a/test-top-routes-overlay.js b/test-top-routes-overlay.js
new file mode 100644
index 00000000..76d48878
--- /dev/null
+++ b/test-top-routes-overlay.js
@@ -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*/.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);