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 @@ +
+ Important Links + + +
Last Heard @@ -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);