mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 11:21:27 +00:00
77d1925f30
# Route view v2 redesign Fixes #1418, Fixes #1419, Fixes #1422 This is the route-view redesign that came out of a long iterative QA cycle. The first commit (`a3c39636`) landed the v1 sidebar timeline + multi-path baseline; this PR's second commit (`0e2e913f`) is the v2 polish covering packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors, and dozens of operator-driven UX fixes. ## The journey, in one line > "The data is a sequence. Geography is annotation. The packet is the cargo, the route is the road — show both." ## New surfaces ### 1. Packet context block (sidebar header) Above the multi-path chip, a per-type fact list explaining **what** is traveling. Operator was tired of "the route view shows the road but not the cargo." | Type | Chip | Facts | |-------------|-----------------|---------------------------------------------------------| | ADVERT | 📡 ADVERT | name · role · sig ✓ · self-reported GPS · pubkey prefix | | TXT_MSG | ✉ DM | src → dst · 🔒 encrypted | | REQ/RESPONSE| 🔒/🔓 REQUEST/…| src → dst · 🔒 encrypted | | GRP_TXT | # CHANNEL MSG | #channel · 🔓 decrypted · "…content preview…" · sender | | TRACE | ⌖ TRACE | Official: N hops · Observed: M | | PATH | 🔀 PATH | src → dst (with "from payload" chip on SRC/DST rows) | Sources merge `pkt.decoded_json` + `obs.decoded_json` (channel data often lives at packet level) and fall back to byte-level `raw_hex` parsing for encrypted DMs and unkeyed channel msgs. ### 2. Multi-path picker The header lists every unique observer-path with `<count>/<total>` chip + hex hop string. Click a path → full-clear and redraw that path only (Tufte v6's "replace + retain subpath weights"). "All" → edge-deduplicated UNION view (each unique edge drawn once, stroke = observer count, single accent color, no seq numbers because there's no single ordering). ### 3. Deep-link URLs `#/map?packet=<hash>&obs=<id>` — bookmarkable, shareable, the single source of truth. sessionStorage flow removed. "Back to packet" preserves the obs id. ### 4. Hop resolution Priority: server `resolved_path` → shared `window.HopResolver` (same resolver as packets page, observer-IATA-aware) → raw prefix. Eliminates a whole class of "route view named hops differently than packet detail" bugs. ### 5. Markers (v5/v6/v7) - All markers same 22 px filled circle, seq number rendered **inside** - SRC + DST get a 2 px hollow endpoint ring - SRC = DST loop → **double concentric ring** (ring grammar extended, no new glyph) - Spider-fan within 14 px collisions (16 px arc, dashed hairline), re-runs on `zoomend` only, debounced ### 6. CB preset live colors - Each preset gets a `routeRamp` (5 stops): default/trit = viridis, deut/prot = plasma, achromat = pure luminance - `cb-presets.js` writes `--mc-rt-ramp-0..4` CSS vars; route reads them via `getComputedStyle` - `cb-preset-changed` + `theme-changed` listeners hot-recolor without re-render ### 7. Desktop chrome - **Resize handle** on right edge of sidebar (drag, persisted to `localStorage["mc-rt-sidebar-width"]`) - **Collapse button** = round chevron **centered on the right edge** (Material/Drive style — not in the top-right corner, doesn't collide with the close X) - Collapsed = 36 px strip with rotated "ROUTE" label, expand on click ### 8. Mobile (bottom sheet) - Anchored above bottom-nav (`bottom: 56px + safe-area-inset`) - Collapsed = thin summary line `TYPE · N hops · X km · M obs` + hex preview, tap chevron to expand to ~75 vh - Drag-grip removed (conflicted with browser pull-to-refresh + CoreScope's own pull-to-reconnect) - Desktop collapse / resize affordances hidden on mobile (sheet is the mobile collapse affordance) - Map controls toggle floats top-right, panel collapses on route entry, reachable via toggle click - All three mobile detail panels (`pktRight`, `.slide-over-panel`, `#mobileDetailSheet`) explicitly closed when entering route view ### 9. Map fit / centering - Manual layer-children walk because `L.LayerGroup.getBounds()` doesn't aggregate (only `FeatureGroup` does) - Mobile padding: `paddingTopLeft: [30, 70]`, `paddingBottomRight: [30, 190]` to clear top-nav + sheet+nav stack - Re-fits on: initial render, isolate, All, `window.resize` (iOS URL-bar collapse) - Staggered timers 0/200/600/1400 ms (and 2800 ms on initial render) to survive layout settles ### 10. Hop drill-in refinements - SNR sparkline suppresses connecting polyline when n < 3 (two points implies a trend across time it can't represent — dots only) - "Node details" link properly chip-styled with aria-label including node name + route count ## Edge weight scales | View | Range | |---------------------------------|----------------| | Single-path | 5 px flat | | Multi-path interior | 3..9 | | Origin→hop1 / last-hop→dest | proxy via max adjacent edge count | | Union overlay | 2..8 | Boundary edges (SRC→first hop, last hop→DST) used to render thin because `edgeCounts` only tracks `path_json` transitions. Now they take the strongest adjacent edge count as proxy (every observer who saw the packet implicitly transited that boundary edge). ## Files - **NEW** `public/route-tufte.js` (~1700 lines) — the route renderer + sidebar - **NEW** `public/route-tufte.css` (~750 lines) — all styling - **MOD** `public/map.js` — async draw functions, deep-link loader, `__mc_nodes` exposure, raw_hex extraction - **MOD** `public/packets.js` — View Route → deep-link URL only, closes all mobile panels - **MOD** `public/cb-presets.js` — `routeRamp` per preset + CSS var write - **MOD** `public/index.html` — script + stylesheet tags ## Testing Manually CDP-validated across desktop and mobile-emulator viewports for every major change. Fixtures cover: - ADVERT (4 hops, single-obs) - DM (TXT_MSG, raw_hex parse) - GRP_TXT (#test channel, decrypted text) - PATH (operator's bug case) - TRACE (3-hop) - 1-hop edge case - Multi-path (75-observer 4-hop with 47 unique paths) - 32-hop stress - Loop (SRC = DST) - Bay Area dense cluster (spider-fan) Per AGENTS.md net-new-UI exemption, no failing-test-first; existing tests stay green. **TODO**: Playwright E2E follow-up PR. ## What's deferred to v2.1 / follow-ups - **Glyph overlay on SRC marker** for packet type (e.g. 📡 corner glyph on ADVERT marker, ⌖ on TRACE) - **Per-hop SNR sparkline for TRACE packets** (their payload contains real per-hop SNR contributions, distinct from observer-derived SNR) - **GRP_TXT full content preview** (currently truncated at 80 chars; could expand inline) - **Playwright E2E test** covering the deep-link → isolate → All flow ## Screenshots (would be useful here — CDP screenshots captured during dev show: desktop with sidebar + multi-path picker, mobile with bottom sheet + overlay toggle, isolated-path view, union view, spider-fan on Bay Area cluster, packet context for each of the 5 main types) ## Operator's frustration patterns (lessons for next time) 1. **Browser-validate every UI change, not just compute state** — CDP-screenshot before claiming a UI fix is done. Verifying `display:none` resolves correctly is necessary but not sufficient; the visual layout matters. 2. **Edge-deduplicated drawing beats per-path overlays** for union views (Tufte v6) — operator's instinct was correct from the start. 3. **Material/Drive UI conventions exist** because they work — center collapse handles on borders, don't pile them in corners. 4. **Mobile = different problem than desktop** — bottom-sheet, no drag-grip near pull-to-refresh zone, asymmetric fitBounds padding, redundant refits to survive iOS URL-bar collapse. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: corescope-bot <bot@corescope.local>
136 lines
6.2 KiB
JavaScript
136 lines
6.2 KiB
JavaScript
/**
|
|
* #1418 — route-view.js edgeWeight() scales + boundary fix.
|
|
*
|
|
* Edge-stroke-width logic (route-view.js `edgeWeight()`):
|
|
* - Single-path mode → flat 5
|
|
* - Multi-path interior edge → 3 + ratio*6 (range 3..9)
|
|
* - Multi-path BOUNDARY edge (origin→hop1 or last-hop→dest) → proxy via
|
|
* max adjacent edgeCount. Before the recent fix, boundary edges with no
|
|
* matching prefix returned 1.5 (the floor for unknown interior edges),
|
|
* visually shrinking origin/dest edges to hairlines.
|
|
* - Union-of-edges view (in isolatePath/restoreAllPaths) → 2 + ratio*6
|
|
* (range 2..8).
|
|
*
|
|
* Strategy: extract the edgeWeight() function from route-view.js with regex,
|
|
* eval it into a sandbox seeded with `positions` + `edgeCounts` + `multiPath`
|
|
* + `totalObservers`, and assert on returns. This exercises the SHIPPING
|
|
* function — if route-view.js drifts, the test breaks.
|
|
*/
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
let passed = 0, failed = 0;
|
|
function assert(cond, msg) {
|
|
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
|
else { failed++; console.error(' ✗ ' + msg); }
|
|
}
|
|
|
|
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
|
|
|
console.log('\n=== #1418 edgeWeight A: source invariants ===');
|
|
assert(/function\s+edgeWeight\s*\(\s*idx\s*\)/.test(src),
|
|
'edgeWeight(idx) function exists in route-view.js');
|
|
assert(/if\s*\(!multiPath\)\s+return\s+5/.test(src),
|
|
'single-path mode returns flat 5');
|
|
// Boundary fix invariant: an isOriginEdge / isDestEdge code path exists
|
|
// and computes a proxy from max adjacent count instead of returning 1.5.
|
|
assert(/isOriginEdge\s*\|\|\s*isDestEdge/.test(src),
|
|
'boundary-edge branch present (isOriginEdge || isDestEdge)');
|
|
assert(/3\s*\+\s*bRatio\s*\*\s*6/.test(src),
|
|
'boundary branch uses 3 + bRatio*6 scale (not 1.5)');
|
|
assert(/3\s*\+\s*ratio\s*\*\s*6/.test(src),
|
|
'interior multi-path uses 3 + ratio*6 (range 3..9)');
|
|
assert(/2\s*\+\s*ratio\s*\*\s*6/.test(src),
|
|
'union/isolate view uses 2 + ratio*6 (range 2..8)');
|
|
|
|
console.log('\n=== #1418 edgeWeight B: extract + exercise the real function ===');
|
|
|
|
// Extract the edgeWeight function body verbatim. The function is declared
|
|
// inside the IIFE; we regex it out and run it in a sandbox with the closure
|
|
// variables it expects (positions, edgeCounts, multiPath, totalObservers).
|
|
const fnMatch = src.match(/function\s+edgeWeight\s*\(\s*idx\s*\)\s*\{[\s\S]*?\n {4}\}/);
|
|
assert(!!fnMatch, 'edgeWeight() function body extracted from route-view.js');
|
|
|
|
function runEdgeWeight(positions, edgeCounts, totalObservers, multiPath, idx) {
|
|
const ctx = { positions, edgeCounts, totalObservers, multiPath };
|
|
vm.createContext(ctx);
|
|
vm.runInContext(fnMatch[0] + '; result = edgeWeight(' + idx + ');', ctx);
|
|
return ctx.result;
|
|
}
|
|
|
|
// --- Single-path mode: always 5 ---
|
|
const singlePos = [
|
|
{ pubkey: 'AABB', isOrigin: true },
|
|
{ pubkey: 'CCDD' },
|
|
{ pubkey: 'EEFF', isDest: true }
|
|
];
|
|
assert(runEdgeWeight(singlePos, {}, 1, false, 0) === 5,
|
|
'single-path mode: edgeWeight(0) === 5');
|
|
assert(runEdgeWeight(singlePos, { 'AA→CC': 99 }, 50, false, 1) === 5,
|
|
'single-path mode: edgeWeight(1) === 5 regardless of edgeCounts');
|
|
|
|
// --- Multi-path INTERIOR edge: 3 + ratio*6 ---
|
|
const mPos = [
|
|
{ pubkey: 'AABB', isOrigin: true }, // origin
|
|
{ pubkey: 'CCDD' }, // hop 1 (interior start)
|
|
{ pubkey: 'EEFF' }, // hop 2 (interior end)
|
|
{ pubkey: 'GG00', isDest: true } // dest
|
|
];
|
|
// Edge 1: CC→EE. edgeCounts has CC→EE: 5 of 10 observers → ratio 0.5
|
|
// expected = 3 + 0.5*6 = 6
|
|
let w = runEdgeWeight(mPos, { 'CC→EE': 5 }, 10, true, 1);
|
|
assert(Math.abs(w - 6) < 0.001,
|
|
'multi-path interior: ratio 0.5 → weight 6 (got ' + w + ')');
|
|
// Full coverage: ratio 1.0 → weight 9
|
|
w = runEdgeWeight(mPos, { 'CC→EE': 10 }, 10, true, 1);
|
|
assert(Math.abs(w - 9) < 0.001,
|
|
'multi-path interior: ratio 1.0 → weight 9 (got ' + w + ')');
|
|
// No matching count: falls through to 1.5 floor
|
|
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 1);
|
|
assert(w === 1.5,
|
|
'multi-path interior: no matching edge → 1.5 hairline floor (got ' + w + ')');
|
|
|
|
// --- BOUNDARY edge fix: origin→hop1 ---
|
|
// idx=0: AA(isOrigin) → CC. edgeCounts has CC→EE: 8 of 10
|
|
// Boundary proxy: look for edges where a==CC (the next-to-boundary node)
|
|
// 8/10 → weight = 3 + 0.8*6 = 7.8
|
|
w = runEdgeWeight(mPos, { 'CC→EE': 8 }, 10, true, 0);
|
|
assert(Math.abs(w - 7.8) < 0.001,
|
|
'boundary edge (origin→hop1): proxied by adjacent CC→EE count 8/10 → 7.8 (got ' + w + ')');
|
|
|
|
// --- BOUNDARY edge fix: last-hop→dest ---
|
|
// idx=2: EE → GG(isDest). Look for edges where b==EE (the from-boundary node)
|
|
// edgeCounts CC→EE: 7 of 10 → 3 + 0.7*6 = 7.2
|
|
w = runEdgeWeight(mPos, { 'CC→EE': 7 }, 10, true, 2);
|
|
assert(Math.abs(w - 7.2) < 0.001,
|
|
'boundary edge (last-hop→dest): proxied by adjacent CC→EE count 7/10 → 7.2 (got ' + w + ')');
|
|
|
|
// --- REGRESSION GUARD: boundary edge with NO adjacent edgeCount must NOT
|
|
// return 1.5 (the old bug). It returns 5 as the documented fallback. ---
|
|
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 0);
|
|
assert(w === 5,
|
|
'boundary edge with no adjacent edgeCount returns 5 (NOT the old 1.5 bug) — got ' + w);
|
|
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 2);
|
|
assert(w === 5,
|
|
'boundary edge (last-hop→dest) with no adjacent count → 5 (NOT 1.5) — got ' + w);
|
|
|
|
// --- Multiple matching adjacent edges: use MAX, not sum ---
|
|
// idx=0: AA(origin)→CC. edgeCounts has CC→EE:3 and CC→FF:7. Max is 7 → 3+0.7*6=7.2
|
|
w = runEdgeWeight(mPos, { 'CC→EE': 3, 'CC→FF': 7 }, 10, true, 0);
|
|
assert(Math.abs(w - 7.2) < 0.001,
|
|
'boundary edge: picks MAX adjacent count (max of 3,7 = 7 → 7.2) — got ' + w);
|
|
|
|
console.log('\n=== #1418 edgeWeight C: isolated-path union weight (2 + ratio*6) ===');
|
|
// The 2+ratio*6 formula is in the isolatePath() block. Source-grep guarantees
|
|
// its presence. Verify the literal expression is unique (not stripped).
|
|
const occurrences2 = (src.match(/2\s*\+\s*ratio\s*\*\s*6/g) || []).length;
|
|
assert(occurrences2 >= 1, 'isolatePath union weight formula (2 + ratio*6) present at least once');
|
|
|
|
console.log('\n=== Summary ===');
|
|
console.log(' passed: ' + passed);
|
|
console.log(' failed: ' + failed);
|
|
if (failed > 0) process.exit(1);
|