Files
meshcore-analyzer/public/map.js
T
Kpa-clawbot 777f77a451 feat(#1420): dark-tile provider picker in customizer (4 variants) (#1430)
# feat(#1420): dark-tile provider picker in customizer (4 variants)

Closes #1420.

## What

Operator pick: don't force a single dark-tile choice on everyone. Wire 4
candidates into the customizer + server config so users can choose which
dark basemap they want, with per-browser persistence.

## Providers shipped

| ID | Source | Filter |
|---|---|---|
| `carto-dark` (default) |
`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none |
| `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked
layers) | none |
| `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg)
brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in
dark, cleared in light |
| `positron-inverted` | Carto Positron + same CSS invert | applied in
dark, cleared in light |

No new dependencies — all providers are URL-only.

## Architecture

- **`public/map-tile-providers.js`** — registry + 5 public helpers
(`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`,
`MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`,
`MC_applyTileFilter`). Persists to
`localStorage['mc-dark-tile-provider']`. Dispatches
`mc-tile-provider-changed` on user pick.
- **`public/map.js` / `public/live.js`** — resolve the active dark
provider via the registry, manage the Esri labels overlay lifecycle (add
when needed, remove cleanly so we don't leak layers on repeated theme
toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen
for both `data-theme` mutations AND `mc-tile-provider-changed`.
- **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the
Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps
re-render live without reload.
- **`public/roles.js`** — hydrates the server default via
`MC_setServerDefaultTileProvider` from `/api/config/client`.
- **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on
`Config` + surfaced in `ClientConfigResponse`. Default empty → client
uses `carto-dark`.
- **`config.example.json`** — documents the new field with all allowed
values.

## Behavior guarantees (from the acceptance criteria)

-  Light mode is **completely unchanged** — `_resolveTileUrl(false)`
short-circuits to `TILE_LIGHT` with no filter and no overlay logic.
-  Switching dark→light always clears the CSS filter, even if an
inverted provider remains selected (`MC_applyTileFilter` is called on
every theme change and early-returns to `style.filter = ''` when not
dark).
-  Switching light→dark with an inverted provider re-applies the
filter.
-  Attribution is updated per provider (Esri credit for Esri, CartoDB
credit for the others); the Leaflet attribution control is refreshed.
-  Esri uses two stacked layers (base + reference labels). The
reference layer is added/removed cleanly so repeat toggles do not leak.
-  Customizer change → immediate re-render, no reload. Uses the same
"live setting + persist + dispatch event" pattern as cb-presets (#1361).

## TDD

- Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile
provider registry (red)` — 6/7 assertions fail (stub only returns
nulls).
- Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4
variants wired into customizer` — 7/7 pass.

## Tests

`test-issue-1420-tile-providers.js` (wired into `test-all.sh` and
`.github/workflows/deploy.yml` JS-unit step):

```
── #1420 Dark-tile provider registry ──
   MC_TILE_PROVIDERS has all 4 IDs with url + attribution
   Inverted providers have non-null invertFilter; non-inverted have null
   MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed
   MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)
   MC_getDarkTileProvider falls back to server default, then carto-dark
   Apply filter for inverted provider in dark mode; clear when switching to non-inverted
   Light mode always clears the CSS filter even if inverted provider is selected
  7 passed, 0 failed
