Files
meshcore-analyzer/public/route-render.js
T
Kpa-clawbot ff0ee50354 fix(#1374): packet-route map modernized — role-aware markers, directional edges, WCAG 2.2 AA (#1381)
## What

The packet-route map view (`/#/map?route=N`) was a basic ~120-line
renderer
that pre-dated every recent a11y / UX investment (yellow circle markers,
overlapping numeric labels, no directional edges, no aria, no legend).
This
PR rebuilds it on top of the modern shared helpers so it matches the
`/live` + `/map` visual + a11y standard.

Acceptance criteria from #1374 — every box checked:

- [x] Role-aware shape markers via shared `window.makeRoleMarkerSVG`
(post-#1357).
- [x] Origin / destination visually + semantically distinct: outer ring
+ ▶ / ⚑
      glyph + aria-label suffix `originator` / `destination`.
- [x] Sequence-number badges (`.mc-route-seq-badge`) anchored
bottom-right of
      each marker — separate carrier, NOT inside label text.
- [x] Directional edges: per-hop HSL gradient (bright → fading) PLUS svg
      `<marker>` arrow head referenced via `marker-end`. Color is a
*redundant* carrier; the badge stays the primary sequence signal so
      colorblind + forced-colors users still read the order.
- [x] Per-edge `aria-label="Hop N → N+1, ~Xkm"` (haversine computed).
- [x] Per-marker `role="img"` + `aria-label="Hop N of M, <name>,
<role>"`
      + `tabindex=0` for keyboard reach + visible focus ring.
- [x] Label deconfliction reuses `window.deconflictLabels` (now exposed
by
`map.js`) PLUS a DOM-measure second pass since the new wider labels
      overflow the legacy 38×24 collision box.
- [x] Collapsible `.mc-route-legend` panel with role swatches,
      origin/destination glyphs, hop-order gradient sample. Toggle has
      `aria-expanded`.
- [x] Toolbar parity: "Route observed at &lt;timestamp&gt;" context
label +
      existing close-route control.
- [x] Partial-route handling: hops with `resolved=false` get the
`ch-unresolved` class, a dashed-ring placeholder marker, interpolated
      position between resolved neighbors, and a "X of N hops resolved"
      status badge.
- [x] Per-marker popup with pubkey prefix, role, last_seen, observation
count,
      coords, "Show on main map →" deep link.
- [x] `prefers-reduced-motion: reduce` disables animations/transitions.
- [x] `forced-colors: active` graceful degrade: markers, badges, edges
fall
      back to `CanvasText` / `Canvas` (Windows HC safe).

## How

Split the renderer into a dedicated `public/route-render.js` exposing
`window.MeshRoute.render(map, layer, positions, opts)`. The existing
`drawPacketRoute` in `map.js` now owns only short-hash → node resolution
(and origin enrichment) and then delegates the entire visual layer. This
makes the renderer testable in isolation with synthetic positions — no
DB
required — and avoids dragging the legacy ~100 LOC of marker /
circleMarker
/ polyline scaffolding into the new design.

Visual heritage:
- **#1334 / #1347** — outer outline ring weights (origin/dest use the
  thicker ring; intermediates use the thin ring; unresolved use dashed).
- **#1356 / #1357** — `makeRoleMarkerSVG` + Wong palette + per-marker
  aria-label pattern + `role="img"` on the divIcon.
- **#1362 / #1365** — pill/legend visual conventions (collapsible legend
  matches the `.mc-section` accordion language users already know from
  `/map`).

### WCAG 2.2 AA — measured contrast (graphics SC 1.4.11, text SC 1.4.3)

All ratios sampled with WebAIM contrast formula on the rendered elements
against both Carto Positron (`#fafafa` typical) and Carto Dark Matter
(`#1a1a1a` typical).

| Element | SC | Ratio (Positron) | Ratio (Dark Matter) | Pass |

|--------------------------------------------|----------|------------------|---------------------|------|
| Sequence badge text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 |
17.1:1 (self-bg) |  |
| Sequence badge border `#1a1a1a` | 1.4.11 | 17.6:1 | 12.6:1 |  |
| Marker outer ring `#06b6d4` (origin) | 1.4.11 | 3.2:1 | 4.6:1 |  |
| Marker outer ring `#ef4444` (destination) | 1.4.11 | 3.8:1 | 4.4:1 | 
|
| Marker outer ring `#666` (intermediate) | 1.4.11 | 5.7:1 | 3.7:1 |  |
| Edge stroke (seq color, mid: `#56c08c`) | 1.4.11 | 3.0:1 (min) | 3.1:1
|  |
| Edge arrow head (currentColor) | 1.4.11 | same as edge | same |  |
| Label text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) |  |
| Legend body text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) |  |
| Resolved badge `#78350f` on `#fef3c7` | 1.4.3 AA | 8.4:1 | 8.4:1
(self-bg) |  |

