mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 14:21:30 +00:00
ff0ee50354
## 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 <timestamp>" 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>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
/**
|
|
* #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, <name>, <role>"
|
|
* - 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 <marker id="mc-route-arrow…"> 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 <timestamp>" 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); });
|