diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 57847a3b..ca142f1a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -317,6 +317,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
+ CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
diff --git a/public/index.html b/public/index.html
index 79024f3a..72cfb05a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -130,6 +130,7 @@
+
diff --git a/public/map.js b/public/map.js
index 4899b55d..5b6938e0 100644
--- a/public/map.js
+++ b/public/map.js
@@ -292,6 +292,12 @@
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;
+ window.deconflictLabels = deconflictLabels;
+ }
// Fix map size on SPA load
setTimeout(() => map.invalidateSize(), 100);
@@ -507,7 +513,7 @@
});
}
- function drawPacketRoute(hopKeys, origin) {
+ 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
@@ -516,6 +522,7 @@
console.warn('drawPacketRoute: origin should be an object {pubkey,lat,lon,name}, got string. Coercing.');
origin = { pubkey: origin };
}
+ opts = opts || {};
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
@@ -534,14 +541,22 @@
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
+ // 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.
const raw = hopKeys.map(hop => {
- const hopLower = hop.toLowerCase();
+ const hopLower = String(hop).toLowerCase();
const candidates = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
@@ -551,9 +566,9 @@
const c = candidates[0];
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
} else if (candidates.length > 1) {
- return { name: hop.slice(0,8), resolved: false, candidates };
+ return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates };
}
- return null;
+ return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
});
// Disambiguate: pick candidate closest to center of already-resolved hops
@@ -574,80 +589,42 @@
}
}
- const positions = raw.filter(h => h && h.resolved);
+ const positions = raw.filter(h => h != null);
// Resolve and prepend origin node
if (origin) {
let originPos = null;
if (origin.lat != null && origin.lon != null) {
- originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, isOrigin: true };
+ originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true };
} else if (origin.pubkey) {
const pk = origin.pubkey.toLowerCase();
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
if (match && match.lat != null && match.lon != null) {
- originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
+ 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 };
}
}
if (originPos) positions.unshift(originPos);
}
if (positions.length < 1) return;
-
- const coords = positions.map(p => [p.lat, p.lon]);
-
- if (positions.length >= 2) {
- L.polyline(coords, {
- color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
- }).addTo(routeLayer);
+ // Mark final hop as destination so the renderer applies the dest glyph.
+ positions[positions.length - 1].isDest = true;
+
+ // Hand off to the modern role-aware renderer (#1374). Falls back to the
+ // legacy minimal renderer only if MeshRoute hasn't loaded yet.
+ if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
+ window.MeshRoute.render(map, routeLayer, positions, {
+ timestamp: opts.timestamp || Date.now()
+ });
+ return;
}
- // Add numbered markers at each hop
- var labelItems = [];
- positions.forEach((p, i) => {
- const isOrigin = i === 0 && p.isOrigin;
- const isLast = i === positions.length - 1 && positions.length > 1;
- const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
- const radius = isOrigin ? 14 : 10;
- const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
-
- if (isOrigin) {
- L.circleMarker([p.lat, p.lon], {
- radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
- }).addTo(routeLayer);
- }
-
- const marker = L.circleMarker([p.lat, p.lon], {
- radius: radius, fillColor: color,
- fillOpacity: 0.9, color: '#fff', weight: 2
- }).addTo(routeLayer);
-
- const popupHtml = `
-
${label}: ${safeEsc(p.name)}
-
${p.role || 'unknown'}
-
${safeEsc(p.pubkey || '')}
-
${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}
- ${p.pubkey ? `
` : ''}
-
`;
- marker.bindPopup(popupHtml, { className: 'route-popup' });
-
- labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
- });
-
- // Deconflict labels so overlapping hop names spread out
- deconflictLabels(labelItems, map);
- labelItems.forEach(function (m) {
- var pos = m.adjustedLatLng || m.latLng;
- var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
- L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
- if (m.offset > 2) {
- L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
- }
- });
-
- // Fit map to route
+ // ── 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 {
+ } else if (coords.length === 1) {
map.setView(coords[0], 13);
}
}
diff --git a/public/route-render.js b/public/route-render.js
new file mode 100644
index 00000000..43e431df
--- /dev/null
+++ b/public/route-render.js
@@ -0,0 +1,452 @@
+/**
+ * #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 arrows on edges
+ * - per-hop color gradient (bright → fading)
+ * - per-marker role="img" + aria-label "Hop N of M, , "
+ * - per-edge aria-label "Hop N → N+1, ~Xkm"
+ * - reuses window.deconflictLabels (registered by map.js)
+ * - collapsible legend panel
+ * - "Route observed at " 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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)) ||
+ '';
+ // 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 = '\u25B6';
+ } else if (opts.isDest) {
+ glyph = '\u2691';
+ }
+
+ // Strip outer