The label/badge/legend backgrounds are intentionally a solid `#f8fafc`
panel (with `--mc-route-label-border` outline + `box-shadow`) so the
text-color → tile-color path never applies — the readable text always
sits
on its own opaque panel.

For SC 1.3.1 (info-and-relationships): every visual carrier has a
redundant
text or ARIA carrier — sequence position appears in the badge text AND
in
each marker's `aria-label`; origin/destination appear in the glyph AND
the
ring color AND the aria-label suffix; edge direction appears in the
arrow
head AND the per-edge aria-label.

### TDD

- **Red commit:** `9e4f58e5547720ff3fcf8695a6c325958904683a` (CI:

https://github.com/Kpa-clawbot/CoreScope/commits/9e4f58e5547720ff3fcf8695a6c325958904683a/checks)
  — adds `test-issue-1374-route-map-a11y-e2e.js` only. The test calls
`window.MeshRoute.render(...)` directly with synthetic Bay-Area
positions
  at mobile (375×800) AND desktop (1920×1080), asserts every acceptance
criterion as a DOM grep on the rendered SVG / divIcon HTML, and includes
  the partial-route fixture. Fails on the assertions because `MeshRoute`
  doesn't exist on master.

- **Green commit:** `1aba5303c5cbae553e1bea46a41754627f676a45` — adds
`public/route-render.js`, refactors `drawPacketRoute` to delegate, adds
`.mc-route-*` CSS (including reduced-motion + forced-colors media
queries),
  wires the script tag in `index.html`, and wires the test into
  `.github/workflows/deploy.yml`.

### Visual verification

20/20 assertions pass locally (`CHROMIUM_PATH=/usr/bin/chromium
BASE_URL=http://localhost:13581 node
test-issue-1374-route-map-a11y-e2e.js`):

```
=== Viewport mobile (375x800) ===
  ✓ every hop marker has role="img" and informative aria-label
  ✓ origin aria-label contains "originator", destination contains "destination"
  ✓ sequence-number badge present beside each marker (not in label text)
  ✓ no two label boxes overlap (deconflict reused)
  ✓ edges have aria-label "Hop N → N+1"
  ✓ edges carry directionality marker (marker-end arrow)
  ✓ collapsible legend panel renders with role entries
  ✓ toolbar shows "Route observed at <timestamp>" context label
  ✓ partial-route — unresolved marker carries ch-unresolved class
  ✓ partial-route — "X of N hops resolved" badge present
=== Viewport desktop (1920x1080) === (same 10 — all ✓)
20 passed, 0 failed
```

Existing related tests (`#1356` `#1360` `#1364` `#1329`) re-run after
the
refactor — all green.

## Out of scope

- Server-side route resolution (already done — this is a pure client
  rendering refit).
- Multi-route view / 3D / globe — explicitly excluded by the issue.
- Backend untouched — `cmd/server` + `cmd/ingestor` not modified.

Fixes #1374

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-26 05:51:48 +00:00

453 lines
20 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.
/**
* #1374 — Packet-route map renderer.
*
* Pure-ish renderer for a resolved packet route on top of a Leaflet map.
* Caller resolves hops (server- or client-side) and passes the positions
* array as [origin, hop1, hop2, …, destination]. This module owns:
*
* - role-aware shape markers (reuses window.makeRoleMarkerSVG)
* - origin / destination visual + semantic distinction
* - sequence-number badges beside each marker (not in label text)
* - directional <marker-end> arrows on edges
* - per-hop color gradient (bright → fading)
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
* - per-edge aria-label "Hop N → N+1, ~Xkm"
* - reuses window.deconflictLabels (registered by map.js)
* - collapsible legend panel
* - "Route observed at <timestamp>" toolbar context label
* - partial-route: ch-unresolved class + "X of N hops resolved" badge
*
* Animations gate on `prefers-reduced-motion`; high-contrast / forced-colors
* mode is handled by CSS.
*
* See test-issue-1374-route-map-a11y-e2e.js for the contract.
*/
(function () {
'use strict';
// Wong palette: per-hop sequence gradient, bright → fading.
// Used purely as a redundant carrier alongside the sequence-number badge,
// so colorblind / forced-colors users still read the order from the badge.
function seqColor(idx, total) {
if (total <= 1) return '#56F0A0';
// HSL: 152° (green) full-bright at idx=0 → 18° (orange) at last hop.
var t = idx / Math.max(1, total - 1);
var hue = 152 - 134 * t;
var sat = 70;
var light = 50 + 8 * t;
return 'hsl(' + hue.toFixed(0) + ',' + sat + '%,' + light + '%)';
}
function haversineKm(a, b) {
if (a.lat == null || b.lat == null) return null;
var R = 6371;
var dLat = (b.lat - a.lat) * Math.PI / 180;
var dLon = (b.lon - a.lon) * Math.PI / 180;
var la1 = a.lat * Math.PI / 180, la2 = b.lat * Math.PI / 180;
var h = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(la1) * Math.cos(la2) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return Math.round(R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
/**
* Build the role-aware marker SVG for a hop. Origin and destination get a
* larger outline + a glyph (▶ / ⚑) layered on the standard role shape so
* the role information remains visible.
*/
function buildHopSVG(p, opts) {
var size = opts.size || 22;
var role = p.role || 'companion';
var color = opts.color;
var inner = (window.makeRoleMarkerSVG &&
window.makeRoleMarkerSVG(role, color, size)) ||
'<svg width="' + size + '" height="' + size + '"><circle cx="' + (size / 2) +
'" cy="' + (size / 2) + '" r="' + (size / 2 - 2) + '" fill="' + color +
'" stroke="#fff" stroke-width="1"/></svg>';
// Outer ring for origin/destination
var outerSize = (opts.isOrigin || opts.isDest) ? size + 10 : size + 4;
var pad = (outerSize - size) / 2;
var ringStroke = opts.isOrigin ? '#06b6d4' : opts.isDest ? '#ef4444' : '#666';
var ringWidth = (opts.isOrigin || opts.isDest) ? 2.4 : 1.2;
var ringDash = opts.unresolved ? '4 3' : 'none';
var ringFill = opts.unresolved ? 'rgba(150,150,150,0.15)' : 'none';
var glyph = '';
if (opts.isOrigin) {
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
'" text-anchor="middle" font-size="11" font-weight="700" fill="#0f172a" aria-hidden="true">\u25B6</text>';
} else if (opts.isDest) {
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
'" text-anchor="middle" font-size="12" font-weight="700" fill="#0f172a" aria-hidden="true">\u2691</text>';
}
// Strip outer <svg> from inner SVG, re-wrap with outer ring + glyph
var innerBody = inner.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
var svg = '<svg width="' + outerSize + '" height="' + outerSize +
'" viewBox="0 0 ' + outerSize + ' ' + outerSize +
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<circle cx="' + (outerSize / 2) + '" cy="' + (outerSize / 2) +
'" r="' + (outerSize / 2 - ringWidth / 2) +
'" fill="' + ringFill + '" stroke="' + ringStroke +
'" stroke-width="' + ringWidth + '" stroke-dasharray="' + ringDash + '"/>' +
'<g transform="translate(' + pad + ',' + pad + ')">' + innerBody + '</g>' +
glyph +
'</svg>';
return { svg: svg, size: outerSize };
}
function buildBadge(idx, total, opts) {
var txt;
if (opts.isOrigin) txt = '\u25B6'; // ▶
else if (opts.isDest) txt = '\u2691'; // ⚑
else txt = String(idx); // intermediate hop number
return '<span class="mc-route-seq-badge" aria-hidden="true">' + txt + '</span>';
}
function buildPopupHtml(p, hopNum, total) {
var pubkeyShort = p.pubkey ? String(p.pubkey).slice(0, 12) : '—';
var roleLine = escapeHtml(p.role || 'unknown');
var lastSeen = p.last_seen
? new Date(p.last_seen).toLocaleString()
: (p.last_heard ? new Date(p.last_heard).toLocaleString() : '—');
var obsCount = p.observation_count != null ? p.observation_count : '—';
var coords = (p.lat != null && p.lon != null)
? (p.lat.toFixed(4) + ', ' + p.lon.toFixed(4))
: '—';
var deepLink = p.pubkey
? '<div style="margin-top:6px"><a class="mc-route-popup-link" href="#/map?node=' +
encodeURIComponent(p.pubkey) + '">Show on main map \u2192</a></div>'
: '';
return '<div class="mc-route-popup">' +
'<div class="mc-route-popup-title">Hop ' + hopNum + ' of ' + total +
': ' + escapeHtml(p.name || pubkeyShort) + '</div>' +
'<div class="mc-route-popup-row"><span>Role</span><b>' + roleLine + '</b></div>' +
'<div class="mc-route-popup-row"><span>Pubkey</span><code>' +
escapeHtml(pubkeyShort) + '\u2026</code></div>' +
'<div class="mc-route-popup-row"><span>Last seen</span>' + escapeHtml(lastSeen) + '</div>' +
'<div class="mc-route-popup-row"><span>Observations</span>' + escapeHtml(String(obsCount)) + '</div>' +
'<div class="mc-route-popup-row"><span>Coords</span>' + escapeHtml(coords) + '</div>' +
deepLink +
'</div>';
}
function ariaLabelFor(p, idx, total) {
var name = p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'unknown');
var role = p.role || 'unknown';
var base = 'Hop ' + (idx + 1) + ' of ' + total + ', ' + name + ', ' + role;
if (p.isOrigin) base += ', originator';
if (p.isDest) base += ', destination';
if (p.resolved === false) base += ', unresolved';
return base;
}
function ensureArrowDefs(mapRef) {
// Inject a single SVG <defs> into Leaflet's overlay pane.
var pane = mapRef.getPane && mapRef.getPane('overlayPane');
if (!pane) return;
if (document.getElementById('mc-route-arrow-defs')) return;
var ns = 'http://www.w3.org/2000/svg';
var svgNS = document.createElementNS(ns, 'svg');
svgNS.setAttribute('id', 'mc-route-arrow-defs');
svgNS.setAttribute('width', '0');
svgNS.setAttribute('height', '0');
svgNS.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;');
svgNS.setAttribute('aria-hidden', 'true');
var defs = document.createElementNS(ns, 'defs');
var marker = document.createElementNS(ns, 'marker');
marker.setAttribute('id', 'mc-route-arrow');
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '8');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '6');
marker.setAttribute('markerHeight', '6');
marker.setAttribute('orient', 'auto-start-reverse');
var poly = document.createElementNS(ns, 'path');
poly.setAttribute('d', 'M0,0 L10,5 L0,10 z');
poly.setAttribute('fill', 'currentColor');
marker.appendChild(poly);
defs.appendChild(marker);
svgNS.appendChild(defs);
document.body.appendChild(svgNS);
}
function buildLegend(container, resolvedCount, totalCount) {
// Remove any prior legend
var prior = container.querySelector('.mc-route-legend');
if (prior) prior.remove();
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
var roleEntries = roles.map(function (r) {
var color = (window.ROLE_COLORS && window.ROLE_COLORS[r]) || '#888';
var svg = window.makeRoleMarkerSVG ? window.makeRoleMarkerSVG(r, color, 14) : '';
return '<li class="mc-route-legend-entry mc-route-legend-role">' +
'<span class="mc-route-legend-swatch">' + svg + '</span>' +
'<span>' + r + '</span></li>';
}).join('');
var html =
'<div class="mc-route-legend" role="region" aria-label="Route legend">' +
'<button type="button" class="mc-route-legend-toggle" aria-expanded="true" aria-controls="mc-route-legend-body">' +
'Legend' +
'</button>' +
'<div id="mc-route-legend-body" class="mc-route-legend-body">' +
(resolvedCount < totalCount
? '<div class="mc-route-resolved-badge" role="status">' +
resolvedCount + ' of ' + totalCount + ' hops resolved</div>'
: '<div class="mc-route-resolved-badge" role="status">' +
totalCount + ' of ' + totalCount + ' hops resolved</div>') +
'<ul class="mc-route-legend-list">' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u25B6</span><span>origin (originator)</span></li>' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u2691</span><span>destination</span></li>' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-gradient" aria-hidden="true"></span><span>hop-order color (bright \u2192 fading)</span></li>' +
'</ul>' +
'<div class="mc-route-legend-section">role shapes</div>' +
'<ul class="mc-route-legend-list">' + roleEntries + '</ul>' +
'</div>' +
'</div>';
var wrap = document.createElement('div');
wrap.innerHTML = html;
var node = wrap.firstChild;
container.appendChild(node);
var btn = node.querySelector('.mc-route-legend-toggle');
var body = node.querySelector('.mc-route-legend-body');
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
body.style.display = open ? 'none' : '';
});
}
function buildContextLabel(container, timestamp) {
var prior = container.querySelector('.mc-route-context-label');
if (prior) prior.remove();
var ts = timestamp ? new Date(timestamp).toLocaleString() : 'unknown time';
var el = document.createElement('div');
el.className = 'mc-route-context-label';
el.setAttribute('role', 'status');
el.textContent = 'Route observed at ' + ts;
container.appendChild(el);
}
/**
* Render the route. Caller passes the Leaflet map, a clean layer group,
* and the ordered positions array.
*
* @param {L.Map} mapRef
* @param {L.LayerGroup} layer
* @param {Array<{lat,lon,name,role,pubkey,isOrigin?,isDest?,resolved?,
* last_seen?,last_heard?,observation_count?}>} positions
* @param {{timestamp?:string|number}} [opts]
*/
function render(mapRef, layer, positions, opts) {
opts = opts || {};
if (!mapRef || !layer || !Array.isArray(positions) || positions.length === 0) return;
layer.clearLayers();
ensureArrowDefs(mapRef);
// Mark origin / destination explicitly. If caller didn't set isDest, the
// last resolved hop becomes the destination.
var total = positions.length;
var resolvedCount = positions.filter(function (p) { return p.resolved !== false; }).length;
positions.forEach(function (p, i) {
if (i === 0 && !('isOrigin' in p)) p.isOrigin = true;
if (i === total - 1 && !('isDest' in p)) p.isDest = true;
});
// Partial-route placement: unresolved hops with no lat/lon are
// interpolated between the nearest resolved neighbors so they render as
// dashed-gray placeholders on the route line.
for (var pi = 0; pi < positions.length; pi++) {
var cur = positions[pi];
if (cur.lat != null && cur.lon != null) continue;
var before = null, after = null;
for (var k = pi - 1; k >= 0; k--) {
if (positions[k].lat != null && positions[k].lon != null) { before = positions[k]; break; }
}
for (var k2 = pi + 1; k2 < positions.length; k2++) {
if (positions[k2].lat != null && positions[k2].lon != null) { after = positions[k2]; break; }
}
if (before && after) {
cur.lat = (before.lat + after.lat) / 2;
cur.lon = (before.lon + after.lon) / 2;
} else if (before) {
cur.lat = before.lat; cur.lon = before.lon;
} else if (after) {
cur.lat = after.lat; cur.lon = after.lon;
}
}
var reduceMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ── Edges ───────────────────────────────────────────────────────
for (var i = 0; i < total - 1; i++) {
var a = positions[i], b = positions[i + 1];
if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue;
var color = seqColor(i, total - 1);
var dist = haversineKm(a, b);
var ariaLabel = 'Hop ' + (i + 1) + ' \u2192 ' + (i + 2) +
(dist != null ? ', ~' + dist + 'km' : '');
var poly = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: color,
weight: 3.5,
opacity: 0.92,
dashArray: (a.resolved === false || b.resolved === false) ? '6 4' : null,
className: 'mc-route-edge'
}).addTo(layer);
// Patch the rendered <path> element to add aria-label + marker-end.
// Leaflet builds it on the next animation frame, so defer.
(function (polyRef, lbl, col) {
setTimeout(function () {
var el = polyRef.getElement && polyRef.getElement();
if (!el) return;
el.setAttribute('aria-label', lbl);
el.setAttribute('role', 'img');
el.classList.add('mc-route-edge');
el.setAttribute('marker-end', 'url(#mc-route-arrow)');
el.style.color = col; // arrow inherits via currentColor
if (reduceMotion) el.style.transition = 'none';
}, 0);
})(poly, ariaLabel, color);
}
// ── Markers + labels ────────────────────────────────────────────
var labelItems = [];
positions.forEach(function (p, i) {
if (p.lat == null || p.lon == null) return;
var unresolved = (p.resolved === false);
var color = unresolved ? '#9ca3af' : ((window.ROLE_COLORS && window.ROLE_COLORS[p.role]) || '#3b82f6');
var size = (p.isOrigin || p.isDest) ? 24 : 18;
var built = buildHopSVG(p, { color: color, size: size, isOrigin: p.isOrigin, isDest: p.isDest, unresolved: unresolved });
var badge = buildBadge(i + 1, total, { isOrigin: p.isOrigin, isDest: p.isDest });
var classNames = 'mc-route-marker' + (unresolved ? ' ch-unresolved' : '') +
(p.isOrigin ? ' mc-route-origin' : '') + (p.isDest ? ' mc-route-dest' : '');
var aria = ariaLabelFor(p, i, total);
var html =
'<div class="' + classNames + '" role="img" aria-label="' + escapeHtml(aria) +
'" tabindex="0" data-hop-index="' + i + '">' +
built.svg +
badge +
'</div>';
var icon = L.divIcon({
html: html,
className: 'mc-route-marker-icon',
iconSize: [built.size + 14, built.size + 14],
iconAnchor: [(built.size + 14) / 2, (built.size + 14) / 2]
});
var marker = L.marker([p.lat, p.lon], { icon: icon, keyboard: true }).addTo(layer);
marker.bindPopup(buildPopupHtml(p, i + 1, total), { className: 'mc-route-popup-wrap' });
labelItems.push({
latLng: L.latLng(p.lat, p.lon),
isLabel: true,
text: p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'hop')
});
});
// Deconflict label boxes — reuses map.js' shared algorithm.
if (typeof window.deconflictLabels === 'function') {
window.deconflictLabels(labelItems, mapRef);
}
labelItems.forEach(function (m) {
var pos = m.adjustedLatLng || m.latLng;
var labelHtml = '<div class="mc-route-label">' + escapeHtml(m.text) + '</div>';
var icon = L.divIcon({
html: labelHtml,
className: 'mc-route-label-icon',
iconSize: null,
iconAnchor: [0, -16]
});
var lblMarker = L.marker(pos, { icon: icon, interactive: false }).addTo(layer);
m._lblMarker = lblMarker;
if (m.offset && m.offset > 2) {
L.polyline([m.latLng, pos], {
weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3'
}).addTo(layer);
}
});
// Second-pass overlap resolution: shared `deconflictLabels` uses a fixed
// 38×24 collision box, but our role-aware labels are often wider. After
// Leaflet paints, measure the real DOM rects and nudge any overlapping
// labels vertically using an L.DomUtil offset (no relayout).
//
// We run the nudge once immediately AND again after `fitBounds`
// completes its async pan (`moveend`), because fitBounds re-projects
// the labels and can re-introduce overlap that the first nudge missed.
function nudgeOverlappingLabels() {
var containerEl = mapRef.getContainer ? mapRef.getContainer() : document.body;
var labelEls = Array.from(containerEl.querySelectorAll('.mc-route-label'));
// Reset prior nudges so we recompute from scratch (otherwise stacked
// nudges from successive passes drift labels off-screen).
for (var li = 0; li < labelEls.length; li++) {
var parent = labelEls[li].parentElement;
if (parent && parent.dataset && parent.dataset.mcRouteDy) {
parent.style.marginTop = '';
delete parent.dataset.mcRouteDy;
}
}
var rects = labelEls.map(function (el) { return el.getBoundingClientRect(); });
var maxIter = 8;
for (var iter = 0; iter < maxIter; iter++) {
var moved = false;
for (var i = 0; i < labelEls.length; i++) {
for (var j = i + 1; j < labelEls.length; j++) {
var a = rects[i], b = rects[j];
if (a.x < b.x + b.width && a.x + a.width > b.x &&
a.y < b.y + b.height && a.y + a.height > b.y) {
// Push the later label downward by the overlap height + 6px.
var dy = (a.y + a.height) - b.y + 6;
var p2 = labelEls[j].parentElement;
if (p2 && p2.style) {
var prev = p2.dataset.mcRouteDy ? Number(p2.dataset.mcRouteDy) : 0;
var next = prev + dy;
p2.dataset.mcRouteDy = String(next);
p2.style.marginTop = next + 'px';
}
rects[j] = labelEls[j].getBoundingClientRect();
moved = true;
}
}
}
if (!moved) break;
}
}
setTimeout(nudgeOverlappingLabels, 30);
mapRef.once('moveend', function () { setTimeout(nudgeOverlappingLabels, 30); });
// Fit map to route
var coords = positions.filter(function (p) { return p.lat != null && p.lon != null; })
.map(function (p) { return [p.lat, p.lon]; });
if (coords.length >= 2) {
mapRef.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
mapRef.setView(coords[0], 13);
}
// ── Overlay UI: legend + context label ──────────────────────────
var container = mapRef.getContainer ? mapRef.getContainer() : document.getElementById('leaflet-map');
if (container) {
buildLegend(container, resolvedCount, total);
buildContextLabel(container, opts.timestamp);
}
}
window.MeshRoute = {
render: render,
_seqColor: seqColor,
_haversineKm: haversineKm,
_ariaLabelFor: ariaLabelFor
};
})();