mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 04:25:04 +00:00
e86b5a3a0c
## Summary Show 2-byte hash support indicator on map markers. Fixes #903. ## What changed ### Backend (`cmd/server/store.go`, `cmd/server/routes.go`) - **`EnrichNodeWithMultiByte()`** — new enrichment function that adds `multi_byte_status` (confirmed/suspected/unknown), `multi_byte_evidence` (advert/path), and `multi_byte_max_hash_size` fields to node API responses - **`GetMultiByteCapMap()`** — cached (15s TTL) map of pubkey → `MultiByteCapEntry`, reusing the existing `computeMultiByteCapability()` logic that combines advert-based and path-hop-based evidence - Wired into both `/api/nodes` (list) and `/api/nodes/{pubkey}` (detail) endpoints ### Frontend (`public/map.js`) - Added **"Multi-byte support"** checkbox in the map Display controls section - When toggled on, repeater markers change color: - 🟢 Green (`#27ae60`) — **confirmed** (advertised with hash_size ≥ 2) - 🟡 Yellow (`#f39c12`) — **suspected** (seen as hop in multi-byte path) - 🔴 Red (`#e74c3c`) — **unknown** (no multi-byte evidence) - Popup tooltip shows multi-byte status and evidence for repeaters - State persisted in localStorage (`meshcore-map-multibyte-overlay`) ## TDD - Red commit: `2f49cbc` — failing test for `EnrichNodeWithMultiByte` - Green commit: `4957782` — implementation + passing tests ## Performance - `GetMultiByteCapMap()` uses a 15s TTL cache (same pattern as `GetNodeHashSizeInfo`) - Enrichment is O(n) over nodes, no per-item API calls - Frontend color override is computed inline during existing marker render loop — no additional DOM rebuilds --------- Co-authored-by: you <you@example.com>
1320 lines
60 KiB
JavaScript
1320 lines
60 KiB
JavaScript
/* === CoreScope — map.js === */
|
||
'use strict';
|
||
|
||
(function () {
|
||
let map = null;
|
||
let routeLayer = null;
|
||
let markerLayer = null;
|
||
let clusterGroup = null;
|
||
let nodes = [];
|
||
let targetNodeKey = null;
|
||
let observers = [];
|
||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true' };
|
||
let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering
|
||
let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node
|
||
let wsHandler = null;
|
||
let heatLayer = null;
|
||
let geoFilterLayer = null;
|
||
let affinityLayer = null;
|
||
let affinityData = null;
|
||
let userHasMoved = false;
|
||
let controlsCollapsed = false;
|
||
|
||
// Safe escape — falls back to identity if app.js hasn't loaded yet
|
||
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
|
||
|
||
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
|
||
|
||
// Multi-byte support overlay colors
|
||
var MB_COLORS = { confirmed: '#27ae60', suspected: '#f39c12', unknown: '#e74c3c' };
|
||
|
||
function makeMarkerIcon(role, isStale, isAlsoObserver, colorOverride) {
|
||
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
|
||
const fillColor = colorOverride || s.color;
|
||
const size = s.radius * 2 + 4;
|
||
const c = size / 2;
|
||
let path;
|
||
switch (s.shape) {
|
||
case 'diamond':
|
||
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||
break;
|
||
case 'square':
|
||
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||
break;
|
||
case 'triangle':
|
||
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||
break;
|
||
case 'star': {
|
||
// 5-pointed star
|
||
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||
let pts = '';
|
||
for (let i = 0; i < 5; i++) {
|
||
const aOuter = (i * 72 - 90) * Math.PI / 180;
|
||
const aInner = ((i * 72) + 36 - 90) * Math.PI / 180;
|
||
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
|
||
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
|
||
}
|
||
path = `<polygon points="${pts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1.5"/>`;
|
||
break;
|
||
}
|
||
default: // circle
|
||
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||
}
|
||
// If this node is also an observer, add a small star overlay
|
||
let obsOverlay = '';
|
||
if (isAlsoObserver) {
|
||
const starSize = 8;
|
||
const sx = size - starSize, sy = 0;
|
||
const scx = starSize / 2, scy = starSize / 2, so = starSize / 2 - 0.5, si = so * 0.4;
|
||
let starPts = '';
|
||
for (let i = 0; i < 5; i++) {
|
||
const aO = (i * 72 - 90) * Math.PI / 180;
|
||
const aI = ((i * 72) + 36 - 90) * Math.PI / 180;
|
||
starPts += `${scx + so * Math.cos(aO)},${scy + so * Math.sin(aO)} `;
|
||
starPts += `${scx + si * Math.cos(aI)},${scy + si * Math.sin(aI)} `;
|
||
}
|
||
obsOverlay = `<g transform="translate(${sx},${sy})"><polygon points="${starPts.trim()}" fill="${ROLE_COLORS.observer || '#f1c40f'}" stroke="#fff" stroke-width="0.8"/></g>`;
|
||
}
|
||
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">${path}${obsOverlay}</svg>`;
|
||
return L.divIcon({
|
||
html: svg,
|
||
className: 'meshcore-marker' + (isStale ? ' marker-stale' : ''),
|
||
iconSize: [size, size],
|
||
iconAnchor: [c, c],
|
||
popupAnchor: [0, -c],
|
||
});
|
||
}
|
||
|
||
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, colorOverride) {
|
||
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
|
||
var hs = node.hash_size || 1;
|
||
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
|
||
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
|
||
var bgColor = colorOverride || s.color;
|
||
// If this repeater is also an observer, show a star indicator inside the label
|
||
var obsIndicator = isAlsoObserver ? ' <span style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>' : '';
|
||
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
|
||
shortHash + obsIndicator + '</div>';
|
||
return L.divIcon({
|
||
html: html,
|
||
className: 'meshcore-marker meshcore-label-marker' + (isStale ? ' marker-stale' : ''),
|
||
iconSize: null,
|
||
iconAnchor: [14, 12],
|
||
popupAnchor: [0, -12],
|
||
});
|
||
}
|
||
|
||
async function init(container) {
|
||
container.innerHTML = `
|
||
<div id="map-wrap" style="position:relative;width:100%;height:100%;display:flex;">
|
||
<div id="leaflet-map" style="flex:1 1 0%;height:100%;"></div>
|
||
<div class="map-side-pane" id="mapSidePane">
|
||
<div class="pane-toggle" id="mapPaneToggle" title="Path Inspector">◀</div>
|
||
<div class="pane-content">
|
||
<h3 style="margin:0 0 8px 0;font-size:14px;">Path Inspector</h3>
|
||
<p style="font-size:11px;color:var(--text-muted);margin:0 0 8px 0;">Hex prefixes (1-3 bytes), comma or space separated.</p>
|
||
<div style="display:flex;gap:4px;margin-bottom:8px;">
|
||
<input type="text" id="mapPiInput" class="input" placeholder="2C,A1,F4" style="flex:1;">
|
||
<button id="mapPiSubmit" class="btn btn-primary btn-sm">Go</button>
|
||
</div>
|
||
<div id="mapPiError" class="path-inspector-error"></div>
|
||
<div id="mapPiResults"></div>
|
||
</div>
|
||
</div>
|
||
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true">⚙️</button>
|
||
<div class="map-controls" id="mapControls" role="region" aria-label="Map controls">
|
||
<h3>🗺️ Map Controls</h3>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Node Types</legend>
|
||
<div id="mcRoleChecks"></div>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Byte Size</legend>
|
||
<div class="filter-group" id="mcByteFilter">
|
||
<button class="btn ${filters.byteSize==='all'?'active':''}" data-byte="all">All</button>
|
||
<button class="btn ${filters.byteSize==='1'?'active':''}" data-byte="1">1-byte</button>
|
||
<button class="btn ${filters.byteSize==='2'?'active':''}" data-byte="2">2-byte</button>
|
||
<button class="btn ${filters.byteSize==='3'?'active':''}" data-byte="3">3-byte</button>
|
||
</div>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Display</legend>
|
||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
|
||
<label for="mcMultiByte"><input type="checkbox" id="mcMultiByte"> Multi-byte support</label>
|
||
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Status</legend>
|
||
<div class="filter-group" id="mcStatusFilter">
|
||
<button class="btn ${filters.statusFilter==='all'?'active':''}" data-status="all">All</button>
|
||
<button class="btn ${filters.statusFilter==='active'?'active':''}" data-status="active">Active</button>
|
||
<button class="btn ${filters.statusFilter==='stale'?'active':''}" data-status="stale">Stale</button>
|
||
</div>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Filters</legend>
|
||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName">—</span></div>
|
||
<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"> 🔍 Affinity Debug</label>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Last Heard</legend>
|
||
<label for="mcLastHeard" class="sr-only">Filter by last heard time</label>
|
||
<select id="mcLastHeard" aria-label="Filter by last heard time">
|
||
<option value="1h">1 hour</option>
|
||
<option value="6h">6 hours</option>
|
||
<option value="24h">24 hours</option>
|
||
<option value="7d">7 days</option>
|
||
<option value="30d" selected>30 days</option>
|
||
</select>
|
||
</fieldset>
|
||
<fieldset class="mc-section">
|
||
<legend class="mc-label">Quick Jump</legend>
|
||
<div class="mc-jumps" id="mcJumps" role="group" aria-label="Jump to region"></div>
|
||
</fieldset>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Init Leaflet — restore saved position or use configurable defaults (#115)
|
||
let defaultCenter = [37.6, -122.1];
|
||
let defaultZoom = 9;
|
||
try {
|
||
const mapCfg = await (await fetch('/api/config/map')).json();
|
||
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) defaultCenter = mapCfg.center;
|
||
if (typeof mapCfg.zoom === 'number') defaultZoom = mapCfg.zoom;
|
||
} catch {}
|
||
let initCenter = defaultCenter;
|
||
let initZoom = defaultZoom;
|
||
// Check URL query params first (from packet detail links)
|
||
const urlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||
if (urlParams.get('lat') && urlParams.get('lon')) {
|
||
initCenter = [parseFloat(urlParams.get('lat')), parseFloat(urlParams.get('lon'))];
|
||
initZoom = parseInt(urlParams.get('zoom')) || 12;
|
||
} else {
|
||
const savedView = localStorage.getItem('map-view');
|
||
if (savedView) {
|
||
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
|
||
}
|
||
}
|
||
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
|
||
|
||
// If navigated with ?node=PUBKEY, highlight that node after markers load
|
||
targetNodeKey = urlParams.get('node') || null;
|
||
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
|
||
attribution: '© OpenStreetMap © CartoDB',
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
const _mapThemeObs = new MutationObserver(function () {
|
||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||
});
|
||
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||
|
||
// Save position on move
|
||
map.on('moveend', () => {
|
||
const c = map.getCenter();
|
||
localStorage.setItem('map-view', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
|
||
userHasMoved = true;
|
||
});
|
||
|
||
map.on('zoomend', () => {
|
||
clearTimeout(_zoomResizeTimer);
|
||
_zoomResizeTimer = setTimeout(() => {
|
||
if (!_renderingMarkers) _repositionMarkers();
|
||
}, 150);
|
||
});
|
||
|
||
map.on('resize', () => {
|
||
clearTimeout(_zoomResizeTimer);
|
||
_zoomResizeTimer = setTimeout(() => {
|
||
if (!_renderingMarkers) _repositionMarkers();
|
||
}, 150);
|
||
});
|
||
|
||
markerLayer = L.layerGroup().addTo(map);
|
||
routeLayer = L.layerGroup().addTo(map);
|
||
|
||
// Fix map size on SPA load
|
||
setTimeout(() => map.invalidateSize(), 100);
|
||
|
||
// Controls toggle
|
||
const toggleBtn = document.getElementById('mapControlsToggle');
|
||
const controlsPanel = document.getElementById('mapControls');
|
||
// Default collapsed on mobile
|
||
if (window.innerWidth <= 640) {
|
||
controlsCollapsed = true;
|
||
controlsPanel.classList.add('collapsed');
|
||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
toggleBtn.addEventListener('click', () => {
|
||
controlsCollapsed = !controlsCollapsed;
|
||
controlsPanel.classList.toggle('collapsed', controlsCollapsed);
|
||
toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed));
|
||
});
|
||
|
||
// Bind controls
|
||
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
|
||
const heatEl = document.getElementById('mcHeatmap');
|
||
if (localStorage.getItem('meshcore-map-heatmap') === 'true') { heatEl.checked = true; }
|
||
heatEl.addEventListener('change', e => { localStorage.setItem('meshcore-map-heatmap', e.target.checked); toggleHeatmap(e.target.checked); });
|
||
document.getElementById('mcNeighbors').addEventListener('change', e => {
|
||
filters.neighbors = e.target.checked;
|
||
const hintEl = document.getElementById('mcNeighborHint');
|
||
const refEl = document.getElementById('mcNeighborRef');
|
||
if (e.target.checked && !selectedReferenceNode) {
|
||
hintEl.style.display = 'block';
|
||
refEl.style.display = 'none';
|
||
} else {
|
||
hintEl.style.display = 'none';
|
||
refEl.style.display = selectedReferenceNode ? 'block' : 'none';
|
||
}
|
||
renderMarkers();
|
||
});
|
||
|
||
// Affinity Debug overlay toggle — shown only when debugAffinity config is on or localStorage override
|
||
(function initAffinityDebug() {
|
||
var label = document.getElementById('mcAffinityDebugLabel');
|
||
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
|
||
if (show && label) label.style.display = '';
|
||
var cb = document.getElementById('mcAffinityDebug');
|
||
if (!cb) return;
|
||
cb.addEventListener('change', function (e) {
|
||
if (e.target.checked) {
|
||
loadAffinityDebugOverlay();
|
||
} else {
|
||
clearAffinityOverlay();
|
||
}
|
||
});
|
||
})();
|
||
|
||
// Hash Labels toggle
|
||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||
if (hashLabelEl) {
|
||
hashLabelEl.checked = filters.hashLabels;
|
||
hashLabelEl.addEventListener('change', e => { filters.hashLabels = e.target.checked; localStorage.setItem('meshcore-map-hash-labels', filters.hashLabels); renderMarkers(); });
|
||
}
|
||
const multiByteEl = document.getElementById('mcMultiByte');
|
||
if (multiByteEl) {
|
||
multiByteEl.checked = filters.multiByteOverlay;
|
||
multiByteEl.addEventListener('change', e => { filters.multiByteOverlay = e.target.checked; localStorage.setItem('meshcore-map-multibyte-overlay', e.target.checked); renderMarkers(); });
|
||
}
|
||
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
|
||
|
||
// Status filter buttons
|
||
document.querySelectorAll('#mcStatusFilter .btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
filters.statusFilter = btn.dataset.status;
|
||
localStorage.setItem('meshcore-map-status-filter', filters.statusFilter);
|
||
document.querySelectorAll('#mcStatusFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.status === filters.statusFilter));
|
||
renderMarkers();
|
||
});
|
||
});
|
||
|
||
// Byte size filter buttons
|
||
document.querySelectorAll('#mcByteFilter .btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
filters.byteSize = btn.dataset.byte;
|
||
localStorage.setItem('meshcore-map-byte-filter', filters.byteSize);
|
||
document.querySelectorAll('#mcByteFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.byte === filters.byteSize));
|
||
renderMarkers();
|
||
});
|
||
});
|
||
|
||
// Geo filter overlay
|
||
(async function () {
|
||
try {
|
||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
|
||
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
|
||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||
const innerPoly = L.polygon(latlngs, {
|
||
color: geoColor, weight: 2, opacity: 0.8,
|
||
fillColor: geoColor, fillOpacity: 0.08
|
||
});
|
||
// Approximate buffer zone — expand each vertex outward from centroid by bufferKm
|
||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||
let cLat = 0, cLon = 0;
|
||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||
const outer = gf.polygon.map(function (p) {
|
||
const dLatM = (p[0] - cLat) * 111000;
|
||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||
if (dist === 0) return [p[0], p[1]];
|
||
const scale = (gf.bufferKm * 1000) / dist;
|
||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||
});
|
||
return L.polygon(outer, {
|
||
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||
fillColor: geoColor, fillOpacity: 0.04
|
||
});
|
||
})() : null;
|
||
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||
const label = document.getElementById('mcGeoFilterLabel');
|
||
if (label) label.style.display = '';
|
||
const el = document.getElementById('mcGeoFilter');
|
||
if (el) {
|
||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
|
||
el.addEventListener('change', function (e) {
|
||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
|
||
});
|
||
}
|
||
} catch (e) { /* no geo filter configured */ }
|
||
})();
|
||
|
||
// WS for live advert updates
|
||
wsHandler = debouncedOnWS(function (msgs) {
|
||
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
|
||
loadNodes();
|
||
}
|
||
});
|
||
|
||
loadNodes().then(() => {
|
||
// Check for route from packet detail (via sessionStorage)
|
||
const routeHopsJson = sessionStorage.getItem('map-route-hops');
|
||
if (routeHopsJson) {
|
||
sessionStorage.removeItem('map-route-hops');
|
||
try {
|
||
const parsed = JSON.parse(routeHopsJson);
|
||
// Support new format {origin, hops} and legacy plain array
|
||
if (Array.isArray(parsed)) {
|
||
drawPacketRoute(parsed, null);
|
||
} else {
|
||
drawPacketRoute(parsed.hops || [], parsed.origin || null);
|
||
}
|
||
} catch {}
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawPacketRoute(hopKeys, origin) {
|
||
// Defensive: origin must be an object with pubkey/lat/lon/name. A bare
|
||
// string slips through both branches at lines below and silently no-ops
|
||
// the originator marker (caused PR #950's bug). Coerce string → object
|
||
// and warn so callers get a clear signal.
|
||
if (typeof origin === 'string') {
|
||
console.warn('drawPacketRoute: origin should be an object {pubkey,lat,lon,name}, got string. Coercing.');
|
||
origin = { pubkey: origin };
|
||
}
|
||
// Hide default markers so only the route is visible
|
||
if (markerLayer) map.removeLayer(markerLayer);
|
||
if (clusterGroup) map.removeLayer(clusterGroup);
|
||
if (heatLayer) map.removeLayer(heatLayer);
|
||
|
||
routeLayer.clearLayers();
|
||
|
||
// Add close route button
|
||
const closeBtn = L.control({ position: 'topright' });
|
||
closeBtn.onAdd = function () {
|
||
const div = L.DomUtil.create('div', 'leaflet-bar');
|
||
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px">✕</a>';
|
||
L.DomEvent.on(div, 'click', function (e) {
|
||
L.DomEvent.preventDefault(e);
|
||
routeLayer.clearLayers();
|
||
if (markerLayer) map.addLayer(markerLayer);
|
||
if (clusterGroup) map.addLayer(clusterGroup);
|
||
map.removeControl(closeBtn);
|
||
});
|
||
return div;
|
||
};
|
||
closeBtn.addTo(map);
|
||
|
||
// Resolve hop short hashes to node positions with geographic disambiguation
|
||
const raw = hopKeys.map(hop => {
|
||
const hopLower = hop.toLowerCase();
|
||
const candidates = nodes.filter(n => {
|
||
const pk = n.public_key.toLowerCase();
|
||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
|
||
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
|
||
});
|
||
if (candidates.length === 1) {
|
||
const c = candidates[0];
|
||
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
|
||
} else if (candidates.length > 1) {
|
||
return { name: hop.slice(0,8), resolved: false, candidates };
|
||
}
|
||
return null;
|
||
});
|
||
|
||
// Disambiguate: pick candidate closest to center of already-resolved hops
|
||
const knownPos = raw.filter(h => h && h.resolved);
|
||
if (knownPos.length > 0) {
|
||
const cLat = knownPos.reduce((s, p) => s + p.lat, 0) / knownPos.length;
|
||
const cLon = knownPos.reduce((s, p) => s + p.lon, 0) / knownPos.length;
|
||
const dist = (lat, lon) => Math.sqrt((lat - cLat) ** 2 + (lon - cLon) ** 2);
|
||
for (const hop of raw) {
|
||
if (hop && !hop.resolved && hop.candidates) {
|
||
hop.candidates.sort((a, b) => dist(a.lat, a.lon) - dist(b.lat, b.lon));
|
||
const best = hop.candidates[0];
|
||
hop.lat = best.lat; hop.lon = best.lon;
|
||
hop.name = best.name || hop.name;
|
||
hop.pubkey = best.public_key; hop.role = best.role;
|
||
hop.resolved = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
const positions = raw.filter(h => h && h.resolved);
|
||
|
||
// Resolve and prepend origin node
|
||
if (origin) {
|
||
let originPos = null;
|
||
if (origin.lat != null && origin.lon != null) {
|
||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, isOrigin: true };
|
||
} else if (origin.pubkey) {
|
||
const pk = origin.pubkey.toLowerCase();
|
||
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
|
||
if (match && match.lat != null && match.lon != null) {
|
||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
|
||
}
|
||
}
|
||
if (originPos) positions.unshift(originPos);
|
||
}
|
||
|
||
if (positions.length < 1) return;
|
||
|
||
const coords = positions.map(p => [p.lat, p.lon]);
|
||
|
||
if (positions.length >= 2) {
|
||
L.polyline(coords, {
|
||
color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
|
||
}).addTo(routeLayer);
|
||
}
|
||
|
||
// Add numbered markers at each hop
|
||
var labelItems = [];
|
||
positions.forEach((p, i) => {
|
||
const isOrigin = i === 0 && p.isOrigin;
|
||
const isLast = i === positions.length - 1 && positions.length > 1;
|
||
const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
|
||
const radius = isOrigin ? 14 : 10;
|
||
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
|
||
|
||
if (isOrigin) {
|
||
L.circleMarker([p.lat, p.lon], {
|
||
radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
|
||
}).addTo(routeLayer);
|
||
}
|
||
|
||
const marker = L.circleMarker([p.lat, p.lon], {
|
||
radius: radius, fillColor: color,
|
||
fillOpacity: 0.9, color: '#fff', weight: 2
|
||
}).addTo(routeLayer);
|
||
|
||
const popupHtml = `<div style="font-size:12px;min-width:160px">
|
||
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
|
||
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
|
||
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${safeEsc(p.pubkey || '')}</div>
|
||
<div style="font-size:11px;color:#9ca3af">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}</div>
|
||
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
|
||
</div>`;
|
||
marker.bindPopup(popupHtml, { className: 'route-popup' });
|
||
|
||
labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
|
||
});
|
||
|
||
// Deconflict labels so overlapping hop names spread out
|
||
deconflictLabels(labelItems, map);
|
||
labelItems.forEach(function (m) {
|
||
var pos = m.adjustedLatLng || m.latLng;
|
||
var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
|
||
L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
|
||
if (m.offset > 2) {
|
||
L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
|
||
}
|
||
});
|
||
|
||
// Fit map to route
|
||
if (coords.length >= 2) {
|
||
map.fitBounds(L.latLngBounds(coords).pad(0.3));
|
||
} else {
|
||
map.setView(coords[0], 13);
|
||
}
|
||
}
|
||
|
||
async function loadNodes() {
|
||
try {
|
||
// Load regions from config + observed IATAs
|
||
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
|
||
|
||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
|
||
nodes = data.nodes || [];
|
||
|
||
// Load observers for jump buttons + map markers
|
||
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||
observers = obsData.observers || [];
|
||
|
||
buildRoleChecks(data.counts || {});
|
||
buildJumpButtons();
|
||
|
||
renderMarkers();
|
||
|
||
// Restore heatmap if previously enabled
|
||
if (localStorage.getItem('meshcore-map-heatmap') === 'true') {
|
||
toggleHeatmap(true);
|
||
}
|
||
|
||
// If navigated with ?node=PUBKEY, center on and highlight that node
|
||
if (targetNodeKey) {
|
||
const targetNode = nodes.find(n => n.public_key === targetNodeKey);
|
||
if (targetNode && targetNode.lat && targetNode.lon) {
|
||
map.setView([targetNode.lat, targetNode.lon], 14);
|
||
// Delay popup open slightly — Leaflet needs the map to settle after setView
|
||
setTimeout(() => {
|
||
let found = false;
|
||
markerLayer.eachLayer(m => {
|
||
if (found) return;
|
||
if (m._nodeKey === targetNodeKey && m.openPopup) {
|
||
m.openPopup();
|
||
found = true;
|
||
}
|
||
});
|
||
if (!found) console.warn('[map] Target node marker not found:', targetNodeKey);
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
// Check for pending path inspector route (cross-page navigation from Path Inspector).
|
||
if (window._pendingPathInspectorRoute) {
|
||
var pending = window._pendingPathInspectorRoute;
|
||
delete window._pendingPathInspectorRoute;
|
||
if (pending.path && pending.path.length > 0) {
|
||
if (window.routeLayer) window.routeLayer.clearLayers();
|
||
// Pass full path as hopKeys; null origin (origin is already the first
|
||
// hop). slice(1) + path[0] string was wrong — drawPacketRoute expects
|
||
// origin to be an OBJECT with pubkey/lat/lon, and stripping the head
|
||
// hid the originating node from the route polyline.
|
||
drawPacketRoute(pending.path, null);
|
||
}
|
||
}
|
||
|
||
// Wire up map side pane (Path Inspector embedded - spec §2.7).
|
||
initMapSidePane();
|
||
|
||
// Don't fitBounds on initial load — respect the Bay Area default or saved view
|
||
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
|
||
} catch (e) {
|
||
console.error('Map load error:', e);
|
||
} finally {
|
||
// Always signal data-loaded — even on error — so E2E tests can proceed.
|
||
// Otherwise an api() failure leaves the test waiting forever.
|
||
var mapContainer = document.getElementById('leaflet-map');
|
||
if (mapContainer) mapContainer.setAttribute('data-loaded', 'true');
|
||
}
|
||
}
|
||
|
||
function buildRoleChecks(counts) {
|
||
const el = document.getElementById('mcRoleChecks');
|
||
if (!el) return;
|
||
el.innerHTML = '';
|
||
const nodePubkeys = new Set(nodes.map(n => (n.public_key || '').toLowerCase()));
|
||
const obsCount = observers.filter(o => o.lat && o.lon && !(o.id && nodePubkeys.has(o.id.toLowerCase()))).length;
|
||
const roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' };
|
||
|
||
// Count active/stale per role from loaded nodes
|
||
const roleCounts = {};
|
||
for (const role of roles) {
|
||
roleCounts[role] = { active: 0, stale: 0 };
|
||
}
|
||
for (const n of nodes) {
|
||
const role = (n.role || 'companion').toLowerCase();
|
||
if (!roleCounts[role]) roleCounts[role] = { active: 0, stale: 0 };
|
||
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
|
||
const status = getNodeStatus(role, lastMs);
|
||
roleCounts[role][status]++;
|
||
}
|
||
|
||
for (const role of roles) {
|
||
const cbId = 'mcRole_' + role;
|
||
const lbl = document.createElement('label');
|
||
lbl.setAttribute('for', cbId);
|
||
const shape = shapeMap[role] || '●';
|
||
let countStr;
|
||
if (role === 'observer') {
|
||
countStr = `(${obsCount})`;
|
||
} else {
|
||
const rc = roleCounts[role] || { active: 0, stale: 0 };
|
||
const isInfra = role === 'repeater' || role === 'room';
|
||
const thresh = isInfra ? '72h' : '24h';
|
||
const activeTip = 'Active \u2014 heard within the last ' + thresh;
|
||
const staleTip = 'Stale \u2014 not heard for over ' + thresh;
|
||
countStr = `(<span title="${activeTip}">${rc.active} active</span>, <span title="${staleTip}">${rc.stale} stale</span>)`;
|
||
}
|
||
lbl.innerHTML = `<input type="checkbox" id="${cbId}" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">${shape}</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">${countStr}</span>`;
|
||
lbl.querySelector('input').addEventListener('change', e => {
|
||
filters[e.target.dataset.role] = e.target.checked;
|
||
renderMarkers();
|
||
});
|
||
el.appendChild(lbl);
|
||
}
|
||
}
|
||
|
||
let REGION_NAMES = {};
|
||
|
||
function buildJumpButtons() {
|
||
const el = document.getElementById('mcJumps');
|
||
if (!el) return;
|
||
// Collect unique regions from observers
|
||
const regions = new Set();
|
||
observers.forEach(o => { if (o.iata) regions.add(o.iata); });
|
||
|
||
// Also extract regions from node locations if we have them
|
||
el.innerHTML = '';
|
||
if (regions.size === 0) {
|
||
el.innerHTML = '<span style="color:var(--text-muted);font-size:12px;">No regions yet</span>';
|
||
return;
|
||
}
|
||
for (const r of [...regions].sort()) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'mc-jump-btn';
|
||
btn.textContent = r;
|
||
btn.setAttribute('aria-label', `Jump to ${REGION_NAMES[r] || r}`);
|
||
btn.addEventListener('click', () => jumpToRegion(r));
|
||
el.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function jumpToRegion(iata) {
|
||
// Find observers in this region, then find nodes seen by those observers
|
||
const regionObserverIds = new Set(observers.filter(o => o.iata === iata).map(o => o.id || o.observer_id));
|
||
// Filter nodes that have location; prefer nodes associated with region observers
|
||
let regionNodes = nodes.filter(n => n.lat && n.lon && n.observer_id && regionObserverIds.has(n.observer_id));
|
||
// Fallback: if observers don't link to nodes, use observers' own locations
|
||
if (regionNodes.length === 0) {
|
||
const obsWithLoc = observers.filter(o => o.iata === iata && o.lat && o.lon);
|
||
if (obsWithLoc.length > 0) {
|
||
const bounds = L.latLngBounds(obsWithLoc.map(o => [o.lat, o.lon]));
|
||
map.fitBounds(bounds.pad(0.5), { padding: [40, 40], maxZoom: 12 });
|
||
return;
|
||
}
|
||
// Final fallback: fit all nodes
|
||
regionNodes = nodes.filter(n => n.lat && n.lon);
|
||
}
|
||
if (regionNodes.length === 0) return;
|
||
const bounds = L.latLngBounds(regionNodes.map(n => [n.lat, n.lon]));
|
||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||
}
|
||
|
||
var _renderingMarkers = false;
|
||
var _lastDeconflictZoom = null;
|
||
var _currentMarkerData = []; // stored marker data for zoom-only repositioning
|
||
var _observerByPubkey = new Map(); // observer id (pubkey) → observer object, rebuilt on each render
|
||
var _zoomResizeTimer = null;
|
||
|
||
function deconflictLabels(markers, mapRef) {
|
||
const placed = [];
|
||
const PAD = 4;
|
||
|
||
var overlaps = function(b) {
|
||
for (var k = 0; k < placed.length; k++) {
|
||
var p = placed[k];
|
||
if (b.x < p.x + p.w + PAD && b.x + b.w + PAD > p.x &&
|
||
b.y < p.y + p.h + PAD && b.y + b.h + PAD > p.y) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Spiral offsets — 6 rings, 8 directions, up to ~132px
|
||
var offsets = [];
|
||
for (var ring = 1; ring <= 6; ring++) {
|
||
var dist = ring * 22;
|
||
for (var angle = 0; angle < 360; angle += 45) {
|
||
var rad = angle * Math.PI / 180;
|
||
offsets.push([Math.round(Math.cos(rad) * dist), Math.round(Math.sin(rad) * dist)]);
|
||
}
|
||
}
|
||
|
||
for (var i = 0; i < markers.length; i++) {
|
||
var m = markers[i];
|
||
var w = m.isLabel ? 38 : 20;
|
||
var h = m.isLabel ? 24 : 20;
|
||
var pt = mapRef.latLngToLayerPoint(m.latLng);
|
||
var bestPt = pt;
|
||
var box = { x: pt.x - w / 2, y: pt.y - h / 2, w: w, h: h };
|
||
|
||
if (overlaps(box)) {
|
||
for (var j = 0; j < offsets.length; j++) {
|
||
var tryPt = L.point(pt.x + offsets[j][0], pt.y + offsets[j][1]);
|
||
var tryBox = { x: tryPt.x - w / 2, y: tryPt.y - h / 2, w: w, h: h };
|
||
if (!overlaps(tryBox)) {
|
||
bestPt = tryPt;
|
||
box = tryBox;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
placed.push(box);
|
||
m.adjustedLatLng = mapRef.layerPointToLatLng(bestPt);
|
||
m.offset = Math.sqrt(Math.pow(bestPt.x - pt.x, 2) + Math.pow(bestPt.y - pt.y, 2));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create, update, or remove the offset indicator (dashed line + dot at true GPS position)
|
||
* for a deconflicted marker. Shared by _renderMarkersInner and _repositionMarkers.
|
||
* @param {Object} m - marker data object with latLng, adjustedLatLng, offset, _leafletLine, _leafletDot
|
||
* @param {L.LayerGroup} layer - layer group to add/remove indicators from
|
||
*/
|
||
function _updateOffsetIndicator(m, layer) {
|
||
var pos = m.adjustedLatLng || m.latLng;
|
||
var redColor = getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444';
|
||
|
||
if (m.offset > 10) {
|
||
// Line from true position to adjusted position
|
||
if (m._leafletLine) {
|
||
m._leafletLine.setLatLngs([m.latLng, pos]);
|
||
} else {
|
||
m._leafletLine = L.polyline([m.latLng, pos], {
|
||
color: redColor, weight: 2, dashArray: '6,4', opacity: 0.85
|
||
});
|
||
layer.addLayer(m._leafletLine);
|
||
}
|
||
// Dot at true GPS position
|
||
if (!m._leafletDot) {
|
||
m._leafletDot = L.circleMarker(m.latLng, {
|
||
radius: 3, fillColor: redColor, fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||
});
|
||
layer.addLayer(m._leafletDot);
|
||
}
|
||
} else {
|
||
// No offset — remove indicator if it existed
|
||
if (m._leafletLine) { layer.removeLayer(m._leafletLine); m._leafletLine = null; }
|
||
if (m._leafletDot) { layer.removeLayer(m._leafletDot); m._leafletDot = null; }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reposition existing markers by re-running deconfliction at the current zoom.
|
||
* Avoids clearing and rebuilding all markers — eliminates flicker on zoom/resize.
|
||
*/
|
||
function _repositionMarkers() {
|
||
if (!map || _currentMarkerData.length === 0) return;
|
||
map.invalidateSize({ animate: false });
|
||
|
||
// Re-run deconfliction with current zoom pixel coordinates
|
||
deconflictLabels(_currentMarkerData, map);
|
||
|
||
for (var i = 0; i < _currentMarkerData.length; i++) {
|
||
var m = _currentMarkerData[i];
|
||
var pos = m.adjustedLatLng || m.latLng;
|
||
|
||
// Update marker position
|
||
if (m._leafletMarker) m._leafletMarker.setLatLng(pos);
|
||
|
||
_updateOffsetIndicator(m, markerLayer);
|
||
}
|
||
}
|
||
|
||
function renderMarkers() {
|
||
if (_renderingMarkers) return;
|
||
_renderingMarkers = true;
|
||
try { _renderMarkersInner(); } finally { _renderingMarkers = false; }
|
||
}
|
||
|
||
function _renderMarkersInner() {
|
||
markerLayer.clearLayers();
|
||
_currentMarkerData = [];
|
||
|
||
const filtered = nodes.filter(n => {
|
||
if (!n.lat || !n.lon) return false;
|
||
if (!filters[n.role || 'companion']) return false;
|
||
// Byte size filter (applies only to repeaters)
|
||
if (filters.byteSize !== 'all' && (n.role || 'companion') === 'repeater') {
|
||
const hs = n.hash_size || 1;
|
||
if (String(hs) !== filters.byteSize) return false;
|
||
}
|
||
// Status filter
|
||
if (filters.statusFilter !== 'all') {
|
||
const role = (n.role || 'companion').toLowerCase();
|
||
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
|
||
const status = getNodeStatus(role, lastMs);
|
||
if (status !== filters.statusFilter) return false;
|
||
}
|
||
// Neighbor filter: show only the reference node and its direct neighbors
|
||
if (filters.neighbors && selectedReferenceNode && neighborPubkeys) {
|
||
const pk = n.public_key;
|
||
if (pk !== selectedReferenceNode && !neighborPubkeys.has(pk)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const allMarkers = [];
|
||
|
||
// Build a set of observer public keys for quick lookup
|
||
_observerByPubkey = new Map();
|
||
for (const obs of observers) {
|
||
if (obs.id) _observerByPubkey.set(obs.id.toLowerCase(), obs);
|
||
}
|
||
|
||
for (const node of filtered) {
|
||
const lastSeenTime = node.last_heard || node.last_seen;
|
||
const isStale = getNodeStatus(node.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0) === 'stale';
|
||
const pk = (node.public_key || '').toLowerCase();
|
||
const isAlsoObserver = _observerByPubkey.has(pk);
|
||
const useLabel = node.role === 'repeater' && filters.hashLabels;
|
||
// Multi-byte overlay: color repeaters by multi_byte_status
|
||
var mbColor = null;
|
||
if (filters.multiByteOverlay && node.role === 'repeater') {
|
||
mbColor = MB_COLORS[node.multi_byte_status] || MB_COLORS.unknown;
|
||
}
|
||
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbColor) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
|
||
const latLng = L.latLng(node.lat, node.lon);
|
||
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + (isAlsoObserver ? ' + observer' : '') + ')' });
|
||
}
|
||
|
||
// Add observer markers (skip observers already represented as a node marker)
|
||
// Build set of node pubkeys that are displayed on the map
|
||
const displayedNodePubkeys = new Set(filtered.map(n => (n.public_key || '').toLowerCase()));
|
||
if (filters.observer) {
|
||
for (const obs of observers) {
|
||
if (!obs.lat || !obs.lon) continue;
|
||
// Skip observers whose pubkey matches a displayed node — they're shown as combined markers
|
||
if (obs.id && displayedNodePubkeys.has(obs.id.toLowerCase())) continue;
|
||
const icon = makeMarkerIcon('observer');
|
||
const latLng = L.latLng(obs.lat, obs.lon);
|
||
allMarkers.push({ latLng, node: obs, icon, isLabel: false, popupFn: function() { return buildObserverPopup(obs); }, alt: (obs.name || obs.id || 'Unknown') + ' (observer)' });
|
||
}
|
||
}
|
||
|
||
// Ensure map has correct pixel dimensions before deconfliction
|
||
// (SPA navigation may render markers before container is fully sized)
|
||
map.invalidateSize({ animate: false });
|
||
|
||
// Deconflict ALL markers
|
||
if (allMarkers.length > 0) {
|
||
deconflictLabels(allMarkers, map);
|
||
}
|
||
|
||
// Store marker data for zoom/resize repositioning (avoids full rebuild)
|
||
_currentMarkerData = allMarkers;
|
||
|
||
for (const m of allMarkers) {
|
||
const pos = m.adjustedLatLng || m.latLng;
|
||
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
|
||
marker._nodeKey = m.node.public_key || m.node.id || null;
|
||
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
|
||
markerLayer.addLayer(marker);
|
||
m._leafletMarker = marker;
|
||
m._leafletLine = null;
|
||
m._leafletDot = null;
|
||
|
||
_updateOffsetIndicator(m, markerLayer);
|
||
}
|
||
}
|
||
|
||
function buildObserverPopup(obs) {
|
||
const name = safeEsc(obs.name || obs.id || 'Unknown');
|
||
const iata = obs.iata ? `<span class="badge-region">${safeEsc(obs.iata)}</span>` : '';
|
||
const lastSeen = obs.last_seen ? timeAgo(obs.last_seen) : '—';
|
||
const packets = (obs.packet_count || 0).toLocaleString();
|
||
const loc = `${obs.lat.toFixed(5)}, ${obs.lon.toFixed(5)}`;
|
||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS.observer};color:#fff;">OBSERVER</span>`;
|
||
|
||
return `
|
||
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
|
||
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${name}</h3>
|
||
${roleBadge} ${iata}
|
||
<dl style="margin-top:8px;font-size:12px;">
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Seen</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${lastSeen}</dd>
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Packets</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${packets}</dd>
|
||
</dl>
|
||
<a href="#/observers/${encodeURIComponent(obs.id || obs.observer_id)}" style="display:block;margin-top:8px;font-size:12px;color:var(--accent);">View Detail →</a>
|
||
</div>`;
|
||
}
|
||
|
||
async function selectReferenceNode(pubkey, name) {
|
||
selectedReferenceNode = pubkey;
|
||
neighborPubkeys = new Set();
|
||
try {
|
||
// Use affinity-based neighbor API (server-side disambiguation) instead of
|
||
// client-side path walking which fails on hash collisions (#484)
|
||
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
|
||
for (const n of (data.neighbors || [])) {
|
||
if (n.pubkey) neighborPubkeys.add(n.pubkey);
|
||
// For ambiguous edges, include all candidates (better to show extra than miss)
|
||
if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
|
||
}
|
||
// If affinity data is insufficient, fall back to client-side path walking
|
||
if (neighborPubkeys.size === 0) {
|
||
const pathData = await api('/nodes/' + pubkey + '/paths');
|
||
const paths = pathData.paths || [];
|
||
for (const p of paths) {
|
||
const hops = p.hops || [];
|
||
for (var i = 0; i < hops.length; i++) {
|
||
if (hops[i].pubkey === pubkey) {
|
||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
|
||
neighborPubkeys = new Set();
|
||
}
|
||
// Update sidebar UI
|
||
const refEl = document.getElementById('mcNeighborRef');
|
||
const refNameEl = document.getElementById('mcNeighborRefName');
|
||
const hintEl = document.getElementById('mcNeighborHint');
|
||
if (refEl) { refEl.style.display = 'block'; }
|
||
if (refNameEl) { refNameEl.textContent = name || pubkey.slice(0, 8); }
|
||
if (hintEl) { hintEl.style.display = 'none'; }
|
||
// Auto-enable the neighbors filter
|
||
filters.neighbors = true;
|
||
const cb = document.getElementById('mcNeighbors');
|
||
if (cb) cb.checked = true;
|
||
renderMarkers();
|
||
}
|
||
// Event delegation for Show Neighbors links (avoids inline onclick / global function timing issues)
|
||
document.addEventListener('click', function(e) {
|
||
var link = e.target.closest('[data-show-neighbors]');
|
||
if (link) {
|
||
e.preventDefault();
|
||
selectReferenceNode(link.dataset.pubkey, link.dataset.name);
|
||
}
|
||
});
|
||
// Expose for testing
|
||
window._mapSelectRefNode = selectReferenceNode;
|
||
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
|
||
|
||
function buildPopup(node) {
|
||
const key = node.public_key ? truncate(node.public_key, 16) : '—';
|
||
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
|
||
const lastAdvert = node.last_seen ? timeAgo(node.last_seen) : '—';
|
||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS[node.role] || '#4b5563'};color:#fff;">${(node.role || 'unknown').toUpperCase()}</span>`;
|
||
// Check if this node is also an observer (combined repeater+observer)
|
||
const matchingObs = node.public_key ? _observerByPubkey.get(node.public_key.toLowerCase()) : null;
|
||
const obsBadge = matchingObs ? ` <span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS.observer || '#f1c40f'};color:#fff;">OBSERVER</span>` : '';
|
||
const hs = node.hash_size || 1;
|
||
const hashPrefix = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '—';
|
||
const hashPrefixRow = `<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Hash Prefix</dt>
|
||
<dd style="font-family:var(--mono);font-size:11px;font-weight:700;margin-left:88px;padding:2px 0;">${safeEsc(hashPrefix)} <span style="font-weight:400;color:var(--text-muted);">(${hs}B)</span></dd>`;
|
||
// Multi-byte support indicator for repeaters
|
||
var mbRow = '';
|
||
if (node.role === 'repeater' && node.multi_byte_status) {
|
||
var mbLabel = { confirmed: '✅ Confirmed', suspected: '⚠️ Suspected', unknown: '❌ Unknown' }[node.multi_byte_status] || node.multi_byte_status;
|
||
var mbEvidence = node.multi_byte_evidence ? ' (' + node.multi_byte_evidence + ')' : '';
|
||
mbRow = '<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Multi-byte</dt>' +
|
||
'<dd style="margin-left:88px;padding:2px 0;font-size:12px;">' + mbLabel + mbEvidence + '</dd>';
|
||
}
|
||
|
||
return `
|
||
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
|
||
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${safeEsc(node.name || 'Unknown')}</h3>
|
||
${roleBadge}${obsBadge}
|
||
<dl style="margin-top:8px;font-size:12px;">
|
||
${hashPrefixRow}
|
||
${mbRow}
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
|
||
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Advert</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${lastAdvert}</dd>
|
||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Adverts</dt>
|
||
<dd style="margin-left:88px;padding:2px 0;">${node.advert_count || 0}</dd>
|
||
</dl>
|
||
<div style="margin-top:8px;clear:both;">
|
||
<a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a>
|
||
${node.public_key ? ` · <a href="javascript:void(0)" role="button" data-show-neighbors data-pubkey="${escapeHtml(node.public_key)}" data-name="${escapeHtml(node.name || 'Unknown')}" style="color:var(--accent);font-size:12px;cursor:pointer;">Show Neighbors</a>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function fitBounds() {
|
||
const nodesWithLoc = nodes.filter(n => n.lat && n.lon && filters[n.role || 'companion']);
|
||
if (nodesWithLoc.length === 0) return;
|
||
if (nodesWithLoc.length === 1) {
|
||
map.setView([nodesWithLoc[0].lat, nodesWithLoc[0].lon], 10);
|
||
return;
|
||
}
|
||
const bounds = L.latLngBounds(nodesWithLoc.map(n => [n.lat, n.lon]));
|
||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||
}
|
||
|
||
// === Map Side Pane — Path Inspector (spec §2.7) ===
|
||
function initMapSidePane() {
|
||
var pane = document.getElementById('mapSidePane');
|
||
var toggle = document.getElementById('mapPaneToggle');
|
||
var input = document.getElementById('mapPiInput');
|
||
var btn = document.getElementById('mapPiSubmit');
|
||
if (!pane || !toggle) return;
|
||
|
||
toggle.addEventListener('click', function () {
|
||
pane.classList.toggle('expanded');
|
||
toggle.textContent = pane.classList.contains('expanded') ? '▶' : '◀';
|
||
// Invalidate map size after transition.
|
||
setTimeout(function () { if (map) map.invalidateSize(); }, 220);
|
||
});
|
||
|
||
if (btn && input) {
|
||
btn.addEventListener('click', function () { mapPiSubmit(input.value); });
|
||
input.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') mapPiSubmit(input.value);
|
||
});
|
||
}
|
||
|
||
// Auto-open if URL has prefixes param while on map.
|
||
var params = new URLSearchParams(location.hash.split('?')[1] || '');
|
||
var prefixParam = params.get('prefixes');
|
||
if (prefixParam && input) {
|
||
pane.classList.add('expanded');
|
||
toggle.textContent = '▶';
|
||
input.value = prefixParam;
|
||
setTimeout(function () { if (map) map.invalidateSize(); }, 220);
|
||
mapPiSubmit(prefixParam);
|
||
}
|
||
}
|
||
|
||
function mapPiSubmit(raw) {
|
||
var errDiv = document.getElementById('mapPiError');
|
||
var resultsDiv = document.getElementById('mapPiResults');
|
||
if (!errDiv || !resultsDiv) return;
|
||
errDiv.textContent = '';
|
||
resultsDiv.innerHTML = '';
|
||
|
||
// Reuse PathInspector validation if available.
|
||
var prefixes = raw.trim().split(/[\s,]+/).filter(function (s) { return s.length > 0; }).map(function (s) { return s.toLowerCase(); });
|
||
var err = (window.PathInspector && window.PathInspector.validatePrefixes) ? window.PathInspector.validatePrefixes(prefixes) : null;
|
||
if (!err && prefixes.length === 0) err = 'Enter at least one prefix.';
|
||
if (err) { errDiv.textContent = err; return; }
|
||
|
||
resultsDiv.innerHTML = '<p style="font-size:12px;">Loading...</p>';
|
||
fetch('/api/paths/inspect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prefixes: prefixes })
|
||
})
|
||
.then(function (r) {
|
||
if (r.status === 503) return r.json().then(function () { throw new Error('Service warming up, retry shortly.'); });
|
||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); });
|
||
return r.json();
|
||
})
|
||
.then(function (data) { renderMapPiResults(data, resultsDiv); })
|
||
.catch(function (e) { resultsDiv.innerHTML = ''; errDiv.textContent = e.message; });
|
||
}
|
||
|
||
function renderMapPiResults(data, div) {
|
||
if (!data.candidates || data.candidates.length === 0) {
|
||
div.innerHTML = '<p style="font-size:12px;color:var(--text-muted);">No candidates found.</p>';
|
||
return;
|
||
}
|
||
var html = '<table class="path-inspector-table" style="font-size:11px;width:100%;"><thead><tr><th>#</th><th>Score</th><th>Path</th><th></th></tr></thead><tbody>';
|
||
for (var i = 0; i < data.candidates.length; i++) {
|
||
var c = data.candidates[i];
|
||
var rowClass = c.speculative ? 'speculative-row' : '';
|
||
html += '<tr class="' + rowClass + '">';
|
||
html += '<td>' + (i + 1) + '</td>';
|
||
html += '<td class="' + (c.speculative ? 'speculative-warning' : '') + '">' + c.score.toFixed(2) + (c.speculative ? ' ⚠' : '') + '</td>';
|
||
html += '<td title="' + safeEsc(c.names.join(' → ')) + '">' + safeEsc(c.names.slice(0, 3).join('→')) + (c.names.length > 3 ? '…' : '') + '</td>';
|
||
html += '<td><button class="btn btn-sm" data-idx="' + i + '" title="Show on Map">📍</button></td>';
|
||
html += '</tr>';
|
||
// Per-hop evidence (collapsed).
|
||
html += '<tr class="evidence-row collapsed" data-evidence="' + i + '"><td colspan="4"><div class="evidence-detail" style="font-size:10px;">';
|
||
if (c.evidence && c.evidence.perHop) {
|
||
for (var j = 0; j < c.evidence.perHop.length; j++) {
|
||
var h = c.evidence.perHop[j];
|
||
html += '<div>Hop ' + (j+1) + ': ' + h.prefix + ' (×' + h.candidatesConsidered + ') w=' + h.edgeWeight.toFixed(2);
|
||
if (h.alternatives && h.alternatives.length > 0) {
|
||
html += ' <span style="color:var(--text-muted);">[+' + h.alternatives.length + ' alt]</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
}
|
||
html += '</div></td></tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
div.innerHTML = html;
|
||
|
||
// Wire buttons.
|
||
div.querySelectorAll('button[data-idx]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var idx = parseInt(btn.dataset.idx);
|
||
var cand = data.candidates[idx];
|
||
if (routeLayer) routeLayer.clearLayers();
|
||
drawPacketRoute(cand.path, null);
|
||
});
|
||
});
|
||
// Expand evidence on row click.
|
||
div.querySelectorAll('.path-inspector-table tbody tr:not(.evidence-row)').forEach(function (row) {
|
||
row.style.cursor = 'pointer';
|
||
row.addEventListener('click', function (e) {
|
||
if (e.target.tagName === 'BUTTON') return;
|
||
var b = row.querySelector('button[data-idx]');
|
||
if (!b) return;
|
||
var ev = div.querySelector('tr[data-evidence="' + b.dataset.idx + '"]');
|
||
if (ev) ev.classList.toggle('collapsed');
|
||
});
|
||
});
|
||
}
|
||
|
||
function destroy() {
|
||
if (wsHandler) offWS(wsHandler);
|
||
wsHandler = null;
|
||
if (map) {
|
||
map.remove();
|
||
map = null;
|
||
}
|
||
markerLayer = null;
|
||
_currentMarkerData = [];
|
||
routeLayer = null;
|
||
if (heatLayer) { heatLayer = null; }
|
||
geoFilterLayer = null;
|
||
selectedReferenceNode = null;
|
||
neighborPubkeys = null;
|
||
delete window._mapSelectRefNode;
|
||
delete window._mapGetNeighborPubkeys;
|
||
}
|
||
|
||
function toggleHeatmap(on) {
|
||
if (!on || !map) {
|
||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; window._meshcoreHeatLayer = null; }
|
||
return;
|
||
}
|
||
const points = nodes
|
||
.filter(n => n.lat != null && n.lon != null)
|
||
.map(n => {
|
||
const weight = n.advert_count || 1;
|
||
return [n.lat, n.lon, weight];
|
||
});
|
||
if (!points.length || typeof L.heatLayer !== 'function') return;
|
||
var savedOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
|
||
if (isNaN(savedOpacity)) savedOpacity = 0.25;
|
||
// Update existing layer data without recreating (avoids opacity flash)
|
||
if (heatLayer) {
|
||
heatLayer.setLatLngs(points);
|
||
return;
|
||
}
|
||
heatLayer = L.heatLayer(points, {
|
||
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.05,
|
||
gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' }
|
||
});
|
||
// Set opacity on canvas BEFORE it's visible — hook the 'add' event
|
||
heatLayer.on('add', function() {
|
||
var canvas = heatLayer._canvas || (heatLayer.getContainer && heatLayer.getContainer());
|
||
if (canvas) canvas.style.opacity = savedOpacity;
|
||
});
|
||
heatLayer.addTo(map);
|
||
window._meshcoreHeatLayer = heatLayer;
|
||
}
|
||
|
||
let _themeRefreshHandler = null;
|
||
|
||
// ─── Affinity Debug Overlay ────────────────────────────────────────────────
|
||
function clearAffinityOverlay() {
|
||
if (affinityLayer) { map.removeLayer(affinityLayer); affinityLayer = null; }
|
||
affinityData = null;
|
||
}
|
||
|
||
function loadAffinityDebugOverlay() {
|
||
clearAffinityOverlay();
|
||
// Fetch debug data — requires API key stored in localStorage
|
||
var apiKey = localStorage.getItem('meshcore-api-key') || '';
|
||
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
|
||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||
.then(function (data) {
|
||
affinityData = data;
|
||
renderAffinityOverlay();
|
||
})
|
||
.catch(function (err) {
|
||
console.warn('[affinity-debug] Failed to load:', err);
|
||
var cb = document.getElementById('mcAffinityDebug');
|
||
if (cb) cb.checked = false;
|
||
});
|
||
}
|
||
|
||
function renderAffinityOverlay() {
|
||
if (!affinityData || !map) return;
|
||
clearAffinityOverlay();
|
||
affinityLayer = L.layerGroup();
|
||
|
||
// Build node position lookup from current markers
|
||
var nodePos = {};
|
||
nodes.forEach(function (n) {
|
||
if (n.latitude && n.longitude) {
|
||
nodePos[n.public_key.toLowerCase()] = [n.latitude, n.longitude];
|
||
}
|
||
});
|
||
|
||
var edges = affinityData.edges || [];
|
||
edges.forEach(function (e) {
|
||
var posA = nodePos[e.nodeA];
|
||
var posB = e.nodeB ? nodePos[e.nodeB] : null;
|
||
|
||
if (!posA) return;
|
||
|
||
// Unresolved prefix — show ❓ marker near nodeA
|
||
if (e.unresolved || (!posB && e.ambiguous)) {
|
||
if (posA) {
|
||
var marker = L.marker([posA[0] + 0.001, posA[1] + 0.001], {
|
||
icon: L.divIcon({ html: '❓', className: 'affinity-unresolved', iconSize: [20, 20] })
|
||
});
|
||
marker.bindPopup('<b>Unresolved prefix:</b> ' + escapeHtml(e.prefix) + '<br>Observations: ' + e.weight);
|
||
affinityLayer.addLayer(marker);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!posB) return;
|
||
|
||
// Color by confidence
|
||
var color = '#ef4444'; // red — ambiguous
|
||
var score = e.score || 0;
|
||
if (score >= 0.6) color = '#22c55e'; // green — high
|
||
else if (score >= 0.3) color = '#eab308'; // yellow — medium
|
||
|
||
// Thickness proportional to weight, clamped 1-5px
|
||
var weight = Math.max(1, Math.min(5, Math.round((e.weight || 1) / 20)));
|
||
|
||
var line = L.polyline([posA, posB], {
|
||
color: color,
|
||
weight: weight,
|
||
opacity: 0.7,
|
||
dashArray: e.ambiguous ? '5,5' : null
|
||
});
|
||
|
||
var popup = '<b>Affinity Edge</b><br>' +
|
||
escapeHtml(e.nodeAName || e.nodeA.substring(0, 8)) + ' ↔ ' + escapeHtml(e.nodeBName || e.nodeB.substring(0, 8)) + '<br>' +
|
||
'Observations: ' + e.observationCount + '<br>' +
|
||
'Score: ' + (e.score || 0).toFixed(3) + '<br>' +
|
||
'Last seen: ' + escapeHtml(e.lastSeen) + '<br>' +
|
||
'Observers: ' + escapeHtml((e.observers || []).join(', '));
|
||
if (e.avgSnr != null) popup += '<br>Avg SNR: ' + e.avgSnr.toFixed(1) + ' dB';
|
||
|
||
line.bindPopup(popup);
|
||
affinityLayer.addLayer(line);
|
||
});
|
||
|
||
affinityLayer.addTo(map);
|
||
}
|
||
// ─── End Affinity Debug ────────────────────────────────────────────────────
|
||
|
||
registerPage('map', {
|
||
init: function(app, routeParam) {
|
||
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
|
||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||
return init(app, routeParam);
|
||
},
|
||
destroy: function() {
|
||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||
return destroy();
|
||
}
|
||
});
|
||
})();
|