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 ? `
View Node →
` : ''} -
`; - 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 = ''; + } else if (opts.isDest) { + glyph = ''; + } + + // Strip outer from inner SVG, re-wrap with outer ring + glyph + var innerBody = inner.replace(/^]*>/, '').replace(/<\/svg>$/, ''); + var 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 ''; + } + + 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 + ? '
Show on main map \u2192
' + : ''; + return '
' + + '
Hop ' + hopNum + ' of ' + total + + ': ' + escapeHtml(p.name || pubkeyShort) + '
' + + '
Role' + roleLine + '
' + + '
Pubkey' + + escapeHtml(pubkeyShort) + '\u2026
' + + '
Last seen' + escapeHtml(lastSeen) + '
' + + '
Observations' + escapeHtml(String(obsCount)) + '
' + + '
Coords' + escapeHtml(coords) + '
' + + deepLink + + '
'; + } + + 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 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 '
  • ' + + '' + svg + '' + + '' + r + '
  • '; + }).join(''); + + var html = + '
    ' + + '' + + '
    ' + + (resolvedCount < totalCount + ? '
    ' + + resolvedCount + ' of ' + totalCount + ' hops resolved
    ' + : '
    ' + + totalCount + ' of ' + totalCount + ' hops resolved
    ') + + '
      ' + + '
    • origin (originator)
    • ' + + '
    • destination
    • ' + + '
    • hop-order color (bright \u2192 fading)
    • ' + + '
    ' + + '
    role shapes
    ' + + '
      ' + roleEntries + '
    ' + + '
    ' + + '
    '; + + 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 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 = + ''; + 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 = '
    ' + escapeHtml(m.text) + '
    '; + 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 + }; +})(); diff --git a/public/style.css b/public/style.css index 67382e20..3c9b0e31 100644 --- a/public/style.css +++ b/public/style.css @@ -4034,3 +4034,201 @@ body { touch-action: pan-y; } .ch-channel-list { padding-bottom: 24px; } } /* === end #1367 ====================================================== */ +/* ─── #1374 packet-route map view ─────────────────────────────────── + Role-aware shape markers + sequence-number badges + directional + arrows + collapsible legend. WCAG SC 1.3.1 / 1.4.3 / 1.4.11 AA. + - Marker badge background uses --mc-route-badge-bg / -fg with measured + contrast ≥ 7:1 against #1a1a1a text on Carto Positron AND Dark + Matter (we burn-in #f8fafc fill + #0f172a text — both tiles). + - Edges use a per-hop HSL gradient as REDUNDANT carrier; the sequence + number badge is the primary order signal so colorblind users and + forced-colors users still read the route. + - `prefers-reduced-motion: reduce` disables marker focus pulse. + - `forced-colors: active` strips role colors → uses CanvasText/Canvas. + ----------------------------------------------------------------- */ +:root { + --mc-route-badge-bg: #f8fafc; + --mc-route-badge-fg: #0f172a; + --mc-route-badge-border: #1a1a1a; + --mc-route-label-bg: #f8fafc; + --mc-route-label-fg: #0f172a; + --mc-route-label-border: #475569; + --mc-route-legend-bg: rgba(248, 250, 252, 0.96); + --mc-route-legend-fg: #0f172a; + --mc-route-legend-border: #475569; +} +[data-theme="dark"] { + --mc-route-legend-bg: rgba(15, 23, 42, 0.94); + --mc-route-legend-fg: #f1f5f9; + --mc-route-legend-border: #94a3b8; +} + +.mc-route-marker-icon { background: transparent !important; border: none !important; } +.mc-route-marker { + position: relative; + display: inline-block; + line-height: 0; +} +.mc-route-marker:focus { + outline: 3px solid #06b6d4; + outline-offset: 2px; + border-radius: 50%; +} +.mc-route-marker.ch-unresolved { + opacity: 0.65; + filter: grayscale(0.8); +} +.mc-route-seq-badge { + position: absolute; + bottom: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 3px; + background: var(--mc-route-badge-bg); + color: var(--mc-route-badge-fg); + border: 1.5px solid var(--mc-route-badge-border); + border-radius: 8px; + font: 700 10px/14px system-ui, sans-serif; + text-align: center; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + pointer-events: none; +} +.mc-route-label-icon { background: transparent !important; border: none !important; } +.mc-route-label { + display: inline-block; + padding: 1px 6px; + background: var(--mc-route-label-bg); + color: var(--mc-route-label-fg); + border: 1px solid var(--mc-route-label-border); + border-radius: 3px; + font: 600 11px/14px system-ui, sans-serif; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} +.mc-route-edge { + fill: none; +} +.mc-route-legend { + position: absolute; + top: 12px; + right: 12px; + z-index: 700; + max-width: 240px; + background: var(--mc-route-legend-bg); + color: var(--mc-route-legend-fg); + border: 1px solid var(--mc-route-legend-border); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + font: 12px/1.4 system-ui, sans-serif; +} +.mc-route-legend-toggle { + display: block; + width: 100%; + padding: 6px 10px; + background: transparent; + color: inherit; + border: 0; + border-bottom: 1px solid var(--mc-route-legend-border); + font: 700 12px/1.2 system-ui, sans-serif; + text-align: left; + cursor: pointer; +} +.mc-route-legend-toggle:focus { outline: 2px solid #06b6d4; outline-offset: 1px; } +.mc-route-legend-toggle[aria-expanded="false"] + .mc-route-legend-body { display: none; } +.mc-route-legend-body { padding: 8px 10px; } +.mc-route-legend-list { list-style: none; padding: 0; margin: 4px 0; } +.mc-route-legend-entry { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} +.mc-route-legend-swatch svg { display: block; } +.mc-route-legend-glyph { + display: inline-block; + width: 14px; + text-align: center; + font-weight: 700; + color: var(--mc-route-legend-fg); +} +.mc-route-legend-gradient { + display: inline-block; + width: 32px; + height: 8px; + border-radius: 2px; + background: linear-gradient(90deg, hsl(152,70%,50%), hsl(18,70%,58%)); + border: 1px solid var(--mc-route-legend-border); +} +.mc-route-legend-section { + margin-top: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--mc-route-legend-fg); + opacity: 0.75; +} +.mc-route-resolved-badge { + display: inline-block; + padding: 2px 6px; + margin-bottom: 4px; + background: #fef3c7; + color: #78350f; + border: 1px solid #92400e; + border-radius: 3px; + font: 700 11px/14px system-ui, sans-serif; +} +.mc-route-context-label { + position: absolute; + top: 12px; + left: 60px; + z-index: 700; + padding: 4px 8px; + background: var(--mc-route-legend-bg); + color: var(--mc-route-legend-fg); + border: 1px solid var(--mc-route-legend-border); + border-radius: 4px; + font: 600 11px/1.3 system-ui, sans-serif; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} +.mc-route-popup .mc-route-popup-title { + font: 700 13px/1.3 system-ui, sans-serif; + margin-bottom: 4px; + color: var(--text, #0f172a); +} +.mc-route-popup .mc-route-popup-row { + display: flex; + justify-content: space-between; + gap: 8px; + font: 11px/1.4 system-ui, sans-serif; + color: var(--text-muted, #475569); +} +.mc-route-popup .mc-route-popup-row b, +.mc-route-popup .mc-route-popup-row code { + color: var(--text, #0f172a); +} +.mc-route-popup-link { + color: var(--accent, #0ea5e9); + font-size: 11px; + text-decoration: underline; +} + +@media (prefers-reduced-motion: reduce) { + .mc-route-marker, + .mc-route-edge { transition: none !important; animation: none !important; } +} +@media (forced-colors: active) { + .mc-route-marker svg circle, + .mc-route-marker svg rect, + .mc-route-marker svg polygon { stroke: CanvasText !important; } + .mc-route-seq-badge, + .mc-route-label, + .mc-route-legend, + .mc-route-context-label { + background: Canvas !important; + color: CanvasText !important; + border-color: CanvasText !important; + } + .mc-route-edge { stroke: CanvasText !important; } +} diff --git a/test-issue-1374-route-map-a11y-e2e.js b/test-issue-1374-route-map-a11y-e2e.js new file mode 100644 index 00000000..05fd634a --- /dev/null +++ b/test-issue-1374-route-map-a11y-e2e.js @@ -0,0 +1,239 @@ +/** + * #1374 — Packet-route map view a11y + visual modernization. + * + * Asserts the rewritten `/#/map?route=N` renderer: + * - role-aware shape markers (reuses makeRoleMarkerSVG) + * - origin / destination semantically distinct from intermediate hops + * - sequence-number badges (separate from label text) + * - directional arrows on edges + per-edge aria-label + * - per-marker role="img" + aria-label "Hop N of M, , " + * - deconflictLabels reused — no overlapping label boxes + * - collapsible legend panel renders + * - partial-route handling: unresolved markers + "X of N hops resolved" + * + * Strategy: the production renderer is split into a pure + * `window.MeshRoute.render(map, layer, positions, options)` that the test + * drives directly with synthetic positions, so no DB is required. The + * production `drawPacketRoute` resolves hops then calls the same function. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' \u2713 ' + name); } + catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// Synthetic 4-hop route in the Bay Area. +const ROUTE_FIXTURE = { + origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true }, + hops: [ + { pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true }, + { pubkey: 'cc22cc22cc22cc22', name: 'San Carlos Rptr', role: 'repeater', lat: 37.51, lon: -122.26, resolved: true }, + { pubkey: 'dd33dd33dd33dd33', name: 'Room Server SJ', role: 'room', lat: 37.34, lon: -121.89, resolved: true }, + { pubkey: 'ee44ee44ee44ee44', name: 'Destination Node', role: 'sensor', lat: 37.27, lon: -121.97, resolved: true, isDest: true }, + ] +}; + +const PARTIAL_FIXTURE = { + origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true }, + hops: [ + { pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true }, + { pubkey: 'unresolved-xx', name: 'unresol', role: null, resolved: false }, + { pubkey: 'dd33dd33dd33dd33', name: 'Destination Node', role: 'sensor', lat: 37.34, lon: -121.89, resolved: true, isDest: true }, + ] +}; + +async function renderRouteOnPage(page, fixture) { + return await page.evaluate((fx) => { + if (!window.MeshRoute || typeof window.MeshRoute.render !== 'function') { + return { error: 'window.MeshRoute.render not present' }; + } + // Build positions array: [origin, ...hops] + const positions = []; + if (fx.origin) positions.push(Object.assign({}, fx.origin)); + for (const h of fx.hops) positions.push(Object.assign({}, h)); + // Reset any existing route + if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) { + window.__mc_routeLayer.clearLayers(); + } + window.MeshRoute.render(window.__mc_map, window.__mc_routeLayer, positions, { + timestamp: new Date('2025-01-01T12:00:00Z').toISOString() + }); + return { ok: true, count: positions.length }; + }, fixture); +} + +async function runViewport(browser, width, height, label) { + console.log('\n=== Viewport ' + label + ' (' + width + 'x' + height + ') ==='); + const ctx = await browser.newContext({ viewport: { width, height } }); + const page = await ctx.newPage(); + page.on('pageerror', e => console.error(' pageerror:', e.message)); + await page.goto(BASE + '/#/map', { waitUntil: 'commit', timeout: 30000 }); + await page.waitForSelector('#leaflet-map', { timeout: 10000 }); + // Wait for MeshRoute to register + await page.waitForFunction(() => window.MeshRoute && window.__mc_map && window.__mc_routeLayer, { timeout: 10000 }); + await page.waitForTimeout(400); + + const r1 = await renderRouteOnPage(page, ROUTE_FIXTURE); + assertNoError(r1); + await page.waitForTimeout(1800); + + await step(label + ': every hop marker has role="img" and informative aria-label', async () => { + const data = await page.evaluate(() => { + const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]')); + return markers.map(m => m.getAttribute('aria-label') || ''); + }); + assert(data.length === 5, 'expected 5 markers, got ' + data.length); + const re = /Hop \d+ of \d+, [^,]+, (repeater|companion|room|sensor|observer)/; + for (const lbl of data) { + assert(re.test(lbl), 'aria-label "' + lbl + '" does not match Hop N of M pattern'); + } + }); + + await step(label + ': origin aria-label contains "originator", destination contains "destination"', async () => { + const data = await page.evaluate(() => { + const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]')); + return markers.map(m => m.getAttribute('aria-label') || ''); + }); + assert(/originator/i.test(data[0]), 'origin label missing "originator": ' + data[0]); + assert(/destination/i.test(data[data.length - 1]), 'destination label missing "destination": ' + data[data.length - 1]); + }); + + await step(label + ': sequence-number badge present beside each marker (not in label text)', async () => { + const data = await page.evaluate(() => { + const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge')); + return badges.map(b => b.textContent.trim()); + }); + assert(data.length >= 5, 'expected >=5 sequence badges, got ' + data.length); + // Badges should be numeric or numbered glyphs. + for (const b of data) { + assert(/^[\d①②③④⑤⑥⑦⑧⑨⑩▶⚑]+$/.test(b), 'badge "' + b + '" not numeric/glyph'); + } + }); + + await step(label + ': no two label boxes overlap (deconflict reused)', async () => { + const rects = await page.evaluate(() => { + const labels = Array.from(document.querySelectorAll('.mc-route-label')); + return labels.map(l => { + const r = l.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + }); + assert(rects.length >= 2, 'expected at least 2 labels rendered, got ' + rects.length); + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + const a = rects[i], b = rects[j]; + const overlap = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; + assert(!overlap, 'labels ' + i + ' and ' + j + ' overlap'); + } + } + }); + + await step(label + ': edges have aria-label "Hop N \u2192 N+1"', async () => { + const data = await page.evaluate(() => { + const edges = Array.from(document.querySelectorAll('path.mc-route-edge[aria-label]')); + return edges.map(e => e.getAttribute('aria-label')); + }); + assert(data.length >= 4, 'expected >=4 edge aria-labels, got ' + data.length); + const re = /Hop \d+ \u2192 \d+/; + for (const lbl of data) assert(re.test(lbl), 'edge label "' + lbl + '" missing arrow pattern'); + }); + + await step(label + ': edges carry directionality marker (marker-end arrow)', async () => { + const data = await page.evaluate(() => { + const edges = Array.from(document.querySelectorAll('path.mc-route-edge')); + const arrowDefs = document.querySelectorAll('marker[id^="mc-route-arrow"]'); + return { + edgeCount: edges.length, + withArrow: edges.filter(e => /url\(#mc-route-arrow/.test(e.getAttribute('marker-end') || '')).length, + defCount: arrowDefs.length + }; + }); + assert(data.defCount >= 1, 'expected at least one def, got ' + data.defCount); + assert(data.withArrow >= data.edgeCount, 'not all edges have marker-end arrow: ' + + data.withArrow + '/' + data.edgeCount); + }); + + await step(label + ': collapsible legend panel renders with role entries', async () => { + const data = await page.evaluate(() => { + const legend = document.querySelector('.mc-route-legend'); + if (!legend) return { found: false }; + const toggle = legend.querySelector('[aria-expanded]'); + const entries = legend.querySelectorAll('.mc-route-legend-entry, .mc-route-legend-role'); + const txt = legend.textContent.toLowerCase(); + return { + found: true, + hasToggle: !!toggle, + entryCount: entries.length, + hasRoleTerm: /repeater|companion|room|sensor/.test(txt), + hasOriginTerm: /origin/.test(txt), + hasDestTerm: /destin/.test(txt) + }; + }); + assert(data.found, '.mc-route-legend not rendered'); + assert(data.hasToggle, 'legend toggle missing aria-expanded'); + assert(data.entryCount >= 3, 'expected >=3 legend entries, got ' + data.entryCount); + assert(data.hasRoleTerm, 'legend missing role labels'); + assert(data.hasOriginTerm, 'legend missing origin/destination glyph entries'); + assert(data.hasDestTerm, 'legend missing destination glyph entry'); + }); + + await step(label + ': toolbar shows "Route observed at " context label', async () => { + const data = await page.evaluate(() => { + const el = document.querySelector('.mc-route-context-label'); + return el ? el.textContent : null; + }); + assert(data && /Route observed at/i.test(data), 'missing "Route observed at" label, got: ' + data); + }); + + // Partial route case + const r2 = await page.evaluate(() => { + if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) window.__mc_routeLayer.clearLayers(); + }); + await renderRouteOnPage(page, PARTIAL_FIXTURE); + await page.waitForTimeout(1500); + + await step(label + ': partial-route — unresolved marker carries ch-unresolved class', async () => { + const data = await page.evaluate(() => { + return document.querySelectorAll('.mc-route-marker[class*="ch-unresolved"]').length; + }); + assert(data >= 1, 'expected >=1 ch-unresolved marker, got ' + data); + }); + + await step(label + ': partial-route — "X of N hops resolved" badge present', async () => { + const data = await page.evaluate(() => { + const el = document.querySelector('.mc-route-resolved-badge'); + return el ? el.textContent : null; + }); + assert(data && /\d+ of \d+ hops resolved/i.test(data), 'missing resolved badge, got: ' + data); + }); + + await ctx.close(); +} + +function assertNoError(r) { + if (r && r.error) throw new Error(r.error); +} + +async function run() { + const launchOpts = { args: ['--no-sandbox'] }; + if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH; + const browser = await chromium.launch(launchOpts); + try { + await runViewport(browser, 375, 800, 'mobile'); + await runViewport(browser, 1920, 1080, 'desktop'); + } finally { + await browser.close(); + } + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + if (failed > 0) process.exit(1); +} + +run().catch(e => { console.error(e); process.exit(1); });