mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 07:21:19 +00:00
40aa02b438
Red commit:c0de33a952(CI: https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416117686) Green commit:c268248d— CI: https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416069319 ## What Fix #1360 regression: cluster role pills on `/map` show ONLY the role letter (R/C/M/S/O); the per-role count number that was visible pre-#1357 is gone. This PR restores the count by concatenating it after the letter inside the pill body, so each pill renders as `R60`, `C30`, `M5`, etc. - `public/map.js` `makeClusterIcon`: pill body becomes `letter + n` (was `letter`). - `aria-label` / `title` (`"60 repeaters"`) untouched — already correct. - DOM, classes, CSS, `--mc-*` constants, border-style ramp, multi-byte labels — untouched. ### Adversarial follow-up (commit on top of green) - **JS cap**: `makeClusterIcon` clamps `n > 999` → `"999+"`, so pathological clusters render as e.g. `R999+` instead of `R10000`. Pill width stays bounded. - **CSS guard** on `.mc-pill`: `max-width: 4ch; overflow: hidden; text-overflow: ellipsis;` as defense-in-depth if a render slips past the JS cap. - **+3 test assertions**: one for the JS cap, two for the CSS guard. Mutation-verified (removing the cap fails ONLY the new cap assertion). ## Why #1357 fixed WCAG 1.4.1 for cluster role pills by promoting the role letter to the pill body, but in doing so dropped the count number that sighted operators relied on for at-a-glance per-role counts. The letter is the WCAG carrier; the count is the data. Both belong in the pill body — they always did before #1357. The audit's intent was to PAIR them, not REPLACE one with the other. ## TDD red→green - **Red** (`c0de33a9`): added `test-issue-1360-pill-letter-count.js` with assertions that pill body concatenates `letter + n` and is no longer the bare `letter`. Fails by assertion against current `master`. Red CI: https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416117686 - **Green** (`c268248d`): one-line change in `public/map.js` (`letter + '</span>'` → `letter + n + '</span>'`). All assertions pass. Green CI: https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416069319 - **Follow-up** (this push): JS `"999+"` cap + CSS width guard + 3 new assertions. #1356 (40), #1293, and `marker-outline-weight` tests remain green. - New test wired into `.github/workflows/deploy.yml` right after `test-issue-1356-map-a11y.js`. ## Visual verification Open https://analyzer.00id.net/#/map after deploy and confirm cluster pills display `R<count>`, `C<count>`, `M<count>`, etc. (e.g. `R60 C30 M5`) instead of bare letters. `aria-label="60 repeaters"` remains for screen readers. For very large clusters, pills cap at `R999+` / `C999+` etc. Fixes #1360 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: CoreScope Bot <bot@corescope>
1555 lines
71 KiB
JavaScript
1555 lines
71 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, clustering: localStorage.getItem('meshcore-map-clustering') !== '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)
|
||
|
||
// ── #1356 a11y constants — letter prefix + glyph + neutral fill carriers ──
|
||
// ROLE_LETTERS gives each role a single capital-letter primary carrier
|
||
// (legible at 10px monospace, survives full grayscale).
|
||
var ROLE_LETTERS = {
|
||
repeater: 'R',
|
||
companion: 'C',
|
||
room: 'M',
|
||
sensor: 'S',
|
||
observer: 'O',
|
||
};
|
||
// MB_GLYPHS prefix the hash text with a non-color status carrier.
|
||
var MB_GLYPHS = {
|
||
confirmed: '\u2713', // ✓
|
||
suspected: '?',
|
||
unknown: '\u2717', // ✗
|
||
};
|
||
// Per-status CSS class (drives the colored 3px left-border in style.css).
|
||
var MB_STATUS_CLASS = {
|
||
confirmed: 'status-confirmed',
|
||
suspected: 'status-suspected',
|
||
unknown: 'status-unknown',
|
||
};
|
||
// #1356 V3 marker-dot tint set — high-luminance accents that mirror the
|
||
// CSS `--mc-mb-confirmed/suspected/unknown` left-border stripe palette so the
|
||
// marker-dot and label-stripe surfaces stay visually consistent. Module
|
||
// scope (not loop-local) to avoid per-iteration object allocation.
|
||
var MB_MARKER_TINT = { confirmed: '#56F0A0', suspected: '#FFD966', unknown: '#FF8888' };
|
||
|
||
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="1"/>`;
|
||
break;
|
||
case 'square':
|
||
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
|
||
break;
|
||
case 'triangle':
|
||
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
|
||
break;
|
||
case 'hexagon': {
|
||
// #1293 — pointy-top hexagon for room servers
|
||
const hr = c - 1.5;
|
||
let hpts = '';
|
||
for (let hi = 0; hi < 6; hi++) {
|
||
const ha = (hi * 60 - 90) * Math.PI / 180;
|
||
hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' +
|
||
(c + hr * Math.sin(ha)).toFixed(2) + ' ';
|
||
}
|
||
path = `<polygon points="${hpts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
|
||
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"/>`;
|
||
break;
|
||
}
|
||
default: // circle
|
||
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
|
||
}
|
||
// 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, mbStatus) {
|
||
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() : '??';
|
||
// #1356 V3: glyph is the primary non-color status carrier, hash is the data,
|
||
// status color is a thin left-border (CSS class drives the hue).
|
||
var status = mbStatus || null;
|
||
var glyph = status ? (MB_GLYPHS[status] || MB_GLYPHS.unknown) : '';
|
||
var statusClass = status ? (' ' + (MB_STATUS_CLASS[status] || MB_STATUS_CLASS.unknown)) : '';
|
||
var ariaStatus = status ? ('multi-byte ' + status + ', hash ' + shortHash)
|
||
: ('repeater hash ' + shortHash);
|
||
// Observer indicator stays a star — it is an orthogonal signal, not a status color.
|
||
var obsIndicator = isAlsoObserver
|
||
? ' <span aria-hidden="true" style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>'
|
||
: '';
|
||
// Glyph + thin-space (U+2009) + hash. Visible content is aria-hidden so AT
|
||
// reads the aria-label only (avoids "check mark 3 E" literal announcements).
|
||
var visible = (glyph ? glyph + '\u2009' : '') + shortHash;
|
||
var html = '<div class="mc-mb-label' + statusClass + '" role="img" aria-label="' + ariaStatus + '">' +
|
||
'<span aria-hidden="true">' + visible + '</span>' + 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"> Cluster markers</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>
|
||
<div id="mapAreaFilter"></div>
|
||
<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);
|
||
clusterGroup = createClusterGroup();
|
||
if (filters.clustering && clusterGroup) clusterGroup.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));
|
||
});
|
||
|
||
// #1329: Map controls accordion. Make each section's legend a button-
|
||
// style toggle with aria-expanded. On mobile (≤640px) only one section
|
||
// is open at a time so the panel never needs internal scrolling. On
|
||
// desktop the .mc-collapsed class has no visual effect (CSS only hides
|
||
// section bodies inside the mobile media query) so all controls stay
|
||
// visible — but single-open behaviour is still tracked for state
|
||
// consistency. See test-issue-1329-map-controls-accordion-e2e.js.
|
||
(function initMapControlsAccordion() {
|
||
const isMobile = window.innerWidth <= 640;
|
||
const sections = Array.from(controlsPanel.querySelectorAll('fieldset.mc-section'));
|
||
sections.forEach((fs, idx) => {
|
||
const legend = fs.querySelector('legend.mc-label');
|
||
if (!legend) return;
|
||
// Initial state: on mobile only the first section is open; on
|
||
// desktop all sections are open.
|
||
const open = !isMobile || idx === 0;
|
||
legend.setAttribute('role', 'button');
|
||
legend.setAttribute('tabindex', '0');
|
||
legend.setAttribute('aria-expanded', String(open));
|
||
fs.classList.toggle('mc-collapsed', !open);
|
||
const setOpen = (target, openNow) => {
|
||
target.setAttribute('aria-expanded', String(openNow));
|
||
const parent = target.closest('fieldset.mc-section');
|
||
if (parent) parent.classList.toggle('mc-collapsed', !openNow);
|
||
};
|
||
const onActivate = (e) => {
|
||
e.preventDefault();
|
||
const currentlyOpen = legend.getAttribute('aria-expanded') === 'true';
|
||
// Single-open: close every other section first.
|
||
sections.forEach(other => {
|
||
const otherLegend = other.querySelector('legend.mc-label');
|
||
if (otherLegend && otherLegend !== legend) setOpen(otherLegend, false);
|
||
});
|
||
setOpen(legend, !currentlyOpen);
|
||
};
|
||
legend.addEventListener('click', onActivate);
|
||
legend.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onActivate(e);
|
||
});
|
||
});
|
||
})();
|
||
|
||
// Bind controls
|
||
var clustersEl = document.getElementById('mcClusters');
|
||
if (clustersEl) {
|
||
clustersEl.checked = filters.clustering;
|
||
clustersEl.addEventListener('change', function (e) {
|
||
filters.clustering = e.target.checked;
|
||
localStorage.setItem('meshcore-map-clustering', filters.clustering);
|
||
if (filters.clustering) {
|
||
if (clusterGroup && !map.hasLayer(clusterGroup)) clusterGroup.addTo(map);
|
||
} else {
|
||
if (clusterGroup && map.hasLayer(clusterGroup)) map.removeLayer(clusterGroup);
|
||
}
|
||
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(); });
|
||
|
||
AreaFilter.init(document.getElementById('mapAreaFilter'));
|
||
AreaFilter.onChange(function () { 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 aqs = AreaFilter.areaQueryString();
|
||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}${aqs}`, { 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;
|
||
const findIn = function (layer) {
|
||
if (found || !layer || !layer.eachLayer) return;
|
||
layer.eachLayer(m => {
|
||
if (found) return;
|
||
if (m._nodeKey === targetNodeKey && m.openPopup) {
|
||
m.openPopup();
|
||
found = true;
|
||
}
|
||
});
|
||
};
|
||
findIn(markerLayer);
|
||
if (!found) findIn(clusterGroup);
|
||
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;
|
||
// Markercluster handles its own re-layout on zoom/move — skip our deconfliction
|
||
// dance when clustering is on.
|
||
if (filters.clustering) 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();
|
||
if (clusterGroup) clusterGroup.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;
|
||
// #1356 V3: multi-byte status is no longer encoded by label fill color.
|
||
// Pass the raw status string to the label icon (it picks glyph + CSS class);
|
||
// marker-dot tinting (for non-label rendering) keeps a colorblind-safe hex.
|
||
var mbStatus = null;
|
||
var mbColor = null;
|
||
if (filters.multiByteOverlay && node.role === 'repeater') {
|
||
mbStatus = node.multi_byte_status || 'unknown';
|
||
// Marker-dot tint (module-scope MB_MARKER_TINT) — high-luminance accent
|
||
// set kept in sync with --mc-mb-* CSS stripes so label + marker agree.
|
||
mbColor = MB_MARKER_TINT[mbStatus] || MB_MARKER_TINT.unknown;
|
||
}
|
||
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbStatus) : 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 — but only when clustering is OFF.
|
||
// When clustering is ON, markercluster handles overlap collapse and
|
||
// deconfliction would just waste CPU + draw offset polylines we don't want.
|
||
if (allMarkers.length > 0 && !filters.clustering) {
|
||
deconflictLabels(allMarkers, map);
|
||
}
|
||
|
||
// Store marker data for zoom/resize repositioning (avoids full rebuild)
|
||
_currentMarkerData = allMarkers;
|
||
|
||
var useCluster = filters.clustering && clusterGroup;
|
||
var clusterMarkers = [];
|
||
for (const m of allMarkers) {
|
||
const pos = (useCluster ? m.latLng : (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._role = (m.node && m.node.role) || 'companion';
|
||
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
|
||
m._leafletMarker = marker;
|
||
m._leafletLine = null;
|
||
m._leafletDot = null;
|
||
|
||
if (useCluster) {
|
||
clusterMarkers.push(marker);
|
||
} else {
|
||
markerLayer.addLayer(marker);
|
||
_updateOffsetIndicator(m, markerLayer);
|
||
}
|
||
}
|
||
if (useCluster && clusterMarkers.length > 0) {
|
||
clusterGroup.addLayers(clusterMarkers);
|
||
}
|
||
}
|
||
|
||
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;
|
||
clusterGroup = 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();
|
||
}
|
||
});
|
||
|
||
// ── Marker clustering (issue #1036) ──
|
||
// Wraps Leaflet.markercluster with CoreScope-themed cluster icons + sane perf
|
||
// defaults for large meshes (target: smooth pan/zoom @ 2k nodes on mid mobile).
|
||
function isMobileForClustering() {
|
||
try {
|
||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
|
||
} catch (_) { return false; }
|
||
}
|
||
function createClusterGroup() {
|
||
if (typeof L === 'undefined' || typeof L.markerClusterGroup !== 'function') {
|
||
console.warn('[map] L.markerClusterGroup not loaded — clustering disabled');
|
||
return null;
|
||
}
|
||
return L.markerClusterGroup({
|
||
chunkedLoading: true,
|
||
chunkInterval: 100,
|
||
chunkDelay: 25,
|
||
removeOutsideVisibleBounds: true,
|
||
maxClusterRadius: 60,
|
||
spiderfyOnMaxZoom: true,
|
||
spiderfyDistanceMultiplier: 1.5,
|
||
showCoverageOnHover: false,
|
||
zoomToBoundsOnClick: true,
|
||
disableClusteringAtZoom: 16,
|
||
animate: !isMobileForClustering(),
|
||
animateAddingMarkers: false,
|
||
iconCreateFunction: makeClusterIcon,
|
||
});
|
||
}
|
||
|
||
function makeClusterIcon(cluster) {
|
||
var markers = cluster.getAllChildMarkers();
|
||
var counts = { repeater: 0, companion: 0, room: 0, sensor: 0, observer: 0 };
|
||
for (var i = 0; i < markers.length; i++) {
|
||
var r = markers[i]._role || 'companion';
|
||
if (counts[r] == null) counts[r] = 0;
|
||
counts[r] += 1;
|
||
}
|
||
var total = (typeof cluster.getChildCount === 'function') ? cluster.getChildCount() : markers.length;
|
||
var bucket = total >= 100 ? 'lg' : total >= 30 ? 'md' : 'sm';
|
||
var roleOrder = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||
// #1356 V2: pill background uses the --mc-role-* Wong palette (CSS var),
|
||
// pill text is the role letter (primary, monochrome-safe carrier).
|
||
// The audit's minimal patch keeps dark text on every Wong hue, so no
|
||
// per-role text-color branching is needed.
|
||
var ROLE_BG_VAR = {
|
||
repeater: 'var(--mc-role-repeater)',
|
||
companion: 'var(--mc-role-companion)',
|
||
room: 'var(--mc-role-room)',
|
||
sensor: 'var(--mc-role-sensor)',
|
||
observer: 'var(--mc-role-observer)',
|
||
};
|
||
var pillsHtml = '';
|
||
var tooltipParts = [];
|
||
var pillsShown = 0;
|
||
for (var j = 0; j < roleOrder.length; j++) {
|
||
var role = roleOrder[j];
|
||
var n = counts[role] || 0;
|
||
if (n <= 0) continue;
|
||
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
|
||
if (pillsShown < 4) {
|
||
var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)';
|
||
var letter = ROLE_LETTERS[role] || '?';
|
||
// #1360 follow-up: cap 4+ digit counts as "999+" to bound pill width.
|
||
// Defense-in-depth: .mc-pill CSS also enforces max-width + ellipsis.
|
||
if (n > 999) n = '999+';
|
||
pillsHtml += '<span class="mc-pill role-' + role + '" ' +
|
||
'role="img" aria-label="' + n + ' ' + role + (n === 1 ? '' : 's') + '" ' +
|
||
'style="background:' + bg + ';color:#1a1a1a" ' +
|
||
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
|
||
letter + n + '</span>';
|
||
pillsShown += 1;
|
||
}
|
||
}
|
||
// #1356 V1: cluster gets role="img" + an aria-label summarising the
|
||
// count and per-role breakdown so screen readers announce the data.
|
||
var ariaLabel = total + ' nodes — ' + tooltipParts.join(', ');
|
||
var html = '<div class="mc-cluster mc-' + bucket + '" ' +
|
||
'role="img" aria-label="' + ariaLabel + '">' +
|
||
'<b class="mc-count" aria-hidden="true">' + total + '</b>' +
|
||
'<div class="mc-pills" aria-hidden="true">' + pillsHtml + '</div>' +
|
||
'</div>';
|
||
var icon = L.divIcon({
|
||
html: html,
|
||
className: 'mc-cluster-wrap mc-' + bucket,
|
||
iconSize: L.point(48, 48),
|
||
});
|
||
// Stash a tooltip string for callers that want to bindTooltip (markercluster
|
||
// does not natively pipe this through, but it's available via cluster icon
|
||
// for E2E inspection).
|
||
icon._tooltip = ariaLabel;
|
||
return icon;
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.__meshcoreMapInternals = { createClusterGroup: createClusterGroup, makeClusterIcon: makeClusterIcon };
|
||
}
|
||
})();
|