mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-14 02:05:38 +00:00
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:
co-authored by
Arcan Consulting - Michael J. Arcan
parent
e8f32df4dc
commit
0fd22039cb
+173
-1
@@ -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, // 1–7px ∝ importance
|
||||
opacity: 0.25 + rel * 0.55 // 0.25–0.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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user