Files
meshcore-analyzer/public/node-reach-map.js
T
efiten e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

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


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00

95 lines
3.9 KiB
JavaScript

/* window.NodeReachMap.render(containerId, node, tiers) — focused Leaflet map of
a node and its links, coloured by bottleneck tier. Returns a controller:
{ map, setLinks(links), bounds, destroy() }
The map + tiles + node pin + legend are built once; setLinks() redraws ONLY
the link layer in place (no teardown/flicker) when the table filter changes.
`tiers` is [{min, label, varName}] ordered strong→weak (from node-reach.js,
the single source of the thresholds). */
(function () {
'use strict';
function cssVar(name) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || '#888';
}
function tierFor(tiers, bottleneck) {
for (var i = 0; i < tiers.length; i++) {
if (bottleneck >= tiers[i].min) return tiers[i];
}
return tiers[tiers.length - 1];
}
function legendControl(tiers, colors) {
var ctl = L.control({ position: 'bottomright' });
ctl.onAdd = function () {
var div = L.DomUtil.create('div', 'nq-legend');
var rows = tiers.map(function (t) {
return '<div><span class="nq-sw" style="background:' + colors[t.varName] + '"></span>' + escapeHtml(t.legend) + '</div>';
}).join('');
div.innerHTML = '<div><strong>Bottleneck</strong> (weaker direction)</div>' + rows;
return div;
};
return ctl;
}
function render(containerId, node, tiers) {
var c = document.getElementById(containerId);
if (!c || typeof L === 'undefined') return null;
// Resolve the tier colours + marker outline ONCE (not per polyline/marker).
var colors = {};
tiers.forEach(function (t) { colors[t.varName] = cssVar(t.varName); });
var outline = cssVar('--surface-0'); // themed marker stroke (was hardcoded #fff)
var accent = cssVar('--accent');
var map = L.map(containerId, { zoomControl: true, attributionControl: false })
.setView([node.lat, node.lon], 11);
if (typeof window._applyTilesToNodeMap === 'function') {
window._applyTilesToNodeMap(map);
} else {
// Loud, not silent: the tile-preference helper is missing.
console.warn('NodeReachMap: _applyTilesToNodeMap unavailable — using OSM fallback');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
}
// Center node: a circleMarker like the neighbours (one glyph family) — just
// larger + accent-filled — rather than the heavy default droplet icon.
L.circleMarker([node.lat, node.lon], { radius: 8, color: outline, weight: 2, fillColor: accent, fillOpacity: 1 })
.addTo(map).bindPopup(escapeHtml(node.name));
legendControl(tiers, colors).addTo(map);
var linkLayer = L.layerGroup().addTo(map);
var bounds = [[node.lat, node.lon]];
function setLinks(links) {
linkLayer.clearLayers();
bounds = [[node.lat, node.lon]];
links.forEach(function (l) {
if (l.lat == null || l.lon == null) return;
bounds.push([l.lat, l.lon]);
var col = colors[tierFor(tiers, l.bottleneck).varName];
// Constant weight — colour alone encodes bottleneck (no double-encoding).
L.polyline([[node.lat, node.lon], [l.lat, l.lon]], { color: col, weight: 2.5, opacity: 0.85 })
.addTo(linkLayer)
.bindPopup(escapeHtml(l.name) + '<br>we ' + l.we_hear + ' / they ' + l.they_hear);
L.circleMarker([l.lat, l.lon], { radius: 5, color: outline, weight: 1, fillColor: col, fillOpacity: 1 })
.addTo(linkLayer).bindTooltip(escapeHtml(l.name));
});
try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) {}
map._nqBounds = bounds;
}
setTimeout(function () { map.invalidateSize(); }, 120);
return {
map: map,
setLinks: setLinks,
get bounds() { return bounds; },
destroy: function () { try { map.remove(); } catch (e) {} }
};
}
window.NodeReachMap = { render: render };
})();