Files
meshcore-analyzer/test-issue-1418-spider-fan.js
T
Kpa-clawbot 77d1925f30 Route view v2 — Tufte redesign (packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors) (#1423)
# 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>
2026-05-27 08:01:15 +00:00

144 lines
6.8 KiB
JavaScript

/**
* #1418 — route-view.js spider-fan collision logic + loop marker invariants.
*
* Spider-fan rules (per route-view.js Tufte v7):
* - Pixel-distance threshold: COLLISION_THRESHOLD = 14px. Markers within
* 14px of each other are grouped and fanned onto an arc of R = 16px.
* - Markers further apart than 14px are NOT fanned (no group, kept put).
* - Each marker's original LatLng is cached in mk._origLatLng so repeated
* re-fan passes (zoom changes, re-render) don't drift.
* - Loop case (SRC.pubkey === DST.pubkey, same physical node) → endpoint
* markers built with isLoop=true → bigger SVG (size 28) + double
* concentric ring (r=10 and r=13 stroke-only circles).
*
* Strategy: replicate the grouping algorithm verbatim from the IIFE,
* apply it to synthetic pixel coordinates, and assert grouping decisions.
* Then exercise buildMarkerSVG() by extracting it and checking the loop-
* specific SVG markup.
*/
'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 spider A: source invariants ===');
assert(/COLLISION_THRESHOLD\s*=\s*14/.test(src),
'COLLISION_THRESHOLD === 14 (pixel proximity for fan trigger)');
assert(/var\s+R\s*=\s*16/.test(src),
'fan radius R === 16 px (arc that markers offset onto)');
assert(/_origLatLng/.test(src),
'_origLatLng cache field present (prevents drift on repeated fans)');
assert(/if\s*\(!mk\._origLatLng\)\s*mk\._origLatLng\s*=\s*ll/.test(src),
'_origLatLng written only ONCE (idempotent — repeated fans use cached origin)');
assert(/srcDstSameNode/.test(src),
'srcDstSameNode detection (loop case) present');
assert(/isLoop:\s*isLoop/.test(src) || /isLoop:\s*srcDstSameNode/.test(src),
'isLoop flag passed into buildMarkerSVG for endpoint markers');
assert(/if\s*\(isLoop\)\s*size\s*=\s*28/.test(src),
'isLoop marker grows to size 28 (vs default 22)');
// Two stroke circles for loop endpoints (r=10 endpoint ring + r=13 outer)
assert(/r="10"[^>]*fill="none"/.test(src) && /r="13"[^>]*fill="none"/.test(src),
'loop markers render double concentric ring (r=10 endpoint + r=13 outer)');
console.log('\n=== #1418 spider B: replicate grouping logic ===');
// Verbatim grouping algorithm from spiderFanFor() in route-view.js.
// Inputs: array of { x, y } pixel points. Output: array of groups (arrays
// of point objects); singletons are NOT returned (matches "if (group.length > 1)").
function groupCollisions(pts, threshold) {
const visited = {};
const groups = [];
pts.forEach(function (a, ai) {
if (visited[ai]) return;
const group = [a];
visited[ai] = true;
pts.forEach(function (b, bi) {
if (bi === ai || visited[bi]) return;
const dx = a.x - b.x, dy = a.y - b.y;
if (Math.sqrt(dx*dx + dy*dy) < threshold) { group.push(b); visited[bi] = true; }
});
if (group.length > 1) groups.push(group);
});
return groups;
}
// Case 1: two markers 10px apart → grouped (within 14px)
let g = groupCollisions([{x:100,y:100},{x:108,y:106}], 14);
assert(g.length === 1 && g[0].length === 2,
'two markers 10px apart → one group of 2');
// Case 2: two markers 50px apart → NOT grouped
g = groupCollisions([{x:100,y:100},{x:150,y:100}], 14);
assert(g.length === 0,
'two markers 50px apart → no group (no fan)');
// Case 3: three markers, two overlap (3px) and one 30px away
g = groupCollisions([{x:100,y:100},{x:101,y:103},{x:130,y:100}], 14);
assert(g.length === 1 && g[0].length === 2,
'three markers: two close + one far → one group of 2 (singleton excluded)');
// Case 4: exactly at threshold (14px). Spec uses strict-less-than → NOT grouped.
g = groupCollisions([{x:0,y:0},{x:14,y:0}], 14);
assert(g.length === 0, 'exactly 14px apart → NOT grouped (strict < threshold)');
// Case 5: cluster of 4 within 14px each → single group of 4
g = groupCollisions([{x:100,y:100},{x:102,y:101},{x:99,y:104},{x:101,y:99}], 14);
assert(g.length === 1 && g[0].length === 4,
'cluster of 4 within threshold → single group of 4');
console.log('\n=== #1418 spider C: extract buildMarkerSVG + verify loop output ===');
const fnMatch = src.match(/function\s+buildMarkerSVG\s*\(\s*p\s*,\s*opts\s*\)\s*\{[\s\S]*?\n {2}\}\n/);
assert(!!fnMatch, 'buildMarkerSVG() function body extracted');
if (fnMatch) {
const ctx = {};
vm.createContext(ctx);
vm.runInContext(fnMatch[0], ctx);
// Normal endpoint (origin, not loop)
const normal = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: false });
assert(normal.size === 22, 'non-loop endpoint marker size === 22 (got ' + normal.size + ')');
assert(/r="10"[^>]*fill="none"/.test(normal.html), 'non-loop endpoint has the r=10 single ring');
assert(!/r="13"/.test(normal.html), 'non-loop endpoint does NOT have the r=13 outer ring');
// Loop endpoint
const loop = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: true });
assert(loop.size === 28, 'loop marker size === 28 (got ' + loop.size + ')');
assert(/r="10"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=10 inner ring');
assert(/r="13"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=13 outer ring');
// SVG viewBox should match the larger size
assert(/viewBox="0 0 28 28"/.test(loop.html), 'loop marker SVG viewBox is 0 0 28 28');
// Interior (non-endpoint) marker — no ring
const inner = ctx.buildMarkerSVG({ resolved: true }, { color: '#3b82f6', seqNum: 2, isLoop: false });
assert(!/r="10"[^>]*fill="none"/.test(inner.html),
'interior marker has NO endpoint ring (only main 8px filled circle)');
// Unresolved hop renders dashed muted circle
const unres = ctx.buildMarkerSVG({ resolved: false }, { color: '#3b82f6', seqNum: 3 });
assert(/stroke-dasharray="2 2"/.test(unres.html),
'unresolved hop rendered with stroke-dasharray="2 2"');
}
console.log('\n=== #1418 spider D: srcDstSameNode loop detection invariants ===');
// The detection condition must be lowercase-compared (route-view does
// String(...).toLowerCase() === String(...).toLowerCase()) so AaBb === aabb.
assert(/positions\[0\]\.pubkey[\s\S]{0,80}positions\[positions\.length-1\]\.pubkey/.test(src),
'srcDstSameNode compares positions[0].pubkey vs positions[last].pubkey');
assert(/toLowerCase\(\)\s*===\s*String\(positions\[positions\.length-1\]\.pubkey\)\.toLowerCase\(\)/.test(src),
'pubkey loop-equality is case-insensitive (toLowerCase on both sides)');
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);