```

`cd cmd/server && go build ./... && go vet ./...` — clean.

## CDP verification

Not run in this PR — the sandbox does not have a Chrome CDP endpoint
reachable, and staging cannot exercise this code path until this branch
is deployed. The issue body's "CDP-verified candidate set" table covers
prior provider-URL validation; the new code path (registry lookup +
filter swap + Esri overlay lifecycle) is covered by the unit tests
above. **Recommend operator run a quick manual verification on staging
post-deploy:** dark mode → open customizer → cycle through all 4
providers, confirm tiles render and the CSS filter is applied for
`voyager-inverted` / `positron-inverted` (verify via
`getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`).

## Files touched

- `public/map-tile-providers.js` (new)
- `public/map.js`, `public/live.js`, `public/customize-v2.js`,
`public/roles.js`, `public/index.html`
- `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go`
- `config.example.json`
- `test-issue-1420-tile-providers.js` (new), `test-all.sh`,
`.github/workflows/deploy.yml`
- `.eslintrc.json` (register new `MC_*` globals)

---------

Co-authored-by: openclaw <bot@openclaw.local>
2026-05-27 14:37:51 +00:00

2094 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === 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.
// Note: `esc` is not a true global; some IIFEs define it locally. Reference
// through globalThis so the optional lookup is safe under `no-undef`.
const safeEsc = (typeof globalThis.esc === 'function') ? globalThis.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);
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
let _darkRefLayer = null; // Esri-only: labels overlay
function _resolveTileUrl(dark) {
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
const reg = window.MC_TILE_PROVIDERS || {};
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
const p = reg[id] || reg['carto-dark'] || {};
return {
url: p.url || p.baseUrl || TILE_DARK,
attribution: p.attribution || '© OpenStreetMap © CartoDB',
refUrl: p.refUrl || null
};
}
function _syncDarkTiles(dark) {
const r = _resolveTileUrl(dark);
tileLayer.setUrl(r.url);
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
// Esri reference (labels) overlay: add when needed, remove otherwise.
if (dark && r.refUrl) {
if (!_darkRefLayer) {
_darkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
} else {
_darkRefLayer.setUrl(r.refUrl);
}
} else if (_darkRefLayer) {
map.removeLayer(_darkRefLayer);
_darkRefLayer = null;
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
if (map.attributionControl) {
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
}
}
const _initTile = _resolveTileUrl(isDark);
const tileLayer = L.tileLayer(_initTile.url, {
attribution: _initTile.attribution,
maxZoom: 19,
}).addTo(map);
if (isDark && _initTile.refUrl) {
_darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(map);
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
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);
_syncDarkTiles(dark);
});
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #1420 — re-render when the user picks a different dark provider in the customizer.
window.addEventListener('mc-tile-provider-changed', function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
_syncDarkTiles(dark);
});
// 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);
// Exposed for the #1374 route renderer (window.MeshRoute) and its E2E tests.
if (typeof window !== 'undefined') {
window.__mc_map = map;
window.__mc_routeLayer = routeLayer;
// Expose nodes array for route-view's path-picker isolation
// (needs to resolve hop prefixes that aren't in the canonical path).
Object.defineProperty(window, '__mc_nodes', { get: function () { return nodes; }, configurable: true });
window.deconflictLabels = deconflictLabels;
}
// 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);
if (Array.isArray(parsed)) {
drawPacketRoute(parsed, null);
} else if (parsed.paths && parsed.paths.length > 0) {
drawPacketRouteMulti(parsed.paths, parsed.origin || null, {
packetHash: parsed.packetHash,
canonicalPath: parsed.hops || null,
destination: parsed.destination || null
});
} else {
drawPacketRoute(parsed.hops || [], parsed.origin || null, { destination: parsed.destination || null });
}
} catch {}
} else {
// #1418/#1419: deep-link via URL params — #/map?packet=<hash>&obs=<id>
// (or just packet=<hash> for first observation). Fetch from API, build
// the same payload as the sessionStorage flow, dispatch to renderer.
// This makes routes shareable / bookmarkable without sessionStorage state.
loadRouteFromDeepLink();
}
});
}
async function drawPacketRoute(hopKeys, origin, opts) {
// 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 };
}
opts = opts || {};
// #1422: use the backend's /api/resolve-hops for proper disambiguation
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
// Falls back to naive nodes.filter() scan if the API is unreachable.
let serverResolved = null;
try {
const hopList = (hopKeys || []).join(',');
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(hopList);
const resp = await fetch(apiUrl, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
serverResolved = json && json.resolved ? json.resolved : null;
}
} catch (e) {
console.warn('resolve-hops API call failed, falling back to local nodes scan', e);
}
// 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);
var container = map.getContainer();
var legend = container.querySelector('.mc-route-legend');
if (legend) legend.remove();
var ctx = container.querySelector('.mc-route-context-label');
if (ctx) ctx.remove();
});
return div;
};
closeBtn.addTo(map);
// Resolve hop short hashes to node positions with geographic disambiguation.
// Unresolvable hops (no matching node) become {resolved:false} sentinels
// so the modern renderer (#1374) can render dashed-gray placeholders + a
// "X of N hops resolved" badge instead of silently dropping them.
//
// #1418/#1422: PREFER serverResolved from /api/resolve-hops which does
// - unique_prefix matching (uses multi-byte adverts to disambiguate 1-byte hops)
// - gps_preference (skips nodes with lat=0 sentinel)
// - affinity scoring (neighbor-graph aware)
// Fall back to a naive local nodes.filter() scan when the API failed or
// the hop wasn't in the server response. When a node MATCHES by
// prefix/pubkey but has no GPS coords, we still want its name/role/pubkey
// for the sidebar — flag it as {resolved:false, gpsless:true} so the
// renderer can label it "📍 no GPS" instead of "unresolved prefix".
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
// Try server resolution first
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true };
}
// Server resolved but node has no usable GPS
return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true };
}
// Fallback: naive local scan (kept for resilience when API is down).
const allMatches = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
});
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length === 1) {
const c = withGps[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 (withGps.length > 1) {
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
} else if (allMatches.length >= 1) {
const c = allMatches[0];
return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true };
}
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
});
// 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 != null);
// Resolve and prepend origin node
if (origin) {
let originPos = null;
const originHasRealGps = (lat, lon) => lat != null && lon != null && !(lat === 0 && lon === 0);
if (originHasRealGps(origin.lat, origin.lon)) {
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true, _fromPayload: 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) {
if (originHasRealGps(match.lat, match.lon)) {
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: true, isOrigin: true, _fromPayload: true };
} else {
originPos = { name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: false, gpsless: true, isOrigin: true, _fromPayload: true };
}
}
}
if (originPos) positions.unshift(originPos);
}
// #1418 Phase D: append destination (recipient from decoded.destHash) so
// the route view shows: sender → [intermediate hops] → recipient.
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
// — show the node in the sidebar as gpsless rather than drawing it at
// (0,0) which would force fitBounds to span the visible route → Africa.
// _fromPayload: true so the renderer can visually mark these as "from
// payload" (different source-of-truth than path hops).
if (opts.destination && opts.destination.pubkey) {
const dpk = opts.destination.pubkey.toLowerCase();
const dmatch = nodes.find(n => n.public_key.toLowerCase() === dpk || n.public_key.toLowerCase().startsWith(dpk));
if (dmatch) {
const hasGps = dmatch.lat != null && dmatch.lon != null && !(dmatch.lat === 0 && dmatch.lon === 0);
if (hasGps) {
positions.push({ lat: dmatch.lat, lon: dmatch.lon, name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: true, _fromPayload: true });
} else {
positions.push({ name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: false, gpsless: true, _fromPayload: true });
}
}
}
if (positions.length < 1) return;
// Mark final hop as destination so the renderer applies the dest glyph.
positions[positions.length - 1].isDest = true;
// Hand off to sequence-primary sequence-primary renderer (#1418), falling
// back to the legacy role-aware MeshRoute (#1374), then to the minimal
// polyline (should never run in production).
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
window.MeshRouteView.render(map, routeLayer, positions, {
timestamp: opts.timestamp || Date.now(),
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
packetContext: opts.packetContext || null
});
return;
}
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
window.MeshRoute.render(map, routeLayer, positions, {
timestamp: opts.timestamp || Date.now()
});
return;
}
// ── Legacy fallback (kept tiny — should never run in production) ─────
const coords = positions.filter(p => p.lat != null).map(p => [p.lat, p.lon]);
if (coords.length >= 2) {
L.polyline(coords, { color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4' }).addTo(routeLayer);
map.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
map.setView(coords[0], 13);
}
}
// #1418 Phase C — multi-path renderer. Accepts an array of paths (each =
// {path: [hopKeys], observer, snr, rssi}), aggregates into canonical hops +
// per-edge observer-count (for stroke-width weighting), and dispatches to
// the sequence renderer with multi-path metadata. Falls back to single-path
// drawPacketRoute when only one observation is provided.
//
// opts.canonicalPath (optional): use this exact hop sequence as the canonical
// spine — i.e. the observation the operator selected. Without it, longest-path
// wins, which can show a totally different route than the user clicked.
async function drawPacketRouteMulti(paths, origin, opts) {
opts = opts || {};
if (typeof origin === 'string') origin = { pubkey: origin };
if (!Array.isArray(paths) || paths.length === 0) return;
if (paths.length === 1) {
return drawPacketRoute(paths[0].path || [], origin, opts);
}
// Pick canonical: prefer caller-supplied (operator's chosen observation),
// else fall back to longest path as the spine.
var canonicalPath;
if (opts.canonicalPath && Array.isArray(opts.canonicalPath) && opts.canonicalPath.length) {
canonicalPath = opts.canonicalPath;
} else {
const sortedByLen = paths.slice().sort((a, b) => (b.path || []).length - (a.path || []).length);
canonicalPath = sortedByLen[0].path || [];
}
const totalObservers = paths.length;
// Count hop & edge occurrences across all paths.
const hopCounts = {};
const edgeCounts = {};
paths.forEach(p => {
const hops = p.path || [];
hops.forEach(h => { hopCounts[h] = (hopCounts[h] || 0) + 1; });
for (let i = 0; i < hops.length - 1; i++) {
const key = hops[i] + '\u2192' + hops[i + 1];
edgeCounts[key] = (edgeCounts[key] || 0) + 1;
}
});
// Resolve canonical hops via /api/resolve-hops + naive fallback.
let serverResolved = null;
try {
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(canonicalPath.join(','));
const resp = await fetch(apiUrl, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
serverResolved = json && json.resolved ? json.resolved : null;
}
} catch (e) {}
const raw = canonicalPath.map(hop => {
const hopLower = String(hop).toLowerCase();
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
// hopCounts is keyed on the SHORT prefix from observation paths (e.g. "37"),
// but canonicalPath may carry full pubkeys when the operator's selected
// observation went through the resolver. Compute hop coverage by checking
// EVERY path[]: a hop in canonicalPath is "covered" by a path if the path
// contains an entry that is either an exact match OR a prefix of the hop's
// full pubkey.
let coverage = 0;
const hopFullKey = (srv && srv.pubkey) ? srv.pubkey.toLowerCase() : hopLower;
paths.forEach(pth => {
const phops = (pth.path || []).map(h => String(h).toLowerCase());
const matches = phops.some(ph => ph === hopFullKey || hopFullKey.startsWith(ph) || ph.startsWith(hopFullKey));
if (matches) coverage++;
});
const obsInfo = { observerCount: coverage || (hopCounts[hop] || 1), observerTotal: totalObservers };
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
return Object.assign({ lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true }, obsInfo);
}
return Object.assign({ name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true }, obsInfo);
}
const allMatches = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
});
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length >= 1) {
const c = withGps[0];
return Object.assign({ lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true }, obsInfo);
}
if (allMatches.length >= 1) {
const c = allMatches[0];
return Object.assign({ name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true }, obsInfo);
}
return Object.assign({ name: String(hop).slice(0,8), pubkey: hop, resolved: false }, obsInfo);
});
if (raw.length < 1) return;
if (origin && origin.pubkey) {
const op = nodes.find(n => n.public_key.toLowerCase() === origin.pubkey.toLowerCase() ||
n.public_key.toLowerCase().startsWith(origin.pubkey.toLowerCase()));
if (op) {
const hasGps = op.lat != null && op.lon != null && !(op.lat === 0 && op.lon === 0);
if (hasGps) {
raw.unshift({ lat: op.lat, lon: op.lon, name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
} else {
raw.unshift({ name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: false, gpsless: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
}
} else if (origin.name) {
raw.unshift({ name: origin.name, pubkey: origin.pubkey, role: 'companion', resolved: false, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
}
}
// #1418 Phase D: append destination (recipient from decoded.destHash) so
// the route view shows: sender → [intermediate hops] → recipient.
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
// — show the node in the sidebar as gpsless rather than drawing it at
// (0,0) which would force fitBounds to span SF→Africa.
if (opts.destination && opts.destination.pubkey) {
const dp = nodes.find(n => n.public_key.toLowerCase() === opts.destination.pubkey.toLowerCase() ||
n.public_key.toLowerCase().startsWith(opts.destination.pubkey.toLowerCase()));
if (dp) {
const hasGps = dp.lat != null && dp.lon != null && !(dp.lat === 0 && dp.lon === 0);
if (hasGps) {
raw.push({ lat: dp.lat, lon: dp.lon, name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: true, observerCount: totalObservers, observerTotal: totalObservers });
} else {
raw.push({ name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: false, gpsless: true, observerCount: totalObservers, observerTotal: totalObservers });
}
}
}
raw[raw.length - 1].isDest = true;
if (raw[0]) raw[0].isOrigin = true;
if (routeLayer) routeLayer.clearLayers();
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
window.MeshRouteView.render(map, routeLayer, raw, {
timestamp: opts.timestamp || Date.now(),
multiPath: true,
totalObservers: totalObservers,
edgeCounts: edgeCounts,
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
allPaths: paths,
packetContext: opts.packetContext || null
});
}
}
window.drawPacketRouteMulti = drawPacketRouteMulti;
// #1418/#1419: deep-link loader. Reads URL params
// #/map?packet=<hash>&obs=<observation_id>
// fetches the packet+observations from /api/packets/<hash>, picks the
// selected observation as canonical, and dispatches the same payload that
// the packets-page 'View on map' button would have set in sessionStorage.
// Without an obs param, the first observation is used.
async function loadRouteFromDeepLink() {
try {
const hash = location.hash || '';
const qs = hash.split('?')[1];
if (!qs) return;
const params = new URLSearchParams(qs);
const packetHash = params.get('packet');
const obsId = params.get('obs');
if (!packetHash) return;
// Wait for nodes to load (drawPacketRoute / Multi rely on `nodes` array
// for the local-fallback resolver).
if (!nodes || !nodes.length) {
await new Promise(r => setTimeout(r, 600));
}
const resp = await fetch('/api/packets/' + encodeURIComponent(packetHash));
if (!resp.ok) {
console.warn('[deep-link] /api/packets/' + packetHash + ' returned ' + resp.status);
return;
}
const data = await resp.json();
const pkt = data.packet || data;
const observations = data.observations || pkt.observations || [];
if (!observations.length) return;
// Pick the user-chosen observation by id, fall back to first
let chosen = null;
if (obsId) chosen = observations.find(o => String(o.id) === String(obsId));
if (!chosen) chosen = observations[0];
// Parse decoded for src/dst.
// Try observation first, fall back to packet-level decoded_json (GRP_TXT
// / TRACE packets often have channel + content at the packet level, not
// per-observation).
let decoded = {};
try {
const obsDec = JSON.parse(chosen.decoded_json || '{}');
const pktDec = JSON.parse(pkt.decoded_json || '{}');
decoded = Object.keys(obsDec).length ? obsDec : pktDec;
// If observation has some fields but missing channel/text, merge from packet.
if (decoded === obsDec && pktDec.channel) {
decoded = Object.assign({}, pktDec, obsDec);
}
} catch (_) {}
const origin = {};
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
const destination = decoded.destHash ? { pubkey: decoded.destHash } : null;
// Resolve the chosen observation's hops (canonical path).
// Priority: 1) server-side `resolved_path` (authoritative, eyeball-
// validated against packet detail), 2) client-side HopResolver (used
// by packets.js — does observer-IATA-aware geographic disambiguation),
// 3) raw prefixes (worst case, leaves naive lookup to drawPacketRoute).
let chosenPath = [];
let rawHops = [];
try { rawHops = JSON.parse(chosen.path_json || '[]'); } catch (_) {}
let resolvedHops = null;
try {
if (chosen.resolved_path) {
resolvedHops = typeof chosen.resolved_path === 'string' ? JSON.parse(chosen.resolved_path) : chosen.resolved_path;
}
} catch (_) {}
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
chosenPath = rawHops.map((h, i) => resolvedHops[i] || h);
} else if (window.HopResolver && typeof window.HopResolver.resolve === 'function' && rawHops.length) {
// Use the SAME resolver the packets page uses, so route view and
// packet detail agree on hop identities.
try {
// Sender + observer hints help disambiguation
const senderLat = decoded.lat || decoded.latitude || null;
const senderLon = decoded.lon || decoded.longitude || null;
let obsLat = null, obsLon = null;
// Try to find observer's coords for geographic affinity scoring
if (chosen.observer_id && Array.isArray(nodes)) {
const obs = nodes.find(n => (n.public_key || '').toLowerCase() === String(chosen.observer_id).toLowerCase());
if (obs && obs.lat != null && obs.lon != null) { obsLat = obs.lat; obsLon = obs.lon; }
}
// HopResolver.init may already have been done by packets.js; if not,
// do a minimal init from window data we have.
if (!window.HopResolver.ready || !window.HopResolver.ready()) {
try {
window.HopResolver.init(nodes || [], { observers: [], iataCoords: {} });
} catch (_) {}
}
const resolveResult = window.HopResolver.resolve(rawHops, senderLat, senderLon, obsLat, obsLon, chosen.observer_id);
chosenPath = rawHops.map(h => {
const r = resolveResult ? resolveResult[h] : null;
return r && r.pubkey ? r.pubkey : h;
});
} catch (_) {
chosenPath = rawHops;
}
} else {
chosenPath = rawHops;
}
// All observation paths for multi-path stroke weighting
const allPaths = observations.map(o => {
let p = [];
try { p = JSON.parse(o.path_json || '[]'); } catch (_) {}
return { path: p, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
}).filter(p => p.path && p.path.length > 0);
// #1418 Phase Y: derive packet context for the sidebar fact-list.
// type comes from decoded.type or pkt.payload_type. Resolve src/dst
// names from the nodes table when possible.
function resolveNameByHash(h) {
if (!h || !nodes || !nodes.length) return null;
const hL = String(h).toLowerCase();
const m = nodes.find(n => n.public_key.toLowerCase().startsWith(hL));
return m ? m.name : null;
}
// Map payload_type byte → string. Source: cmd/ingestor/decoder.go.
// 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT,
// 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE, 10=MULTIPART,
// 11=CONTROL, 12=RAW_CUSTOM.
const PAYLOAD_TYPE_MAP = {
0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT',
5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH',
9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 12: 'RAW_CUSTOM'
};
const inferredType = decoded.type || PAYLOAD_TYPE_MAP[pkt.payload_type] || 'OTHER';
// Try to peek at raw_hex bytes to extract src/destHash when decoded is empty.
// TXT_MSG/REQ/RESPONSE/ANON_REQ all have the same wire layout:
// byte0=route+type, byte1=path_len, then path bytes, then destHash + srcHash + encrypted body.
// ANON_REQ doesn't have a srcHash byte (sender is anonymous), only destHash.
let inferredSrc = decoded.srcHash || null;
let inferredDst = decoded.destHash || null;
const TYPES_WITH_DST_SRC = [1, 2, 7, 8]; // RESPONSE=1, TXT_MSG=2, ANON_REQ=7, PATH=8 — all carry hashes
if ((!inferredSrc || !inferredDst) && chosen.raw_hex && TYPES_WITH_DST_SRC.indexOf(pkt.payload_type) >= 0) {
try {
const hex = chosen.raw_hex;
// MeshCore wire max for path length is 64 hops. Cap defensively:
// a crafted ingest packet with pathLen=200 would slice random body
// bytes into the srcHash/destHash UI fields. Guard before use.
const pathLen = parseInt(hex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
throw new Error('pathLen out of range: ' + pathLen);
}
const destOff = 4 + pathLen * 2;
if (hex.length >= destOff + 2) {
inferredDst = inferredDst || hex.slice(destOff, destOff + 2).toUpperCase();
// ANON_REQ has no srcHash
if (pkt.payload_type !== 7 && hex.length >= destOff + 4) {
inferredSrc = inferredSrc || hex.slice(destOff + 2, destOff + 4).toUpperCase();
}
}
} catch (_) {}
}
// GRP_TXT: channel_hash byte sits right after path bytes in raw_hex.
// Layout: byte0=route+type, byte1=path_len, path bytes, channel_hash, encrypted body.
let inferredChannelHash = decoded.channelHashHex || null;
if (!inferredChannelHash && chosen.raw_hex && pkt.payload_type === 5) {
try {
const hex = chosen.raw_hex;
const pathLen = parseInt(hex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
throw new Error('pathLen out of range: ' + pathLen);
}
const chOff = 4 + pathLen * 2;
if (hex.length >= chOff + 2) {
inferredChannelHash = hex.slice(chOff, chOff + 2).toUpperCase();
}
} catch (_) {}
}
const pktCtx = {
type: inferredType,
decoded: Object.assign({}, decoded, { srcHash: inferredSrc, destHash: inferredDst, channelHashHex: inferredChannelHash || decoded.channelHashHex }),
payloadType: pkt.payload_type || null,
srcResolvedName: inferredSrc ? resolveNameByHash(inferredSrc) : null,
destResolvedName: inferredDst ? resolveNameByHash(inferredDst) : null,
observedHops: chosenPath.length,
observationCount: observations.length
};
// GRP_TXT: try to resolve the channel name from /api/channels.
if (inferredType === 'GRP_TXT' && inferredChannelHash) {
try {
const chResp = await fetch('/api/channels?includeEncrypted=true');
if (chResp.ok) {
const chData = await chResp.json();
const chList = chData.channels || [];
const wantUp = inferredChannelHash.toUpperCase();
// Match by:
// 1) hash field starts with target (full hex hash)
// 2) hash == "enc_<HEX>" (no-key fallback channels)
// 3) name contains "0x<HEX>" (encrypted placeholder)
// 4) for keyed channels: compute SHA256(name)[0] === target byte
// (browser SubtleCrypto — async)
let match = chList.find(c => {
const ch = String(c.hash || '').toUpperCase();
const nm = String(c.name || '').toUpperCase();
return ch.startsWith(wantUp) ||
ch === 'ENC_' + wantUp ||
nm.includes('0X' + wantUp);
});
// If not matched and we have SubtleCrypto, try SHA256 lookup
if (!match && window.crypto && window.crypto.subtle) {
for (const c of chList) {
if (c.encrypted) continue; // skip the enc_ placeholders
const name = c.name || '';
if (!name) continue;
try {
const buf = new TextEncoder().encode(name);
const hashBuf = await window.crypto.subtle.digest('SHA-256', buf);
const arr = new Uint8Array(hashBuf);
const byteHex = arr[0].toString(16).padStart(2, '0').toUpperCase();
if (byteHex === wantUp) { match = c; break; }
} catch (_) {}
}
}
if (match) {
const isEnc = !!match.encrypted || /^enc_/i.test(match.hash || '');
pktCtx.channelName = isEnc ? ('Encrypted (0x' + inferredChannelHash + ')') : (match.name || '#' + inferredChannelHash);
pktCtx.channelEncrypted = isEnc;
if (match.lastMessage && match.lastMessage !== 'Encrypted — click to decrypt') {
pktCtx.channelLastMessage = match.lastMessage;
}
}
}
} catch (_) {}
if (!pktCtx.channelName) {
pktCtx.channelName = 'channel 0x' + inferredChannelHash;
}
// Fetch decrypted text for this specific packet (only if not encrypted-only)
if (!pktCtx.channelEncrypted) {
try {
const cleanName = (pktCtx.channelName || '').replace(/^#/, '');
const msgResp = await fetch('/api/channels/' + encodeURIComponent(cleanName) + '/messages?limit=10');
if (msgResp.ok) {
const msgs = await msgResp.json();
const m = (msgs.messages || []).find(mm => mm.packet_hash === packetHash || mm.hash === packetHash);
if (m && (m.text || m.plainText)) pktCtx.decryptedText = m.text || m.plainText;
}
} catch (_) {}
}
}
if (allPaths.length === 0) {
// Polish review (doshi #1423): operator clicked a ?packet=<hash> URL
// but every observation had an empty path. Previously this bailed
// silently (map stayed where it was, no message). Surface a console
// breadcrumb + a brief toast so the operator knows why nothing
// rendered.
console.warn('[deep-link] packet ' + packetHash + ' has no observed route data (all observations had empty path)');
try {
var toast = document.createElement('div');
toast.className = 'mc-rt-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);' +
'background:var(--mc-bg-secondary,#1a1a1a);color:var(--mc-text-primary,#e5e5e5);' +
'padding:10px 16px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);' +
'z-index:10000;font:13px/1.4 system-ui,sans-serif;max-width:80vw;text-align:center;';
toast.textContent = 'Packet has no observed route data.';
document.body.appendChild(toast);
setTimeout(function () { try { toast.remove(); } catch (_) {} }, 4000);
} catch (e) { console.warn('[deep-link] toast failed:', e); }
return;
}
if (allPaths.length === 1) {
drawPacketRoute(chosenPath, origin, { destination: destination, packetHash: packetHash, observationId: obsId, packetContext: pktCtx });
} else {
drawPacketRouteMulti(allPaths, origin, {
packetHash: packetHash,
observationId: obsId,
canonicalPath: chosenPath,
destination: destination,
packetContext: pktCtx
});
}
} catch (e) {
console.warn('[deep-link] route load failed', e);
}
}
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).
// #1407: pill text COLOR now comes from --mc-role-X-text (set by
// cb-presets.js + style.css :root defaults), paired per-bg to clear
// WCAG 1.4.3 AA on every preset (achromat / trit needed the flip).
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 ROLE_TEXT_VAR = {
repeater: 'var(--mc-role-repeater-text, #1a1a1a)',
companion: 'var(--mc-role-companion-text, #1a1a1a)',
room: 'var(--mc-role-room-text, #1a1a1a)',
sensor: 'var(--mc-role-sensor-text, #1a1a1a)',
observer: 'var(--mc-role-observer-text, #1a1a1a)',
};
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 fg = ROLE_TEXT_VAR[role] || 'var(--mc-role-companion-text, #1a1a1a)';
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:' + fg + '" ' +
'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 };
}
})();