Compare commits

...

14 Commits

Author SHA1 Message Date
efiten f0c69d5fe7 perf(server): fix repeaterEnrichTTL mismatch causing 18s /api/nodes latency (#1425)
## Root cause

`repeaterEnrichTTL` was **15 seconds**, but the background recomputer
(`StartRepeaterEnrichmentRecomputer`) runs every **5 minutes**.

After each recomputer tick, the relay/usefulness caches were valid for
15 seconds. For the remaining 4m45s, every `/api/nodes` request hit a
stale TTL gate in `GetRepeaterRelayInfoMap` /
`GetRepeaterUsefulnessScoreMap` and fell through to
`computeRepeaterRelayInfoMap` **on the request goroutine**. On
production (16k+ transmissions, 240k hop records) that rebuild takes ~18
seconds, making `/api/nodes?limit=5000` freeze on virtually every page
load.

The pattern was:
```
recomputer runs at T=0  → cache valid
T=15s                   → TTL expires
T=15s … T=5min          → every request rebuilds on-thread (18s each)
T=5min                  → recomputer runs again → 15s valid window
repeat
```

## Fix

One line in `repeater_enrich_bulk.go`:

```go
// Before
const repeaterEnrichTTL = 15 * time.Second

// After
const repeaterEnrichTTL = 10 * time.Minute
```

The TTL now exceeds the recomputer interval so the cache is always warm
between background ticks. The TTL remains as a safety net for cases
where the recomputer isn't running (tests, early startup edge cases) —
it just no longer expires between ticks.

## Production results (analyzer.on8ar.eu)

Tested with binary injection on the live server before opening this PR.

| Metric | Before | After |
|--------|--------|-------|
| TTFB (`/api/nodes?limit=5000`) | 18.6 s | 0.47–0.54 s |
| Total response time | 18.9 s | 1.55–1.73 s |
| Improvement | — | **34–39×** |

Confirmed still fast at t+60s (well past the old 15s window).

## Test results

```
TestHandleNodesPerfLargeFleet      elapsed=1.9ms   budget=2s  PASS
TestHandleNodesLimit2000ColdMiss   elapsed=5.3ms   budget=2s  PASS
```

Both existing perf regression tests pass unchanged — the TTL change
doesn't affect their behavior (they test the cold-prewarm path, not TTL
expiry).

## Why this wasn't caught by tests

`TestHandleNodesLimit2000ColdMiss` only tests the cold-startup path
(cache nil → on-thread build → cache hit). It doesn't test the
TTL-expiry path (cache exists but stale → on-thread rebuild). A test
covering the latter would need to fast-forward time past the TTL, which
the existing fixture doesn't do.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:28:46 -07:00
Kpa-clawbot 48717aaccb ci: update go-server-coverage.json [skip ci] 2026-05-27 08:21:00 +00:00
Kpa-clawbot 13ae0dd6aa ci: update go-ingestor-coverage.json [skip ci] 2026-05-27 08:20:59 +00:00
Kpa-clawbot ec7ff4c597 ci: update frontend-tests.json [skip ci] 2026-05-27 08:20:58 +00:00
Kpa-clawbot 5d8d857cfb ci: update frontend-coverage.json [skip ci] 2026-05-27 08:20:57 +00:00
Kpa-clawbot 8d702bdfd9 ci: update e2e-tests.json [skip ci] 2026-05-27 08:20:55 +00:00
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
Kpa-clawbot 306ac37ea0 ci: update go-server-coverage.json [skip ci] 2026-05-27 01:08:57 +00:00
Kpa-clawbot 50a1b1c6e8 ci: update go-ingestor-coverage.json [skip ci] 2026-05-27 01:08:56 +00:00
Kpa-clawbot 0c52cf663a ci: update frontend-tests.json [skip ci] 2026-05-27 01:08:55 +00:00
Kpa-clawbot be1b014269 ci: update frontend-coverage.json [skip ci] 2026-05-27 01:08:54 +00:00
Kpa-clawbot c796d48442 ci: update e2e-tests.json [skip ci] 2026-05-27 01:08:52 +00:00
Kpa-clawbot 0986caaa44 fix(#1412): customizer nodeColors stops force-overriding ROLE_COLORS — CB presets now actually propagate (#1414)
WIP — red commit only. Reproduces #1412.

## TDD red phase
`test-issue-1412-customizer-no-override.js` asserts that after
`MeshCorePresets.applyPreset('deut')` and a server-config push of legacy
`nodeColors`, `window.ROLE_COLORS.repeater === '#FE6100'`. On master
this
fails because `customize-v2.js:553` pushes server-config into the
`_roleOverrides` map, which the live getter prefers over CSS vars.

Green commit (customize-v2.js + customize.js fix) follows.

Refs #1412

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-26 17:48:07 -07:00
Kpa-clawbot 89410d58b4 fix(#1413): nav-left + nav-stats overlap at vw~1200 — flex sizing fix (#1417)
## What

Fix the horizontal overlap between `.nav-more-btn` (in `.nav-left`) and
`.nav-stats` (in `.nav-right`) at viewport widths roughly 1101..1599px.
At vw=1200 the count number in the stats badge rendered on top of the
"More ▾" text.

## Root cause

`.top-nav` uses `display: flex; justify-content: space-between;` but had
**no column gap** between its children, and `.nav-links` had **no
flex-grow**. So `.nav-left` only consumed its content's intrinsic width
and `.nav-right` (with `flex-shrink: 0`) was free to abut it. Worse, the
Priority+ measurement loop in `app.js` (`applyNavPriority` → `fits()`)
compared intrinsic widths against `window.innerWidth` while `.top-nav {
overflow: hidden }` masked the actual collision — so the loop happily
declared "fits" while pixels overlapped.

CDP measurement on master at vw=1200 (`/#/packets`):

- `.nav-more-btn` rect: x=499..557 (w=58)
- `.nav-stats` rect: x=496..962 (w=466)
- Gap: **−60.7px** (overlapping)

Fix candidates tested via Chrome DevTools Protocol (`Runtime.evaluate` +
`Emulation.setDeviceMetricsOverride`) across vw=1101, 1200, 1366, 1440,
1600, 1920 (plus 768, 900, 1024, 1080, 1100, 1300, 1500, 1700, 1800 as a
sanity sweep). Winner:

```css
.top-nav   { column-gap: 16px; }
.nav-links { flex: 1 1 auto; min-width: 0; }
```

Per-viewport gap (`stats.left - more.right`) baseline → fix:

| vw   | baseline | fix      |
|------|----------|----------|
| 1101 | −144.0   | **16.0** |
| 1200 |  −60.7   | **16.0** |
| 1300 |    8.4   | **16.0** |
| 1366 |   64.2   | 64.2     |
| 1440 |    0.0   | **44.5** |
| 1600 |   24.2   | 24.2     |
| 1920 | more hidden (no overflow) — n/a | n/a |

Single-candidate variants (`.nav-left { flex: 1 1 auto }` alone,
`.top-nav { justify-content: space-between }` alone — already on, no
effect, `.nav-links { flex: 1 1 auto }` alone, margin/padding hacks on
`.nav-right`/`.nav-stats`) all still produced ≤8px gap at vw=1200. Only
the combo (column-gap on parent + flex-grow on `.nav-links`) cleanly
resolves all six required widths.

## TDD

Red commit: `3d374b4c93319805e89e46d8fdc8a8ea8c6c1479` (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26482870401)

- `test-issue-1413-nav-overlap-e2e.js` — Playwright at vw 1101, 1200,
1366, 1440, 1600, 1920 on `/#/packets`. Asserts `.nav-more-btn.right + 8
<= .nav-stats.left` (when both visible) and that `.top-nav` does not
horizontally scroll. Wired into `.github/workflows/deploy.yml` alongside
the other `test-nav-*-e2e.js` entries.
- Red commit ships ONLY the test (+workflow line); CI fails on the
assertion at vw=1101..1300 and vw=1440 (gap below 8px threshold).
- Green commit applies the two CSS rules above and turns CI green.

## Manual verification

1. Open `http://analyzer-stg.00id.net/#/packets` in a desktop browser.
2. Resize the viewport to ~1200px wide.
3. Confirm the "More ▾" button and the stats badge are visibly separated
(≥16px gap) and the badge count is not stacked on the button text.
4. Repeat at 1101, 1300, 1440, 1600, 1920px — gap ≥16px at all widths
where stats is visible.
5. At ≤1100px confirm `.nav-stats` is still hidden (display:none,
unchanged).

## Scope guards

- No changes to the Priority+ algorithm (`applyNavPriority` / `fits()`
in `app.js`). #1391, #1311, #1139, #1148, #1102, #1055 logic untouched.
- No changes to the More dropdown (`position: fixed`, #1406).
- No changes to `.nav-left { overflow }` (#1405 stayed dropped).
- Mobile (<768px) hamburger layout unchanged.

Fixes #1413

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 17:38:47 -07:00
22 changed files with 4257 additions and 44 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"38.79%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"35.7%","color":"red"}
+8
View File
@@ -113,6 +113,13 @@ jobs:
node test-issue-1375-scope-stats-fetch.js
node test-issue-1361-cb-presets.js
node test-issue-1407-cb-preset-propagation.js
node test-issue-1412-customizer-no-override.js
node test-issue-1418-raw-hex-extraction.js
node test-issue-1418-edge-weights.js
node test-issue-1418-cb-preset-ramp.js
node test-issue-1418-spider-fan.js
node test-issue-1418-deeplink-hops-channels.js
node test-issue-1418-polish-review.js
node test-live.js
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
@@ -268,6 +275,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1391-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1413-nav-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1400-nav-vertical-clip.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
+10 -9
View File
@@ -5,12 +5,13 @@ import (
"time"
)
// repeaterEnrichTTL bounds how stale the per-page bulk enrichment caches
// for handleNodes may be. Same 15s budget as GetNodeHashSizeInfo — the
// numbers feed an at-a-glance status column, not an alerting path, so
// up-to-15s freshness is fine and keeps the request path O(page) instead
// of O(page × byPathHop[pk] × parsed timestamps).
const repeaterEnrichTTL = 15 * time.Second
// repeaterEnrichTTL is the safety-net TTL for the bulk enrichment caches.
// Derived as 2× the recomputer's default tick so the cache is always valid
// between background refreshes. Without a recomputer (tests, edge cases)
// the cache rebuilds on-thread at most once per TTL window.
// Note: if analytics.defaultIntervalSeconds is configured above 600 the
// TTL will expire before the recomputer runs; keep that value < TTL/2.
const repeaterEnrichTTL = 2 * repeaterEnrichmentRecomputerDefaultInterval
// GetRepeaterRelayInfoMap returns a cached pubkey → RepeaterRelayInfo
// map covering EVERY pubkey that currently appears as a path hop in any
@@ -29,9 +30,9 @@ const repeaterEnrichTTL = 15 * time.Second
// The cached map is keyed by lowercase pubkey/hop key (same shape as
// byPathHop). Lookups should use strings.ToLower(pk).
//
// The cache is invalidated by TTL only — never by ingest. With a 15s
// budget that's acceptable for a status column; if a fresher signal is
// ever needed for a non-status caller, expose a non-cached path.
// The cache is invalidated by TTL only — never by ingest. Up-to-10min
// freshness is fine for an at-a-glance status column; if a fresher
// signal is ever needed for a non-status caller, expose a non-cached path.
func (s *PacketStore) GetRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
s.repeaterEnrichMu.Lock()
if s.repeaterRelayCache != nil &&
+3 -3
View File
@@ -7,9 +7,9 @@ import (
// repeaterEnrichmentRecomputerInterval is the default tick interval
// for the steady-state recompute of the repeater enrichment bulk
// caches. The on-request 15s-TTL fallback in repeater_enrich_bulk.go
// is kept as a safety net — the recomputer just makes sure the cache
// is populated before any request arrives.
// caches. The on-request TTL fallback in repeater_enrich_bulk.go is
// kept as a safety net — the recomputer just makes sure the cache is
// populated before any request arrives.
//
// 5min mirrors the analytics_recomputer default from #1240 and is
// plenty fresh for an at-a-glance status column.
+17
View File
@@ -49,6 +49,8 @@
suspected: '#FFD966',
unknown: '#FF8888'
}
,
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
},
{
id: 'deut',
@@ -73,6 +75,8 @@
suspected: '#FFB000',
unknown: '#DC267F'
}
,
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
},
{
id: 'prot',
@@ -95,6 +99,8 @@
suspected: '#FFB000',
unknown: '#DC267F'
}
,
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
},
{
id: 'trit',
@@ -125,6 +131,8 @@
suspected: '#DDCC77',
unknown: '#CC6677'
}
,
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
},
{
id: 'achromat',
@@ -158,6 +166,8 @@
suspected: '#808080',
unknown: '#595959'
}
,
routeRamp: ['#222222', '#555555', '#888888', '#bbbbbb', '#eeeeee']
}
];
@@ -241,6 +251,13 @@
Object.keys(p.mb).forEach(function (k) {
style.setProperty('--mc-mb-' + k, p.mb[k]);
});
// #1418 — route-view sequence ramp (5 stops). route-view.js reads
// --mc-rt-ramp-0..4 instead of hardcoded viridis/magma so a CB preset
// changes the route edge colors live. Achromat uses a luminance ramp.
var rr = p.routeRamp || ['#440154','#3b528b','#21918c','#5ec962','#fde725'];
for (var ri = 0; ri < 5; ri++) {
style.setProperty('--mc-rt-ramp-' + ri, rr[ri] || rr[rr.length - 1]);
}
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
// that read --mc-role-* directly, so no explicit sync is needed. The
// pre-#1407 code path kept them in sync as a workaround for the static
+9 -6
View File
@@ -546,13 +546,16 @@
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
// Node colors → CSS vars + global objects
// Node colors → --node-X CSS var only (legacy compat).
// #1412: do NOT push server-config nodeColors into window.ROLE_COLORS —
// that defeats cb-presets propagation by trapping the legacy palette in
// the _roleOverrides map (where the live getter prefers it over the
// --mc-role-X CSS vars that presets actually write). User-chosen
// overrides still flow through setRoleColorOverride() in customize.js.
var nc = effectiveConfig.nodeColors;
if (nc) {
for (var role in nc) {
root.setProperty('--node-' + role, nc[role]);
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = nc[role];
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = nc[role];
}
}
@@ -2142,11 +2145,11 @@
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
// Apply node/type colors from overrides early
// Apply node colors from overrides early — --node-X CSS var only.
// #1412: do NOT write to window.ROLE_COLORS / ROLE_STYLE here.
if (earlyOverrides.nodeColors) {
for (var role in earlyOverrides.nodeColors) {
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = earlyOverrides.nodeColors[role];
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = earlyOverrides.nodeColors[role];
root.setProperty('--node-' + role, earlyOverrides.nodeColors[role]);
}
}
if (earlyOverrides.typeColors && window.TYPE_COLORS) {
+11 -3
View File
@@ -1145,8 +1145,13 @@
inp.addEventListener('input', function () {
var key = inp.dataset.node;
state.nodeColors[key] = inp.value;
// Sync to global role colors used by map/packets/etc
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
// #1412: route per-key user picks through setRoleColorOverride so
// the explicit override map is the only place mutation happens.
// (Direct subscript assignment would also work via the roles.js
// proxy, but the explicit API is the documented contract.)
if (typeof window.setRoleColorOverride === 'function') {
window.setRoleColorOverride(key, inp.value);
}
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
// Trigger re-render of current page
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
@@ -1162,7 +1167,10 @@
btn.addEventListener('click', function () {
var key = btn.dataset.resetNode;
state.nodeColors[key] = DEFAULTS.nodeColors[key];
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
// #1412: clearing the override lets cb-preset CSS var win again.
if (typeof window.setRoleColorOverride === 'function') {
window.setRoleColorOverride(key, DEFAULTS.nodeColors[key]);
}
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
render(container);
});
+2
View File
@@ -36,6 +36,7 @@
})();
</script>
<link rel="stylesheet" href="style.css?v=__BUST__">
<link rel="stylesheet" href="route-view.css?v=__BUST__">
<link rel="stylesheet" href="home.css?v=__BUST__">
<link rel="stylesheet" href="live.css?v=__BUST__">
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
@@ -151,6 +152,7 @@
<script src="geo-filter-overlay.js?v=__BUST__"></script>
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="table-sort.js?v=__BUST__"></script>
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
+523 -16
View File
@@ -296,6 +296,9 @@
if (typeof window !== 'undefined') {
window.__mc_map = map;
window.__mc_routeLayer = routeLayer;
// Expose nodes array for route-view's path-picker isolation
// (needs to resolve hop prefixes that aren't in the canonical path).
Object.defineProperty(window, '__mc_nodes', { get: function () { return nodes; }, configurable: true });
window.deconflictLabels = deconflictLabels;
}
@@ -502,18 +505,29 @@
sessionStorage.removeItem('map-route-hops');
try {
const parsed = JSON.parse(routeHopsJson);
// Support new format {origin, hops} and legacy plain array
if (Array.isArray(parsed)) {
drawPacketRoute(parsed, null);
} else if (parsed.paths && parsed.paths.length > 0) {
drawPacketRouteMulti(parsed.paths, parsed.origin || null, {
packetHash: parsed.packetHash,
canonicalPath: parsed.hops || null,
destination: parsed.destination || null
});
} else {
drawPacketRoute(parsed.hops || [], parsed.origin || null);
drawPacketRoute(parsed.hops || [], parsed.origin || null, { destination: parsed.destination || null });
}
} catch {}
} else {
// #1418/#1419: deep-link via URL params — #/map?packet=<hash>&obs=<id>
// (or just packet=<hash> for first observation). Fetch from API, build
// the same payload as the sessionStorage flow, dispatch to renderer.
// This makes routes shareable / bookmarkable without sessionStorage state.
loadRouteFromDeepLink();
}
});
}
function drawPacketRoute(hopKeys, origin, opts) {
async 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
@@ -523,6 +537,21 @@
origin = { pubkey: origin };
}
opts = opts || {};
// #1422: use the backend's /api/resolve-hops for proper disambiguation
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
// Falls back to naive nodes.filter() scan if the API is unreachable.
let serverResolved = null;
try {
const hopList = (hopKeys || []).join(',');
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(hopList);
const resp = await fetch(apiUrl, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
serverResolved = json && json.resolved ? json.resolved : null;
}
} catch (e) {
console.warn('resolve-hops API call failed, falling back to local nodes scan', e);
}
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
@@ -555,18 +584,42 @@
// 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.
//
// #1418/#1422: PREFER serverResolved from /api/resolve-hops which does
// - unique_prefix matching (uses multi-byte adverts to disambiguate 1-byte hops)
// - gps_preference (skips nodes with lat=0 sentinel)
// - affinity scoring (neighbor-graph aware)
// Fall back to a naive local nodes.filter() scan when the API failed or
// the hop wasn't in the server response. When a node MATCHES by
// prefix/pubkey but has no GPS coords, we still want its name/role/pubkey
// for the sidebar — flag it as {resolved:false, gpsless:true} so the
// renderer can label it "📍 no GPS" instead of "unresolved prefix".
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
const candidates = nodes.filter(n => {
// Try server resolution first
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true };
}
// Server resolved but node has no usable GPS
return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true };
}
// Fallback: naive local scan (kept for resilience when API is down).
const allMatches = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
});
if (candidates.length === 1) {
const c = candidates[0];
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length === 1) {
const c = withGps[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), pubkey: hop, resolved: false, candidates };
} else if (withGps.length > 1) {
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
} else if (allMatches.length >= 1) {
const c = allMatches[0];
return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true };
}
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
});
@@ -594,24 +647,59 @@
// 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, role: origin.role || 'companion', resolved: true, isOrigin: true };
const originHasRealGps = (lat, lon) => lat != null && lon != null && !(lat === 0 && lon === 0);
if (originHasRealGps(origin.lat, origin.lon)) {
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true, _fromPayload: 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 || 'companion', resolved: true, isOrigin: true };
if (match) {
if (originHasRealGps(match.lat, match.lon)) {
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, _fromPayload: true };
} else {
originPos = { name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: false, gpsless: true, isOrigin: true, _fromPayload: true };
}
}
}
if (originPos) positions.unshift(originPos);
}
// #1418 Phase D: append destination (recipient from decoded.destHash) so
// the route view shows: sender → [intermediate hops] → recipient.
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
// — show the node in the sidebar as gpsless rather than drawing it at
// (0,0) which would force fitBounds to span the visible route → Africa.
// _fromPayload: true so the renderer can visually mark these as "from
// payload" (different source-of-truth than path hops).
if (opts.destination && opts.destination.pubkey) {
const dpk = opts.destination.pubkey.toLowerCase();
const dmatch = nodes.find(n => n.public_key.toLowerCase() === dpk || n.public_key.toLowerCase().startsWith(dpk));
if (dmatch) {
const hasGps = dmatch.lat != null && dmatch.lon != null && !(dmatch.lat === 0 && dmatch.lon === 0);
if (hasGps) {
positions.push({ lat: dmatch.lat, lon: dmatch.lon, name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: true, _fromPayload: true });
} else {
positions.push({ name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: false, gpsless: true, _fromPayload: true });
}
}
}
if (positions.length < 1) return;
// 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.
// Hand off to sequence-primary sequence-primary renderer (#1418), falling
// back to the legacy role-aware MeshRoute (#1374), then to the minimal
// polyline (should never run in production).
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
window.MeshRouteView.render(map, routeLayer, positions, {
timestamp: opts.timestamp || Date.now(),
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
packetContext: opts.packetContext || null
});
return;
}
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
window.MeshRoute.render(map, routeLayer, positions, {
timestamp: opts.timestamp || Date.now()
@@ -629,6 +717,425 @@
}
}
// #1418 Phase C — multi-path renderer. Accepts an array of paths (each =
// {path: [hopKeys], observer, snr, rssi}), aggregates into canonical hops +
// per-edge observer-count (for stroke-width weighting), and dispatches to
// the sequence renderer with multi-path metadata. Falls back to single-path
// drawPacketRoute when only one observation is provided.
//
// opts.canonicalPath (optional): use this exact hop sequence as the canonical
// spine — i.e. the observation the operator selected. Without it, longest-path
// wins, which can show a totally different route than the user clicked.
async function drawPacketRouteMulti(paths, origin, opts) {
opts = opts || {};
if (typeof origin === 'string') origin = { pubkey: origin };
if (!Array.isArray(paths) || paths.length === 0) return;
if (paths.length === 1) {
return drawPacketRoute(paths[0].path || [], origin, opts);
}
// Pick canonical: prefer caller-supplied (operator's chosen observation),
// else fall back to longest path as the spine.
var canonicalPath;
if (opts.canonicalPath && Array.isArray(opts.canonicalPath) && opts.canonicalPath.length) {
canonicalPath = opts.canonicalPath;
} else {
const sortedByLen = paths.slice().sort((a, b) => (b.path || []).length - (a.path || []).length);
canonicalPath = sortedByLen[0].path || [];
}
const totalObservers = paths.length;
// Count hop & edge occurrences across all paths.
const hopCounts = {};
const edgeCounts = {};
paths.forEach(p => {
const hops = p.path || [];
hops.forEach(h => { hopCounts[h] = (hopCounts[h] || 0) + 1; });
for (let i = 0; i < hops.length - 1; i++) {
const key = hops[i] + '\u2192' + hops[i + 1];
edgeCounts[key] = (edgeCounts[key] || 0) + 1;
}
});
// Resolve canonical hops via /api/resolve-hops + naive fallback.
let serverResolved = null;
try {
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(canonicalPath.join(','));
const resp = await fetch(apiUrl, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
serverResolved = json && json.resolved ? json.resolved : null;
}
} catch (e) {}
const raw = canonicalPath.map(hop => {
const hopLower = String(hop).toLowerCase();
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
// hopCounts is keyed on the SHORT prefix from observation paths (e.g. "37"),
// but canonicalPath may carry full pubkeys when the operator's selected
// observation went through the resolver. Compute hop coverage by checking
// EVERY path[]: a hop in canonicalPath is "covered" by a path if the path
// contains an entry that is either an exact match OR a prefix of the hop's
// full pubkey.
let coverage = 0;
const hopFullKey = (srv && srv.pubkey) ? srv.pubkey.toLowerCase() : hopLower;
paths.forEach(pth => {
const phops = (pth.path || []).map(h => String(h).toLowerCase());
const matches = phops.some(ph => ph === hopFullKey || hopFullKey.startsWith(ph) || ph.startsWith(hopFullKey));
if (matches) coverage++;
});
const obsInfo = { observerCount: coverage || (hopCounts[hop] || 1), observerTotal: totalObservers };
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
return Object.assign({ lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true }, obsInfo);
}
return Object.assign({ name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true }, obsInfo);
}
const allMatches = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
});
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length >= 1) {
const c = withGps[0];
return Object.assign({ lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true }, obsInfo);
}
if (allMatches.length >= 1) {
const c = allMatches[0];
return Object.assign({ name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true }, obsInfo);
}
return Object.assign({ name: String(hop).slice(0,8), pubkey: hop, resolved: false }, obsInfo);
});
if (raw.length < 1) return;
if (origin && origin.pubkey) {
const op = nodes.find(n => n.public_key.toLowerCase() === origin.pubkey.toLowerCase() ||
n.public_key.toLowerCase().startsWith(origin.pubkey.toLowerCase()));
if (op) {
const hasGps = op.lat != null && op.lon != null && !(op.lat === 0 && op.lon === 0);
if (hasGps) {
raw.unshift({ lat: op.lat, lon: op.lon, name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
} else {
raw.unshift({ name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: false, gpsless: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
}
} else if (origin.name) {
raw.unshift({ name: origin.name, pubkey: origin.pubkey, role: 'companion', resolved: false, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
}
}
// #1418 Phase D: append destination (recipient from decoded.destHash) so
// the route view shows: sender → [intermediate hops] → recipient.
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
// — show the node in the sidebar as gpsless rather than drawing it at
// (0,0) which would force fitBounds to span SF→Africa.
if (opts.destination && opts.destination.pubkey) {
const dp = nodes.find(n => n.public_key.toLowerCase() === opts.destination.pubkey.toLowerCase() ||
n.public_key.toLowerCase().startsWith(opts.destination.pubkey.toLowerCase()));
if (dp) {
const hasGps = dp.lat != null && dp.lon != null && !(dp.lat === 0 && dp.lon === 0);
if (hasGps) {
raw.push({ lat: dp.lat, lon: dp.lon, name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: true, observerCount: totalObservers, observerTotal: totalObservers });
} else {
raw.push({ name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: false, gpsless: true, observerCount: totalObservers, observerTotal: totalObservers });
}
}
}
raw[raw.length - 1].isDest = true;
if (raw[0]) raw[0].isOrigin = true;
if (routeLayer) routeLayer.clearLayers();
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
window.MeshRouteView.render(map, routeLayer, raw, {
timestamp: opts.timestamp || Date.now(),
multiPath: true,
totalObservers: totalObservers,
edgeCounts: edgeCounts,
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
allPaths: paths,
packetContext: opts.packetContext || null
});
}
}
window.drawPacketRouteMulti = drawPacketRouteMulti;
// #1418/#1419: deep-link loader. Reads URL params
// #/map?packet=<hash>&obs=<observation_id>
// fetches the packet+observations from /api/packets/<hash>, picks the
// selected observation as canonical, and dispatches the same payload that
// the packets-page 'View on map' button would have set in sessionStorage.
// Without an obs param, the first observation is used.
async function loadRouteFromDeepLink() {
try {
const hash = location.hash || '';
const qs = hash.split('?')[1];
if (!qs) return;
const params = new URLSearchParams(qs);
const packetHash = params.get('packet');
const obsId = params.get('obs');
if (!packetHash) return;
// Wait for nodes to load (drawPacketRoute / Multi rely on `nodes` array
// for the local-fallback resolver).
if (!nodes || !nodes.length) {
await new Promise(r => setTimeout(r, 600));
}
const resp = await fetch('/api/packets/' + encodeURIComponent(packetHash));
if (!resp.ok) {
console.warn('[deep-link] /api/packets/' + packetHash + ' returned ' + resp.status);
return;
}
const data = await resp.json();
const pkt = data.packet || data;
const observations = data.observations || pkt.observations || [];
if (!observations.length) return;
// Pick the user-chosen observation by id, fall back to first
let chosen = null;
if (obsId) chosen = observations.find(o => String(o.id) === String(obsId));
if (!chosen) chosen = observations[0];
// Parse decoded for src/dst.
// Try observation first, fall back to packet-level decoded_json (GRP_TXT
// / TRACE packets often have channel + content at the packet level, not
// per-observation).
let decoded = {};
try {
const obsDec = JSON.parse(chosen.decoded_json || '{}');
const pktDec = JSON.parse(pkt.decoded_json || '{}');
decoded = Object.keys(obsDec).length ? obsDec : pktDec;
// If observation has some fields but missing channel/text, merge from packet.
if (decoded === obsDec && pktDec.channel) {
decoded = Object.assign({}, pktDec, obsDec);
}
} catch (_) {}
const origin = {};
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
const destination = decoded.destHash ? { pubkey: decoded.destHash } : null;
// Resolve the chosen observation's hops (canonical path).
// Priority: 1) server-side `resolved_path` (authoritative, eyeball-
// validated against packet detail), 2) client-side HopResolver (used
// by packets.js — does observer-IATA-aware geographic disambiguation),
// 3) raw prefixes (worst case, leaves naive lookup to drawPacketRoute).
let chosenPath = [];
let rawHops = [];
try { rawHops = JSON.parse(chosen.path_json || '[]'); } catch (_) {}
let resolvedHops = null;
try {
if (chosen.resolved_path) {
resolvedHops = typeof chosen.resolved_path === 'string' ? JSON.parse(chosen.resolved_path) : chosen.resolved_path;
}
} catch (_) {}
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
chosenPath = rawHops.map((h, i) => resolvedHops[i] || h);
} else if (window.HopResolver && typeof window.HopResolver.resolve === 'function' && rawHops.length) {
// Use the SAME resolver the packets page uses, so route view and
// packet detail agree on hop identities.
try {
// Sender + observer hints help disambiguation
const senderLat = decoded.lat || decoded.latitude || null;
const senderLon = decoded.lon || decoded.longitude || null;
let obsLat = null, obsLon = null;
// Try to find observer's coords for geographic affinity scoring
if (chosen.observer_id && Array.isArray(nodes)) {
const obs = nodes.find(n => (n.public_key || '').toLowerCase() === String(chosen.observer_id).toLowerCase());
if (obs && obs.lat != null && obs.lon != null) { obsLat = obs.lat; obsLon = obs.lon; }
}
// HopResolver.init may already have been done by packets.js; if not,
// do a minimal init from window data we have.
if (!window.HopResolver.ready || !window.HopResolver.ready()) {
try {
window.HopResolver.init(nodes || [], { observers: [], iataCoords: {} });
} catch (_) {}
}
const resolveResult = window.HopResolver.resolve(rawHops, senderLat, senderLon, obsLat, obsLon, chosen.observer_id);
chosenPath = rawHops.map(h => {
const r = resolveResult ? resolveResult[h] : null;
return r && r.pubkey ? r.pubkey : h;
});
} catch (_) {
chosenPath = rawHops;
}
} else {
chosenPath = rawHops;
}
// All observation paths for multi-path stroke weighting
const allPaths = observations.map(o => {
let p = [];
try { p = JSON.parse(o.path_json || '[]'); } catch (_) {}
return { path: p, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
}).filter(p => p.path && p.path.length > 0);
// #1418 Phase Y: derive packet context for the sidebar fact-list.
// type comes from decoded.type or pkt.payload_type. Resolve src/dst
// names from the nodes table when possible.
function resolveNameByHash(h) {
if (!h || !nodes || !nodes.length) return null;
const hL = String(h).toLowerCase();
const m = nodes.find(n => n.public_key.toLowerCase().startsWith(hL));
return m ? m.name : null;
}
// Map payload_type byte → string. Source: cmd/ingestor/decoder.go.
// 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT,
// 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE, 10=MULTIPART,
// 11=CONTROL, 12=RAW_CUSTOM.
const PAYLOAD_TYPE_MAP = {
0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT',
5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH',
9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 12: 'RAW_CUSTOM'
};
const inferredType = decoded.type || PAYLOAD_TYPE_MAP[pkt.payload_type] || 'OTHER';
// Try to peek at raw_hex bytes to extract src/destHash when decoded is empty.
// TXT_MSG/REQ/RESPONSE/ANON_REQ all have the same wire layout:
// byte0=route+type, byte1=path_len, then path bytes, then destHash + srcHash + encrypted body.
// ANON_REQ doesn't have a srcHash byte (sender is anonymous), only destHash.
let inferredSrc = decoded.srcHash || null;
let inferredDst = decoded.destHash || null;
const TYPES_WITH_DST_SRC = [1, 2, 7, 8]; // RESPONSE=1, TXT_MSG=2, ANON_REQ=7, PATH=8 — all carry hashes
if ((!inferredSrc || !inferredDst) && chosen.raw_hex && TYPES_WITH_DST_SRC.indexOf(pkt.payload_type) >= 0) {
try {
const hex = chosen.raw_hex;
// MeshCore wire max for path length is 64 hops. Cap defensively:
// a crafted ingest packet with pathLen=200 would slice random body
// bytes into the srcHash/destHash UI fields. Guard before use.
const pathLen = parseInt(hex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
throw new Error('pathLen out of range: ' + pathLen);
}
const destOff = 4 + pathLen * 2;
if (hex.length >= destOff + 2) {
inferredDst = inferredDst || hex.slice(destOff, destOff + 2).toUpperCase();
// ANON_REQ has no srcHash
if (pkt.payload_type !== 7 && hex.length >= destOff + 4) {
inferredSrc = inferredSrc || hex.slice(destOff + 2, destOff + 4).toUpperCase();
}
}
} catch (_) {}
}
// GRP_TXT: channel_hash byte sits right after path bytes in raw_hex.
// Layout: byte0=route+type, byte1=path_len, path bytes, channel_hash, encrypted body.
let inferredChannelHash = decoded.channelHashHex || null;
if (!inferredChannelHash && chosen.raw_hex && pkt.payload_type === 5) {
try {
const hex = chosen.raw_hex;
const pathLen = parseInt(hex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
throw new Error('pathLen out of range: ' + pathLen);
}
const chOff = 4 + pathLen * 2;
if (hex.length >= chOff + 2) {
inferredChannelHash = hex.slice(chOff, chOff + 2).toUpperCase();
}
} catch (_) {}
}
const pktCtx = {
type: inferredType,
decoded: Object.assign({}, decoded, { srcHash: inferredSrc, destHash: inferredDst, channelHashHex: inferredChannelHash || decoded.channelHashHex }),
payloadType: pkt.payload_type || null,
srcResolvedName: inferredSrc ? resolveNameByHash(inferredSrc) : null,
destResolvedName: inferredDst ? resolveNameByHash(inferredDst) : null,
observedHops: chosenPath.length,
observationCount: observations.length
};
// GRP_TXT: try to resolve the channel name from /api/channels.
if (inferredType === 'GRP_TXT' && inferredChannelHash) {
try {
const chResp = await fetch('/api/channels?includeEncrypted=true');
if (chResp.ok) {
const chData = await chResp.json();
const chList = chData.channels || [];
const wantUp = inferredChannelHash.toUpperCase();
// Match by:
// 1) hash field starts with target (full hex hash)
// 2) hash == "enc_<HEX>" (no-key fallback channels)
// 3) name contains "0x<HEX>" (encrypted placeholder)
// 4) for keyed channels: compute SHA256(name)[0] === target byte
// (browser SubtleCrypto — async)
let match = chList.find(c => {
const ch = String(c.hash || '').toUpperCase();
const nm = String(c.name || '').toUpperCase();
return ch.startsWith(wantUp) ||
ch === 'ENC_' + wantUp ||
nm.includes('0X' + wantUp);
});
// If not matched and we have SubtleCrypto, try SHA256 lookup
if (!match && window.crypto && window.crypto.subtle) {
for (const c of chList) {
if (c.encrypted) continue; // skip the enc_ placeholders
const name = c.name || '';
if (!name) continue;
try {
const buf = new TextEncoder().encode(name);
const hashBuf = await window.crypto.subtle.digest('SHA-256', buf);
const arr = new Uint8Array(hashBuf);
const byteHex = arr[0].toString(16).padStart(2, '0').toUpperCase();
if (byteHex === wantUp) { match = c; break; }
} catch (_) {}
}
}
if (match) {
const isEnc = !!match.encrypted || /^enc_/i.test(match.hash || '');
pktCtx.channelName = isEnc ? ('Encrypted (0x' + inferredChannelHash + ')') : (match.name || '#' + inferredChannelHash);
pktCtx.channelEncrypted = isEnc;
if (match.lastMessage && match.lastMessage !== 'Encrypted — click to decrypt') {
pktCtx.channelLastMessage = match.lastMessage;
}
}
}
} catch (_) {}
if (!pktCtx.channelName) {
pktCtx.channelName = 'channel 0x' + inferredChannelHash;
}
// Fetch decrypted text for this specific packet (only if not encrypted-only)
if (!pktCtx.channelEncrypted) {
try {
const cleanName = (pktCtx.channelName || '').replace(/^#/, '');
const msgResp = await fetch('/api/channels/' + encodeURIComponent(cleanName) + '/messages?limit=10');
if (msgResp.ok) {
const msgs = await msgResp.json();
const m = (msgs.messages || []).find(mm => mm.packet_hash === packetHash || mm.hash === packetHash);
if (m && (m.text || m.plainText)) pktCtx.decryptedText = m.text || m.plainText;
}
} catch (_) {}
}
}
if (allPaths.length === 0) {
// Polish review (doshi #1423): operator clicked a ?packet=<hash> URL
// but every observation had an empty path. Previously this bailed
// silently (map stayed where it was, no message). Surface a console
// breadcrumb + a brief toast so the operator knows why nothing
// rendered.
console.warn('[deep-link] packet ' + packetHash + ' has no observed route data (all observations had empty path)');
try {
var toast = document.createElement('div');
toast.className = 'mc-rt-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);' +
'background:var(--mc-bg-secondary,#1a1a1a);color:var(--mc-text-primary,#e5e5e5);' +
'padding:10px 16px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);' +
'z-index:10000;font:13px/1.4 system-ui,sans-serif;max-width:80vw;text-align:center;';
toast.textContent = 'Packet has no observed route data.';
document.body.appendChild(toast);
setTimeout(function () { try { toast.remove(); } catch (_) {} }, 4000);
} catch (e) { console.warn('[deep-link] toast failed:', e); }
return;
}
if (allPaths.length === 1) {
drawPacketRoute(chosenPath, origin, { destination: destination, packetHash: packetHash, observationId: obsId, packetContext: pktCtx });
} else {
drawPacketRouteMulti(allPaths, origin, {
packetHash: packetHash,
observationId: obsId,
canonicalPath: chosenPath,
destination: destination,
packetContext: pktCtx
});
}
} catch (e) {
console.warn('[deep-link] route load failed', e);
}
}
async function loadNodes() {
try {
// Load regions from config + observed IATAs
+38 -5
View File
@@ -3076,11 +3076,44 @@
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
sessionStorage.setItem('map-route-hops', JSON.stringify({
origin: origin,
hops: resolvedKeys
}));
window.location.hash = '#/map?route=1';
// #1418 Phase D: also include the recipient (destHash) so the route
// displays as: sender → [intermediate hops] → recipient. Without
// this the destination node is invisible — operator only sees the
// last intermediate repeater.
const destination = {};
if (decoded.destHash) destination.pubkey = decoded.destHash;
// #1418 Phase C: include ALL observations as alternate paths so the
// route view can render union-of-edges with stroke-width weighting.
// Each observation contributes its own path_json array.
const allPaths = (observations || []).map(o => {
let path = [];
try { path = JSON.parse(o.path_json || '[]'); } catch (_) {}
return { path: path, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
}).filter(p => p.path && p.path.length > 0);
// #1418/#1419: navigate via deep-link URL only. The map page's
// loadRouteFromDeepLink() re-fetches the packet from the API and
// builds the full payload (incl. packetContext) consistently.
// SessionStorage was unreliable — the deep-link path includes
// packetContext but the sessionStorage payload didn't, leading
// to missing chip + facts when entered from the packets page.
const obsId = currentObs ? currentObs.id : (observations[0] && observations[0].id);
const pkHash = pkt.hash || pkt.packet_hash;
const obsPart = obsId ? '&obs=' + encodeURIComponent(obsId) : '';
// Tufte audit fix: close ALL mobile packet panels so operator lands
// on the route view, not behind a still-visible detail sheet.
// Three different panels exist depending on viewport + flow:
// - #pktRight (desktop split-pane)
// - .slide-over-panel (mid-width SlideOver)
// - #mobileDetailSheet (small-mobile bottom sheet)
if (window.innerWidth <= 767) {
try { closeDetailPanel(); } catch (_) {}
try { if (window.SlideOver && window.SlideOver.close) window.SlideOver.close(); } catch (_) {}
try {
const sheet = document.getElementById('mobileDetailSheet');
if (sheet) sheet.classList.remove('open');
} catch (_) {}
}
window.location.hash = '#/map?packet=' + encodeURIComponent(pkHash) + obsPart;
} catch {
window.location.hash = '#/map';
}
+727
View File
@@ -0,0 +1,727 @@
/* route-view.css — minimal route view layout + styling. */
body.mc-route-active #leaflet-map { left: 320px !important; width: calc(100% - 320px) !important; }
/* Auto-collapse Map Controls panel when route view opens. The toggle button
(.map-controls-toggle) stays visible clicking it expands the panel.
Map controls JS uses the `.collapsed` class for its own toggle state. */
body.mc-route-active .map-controls.collapsed { display: none !important; }
body.mc-route-active #pktRight,
body.mc-route-active .slide-over-panel,
body.mc-route-active .slide-over-backdrop,
body.mc-route-active .mobile-detail-sheet { display: none !important; }
/* Hide regular node clusters and topology markers during route view so the
route polyline + its own markers aren't lost in a 600-node mesh. The route
layer's own markers use .mc-rt-marker-icon and are NOT hidden. */
body.mc-route-active .leaflet-marker-pane .meshcore-marker { display: none !important; }
body.mc-route-active .leaflet-marker-pane .meshcore-label-marker { display: none !important; }
body.mc-route-active .leaflet-marker-pane .marker-cluster { display: none !important; }
/* CoreScope custom cluster bubble wrappers (not the leaflet.markercluster ones) */
body.mc-route-active .leaflet-marker-pane .mc-cluster-wrap { display: none !important; }
/* Hide overlay-pane SVG paths that aren't part of the route. The route's
own polylines have class="mc-rt-edge". */
body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display: none !important; }
.mc-rt-sidebar {
position: fixed;
top: 52px; /* below top-nav */
left: 0;
bottom: 0;
width: 320px;
background: var(--surface, #1a1a1a);
border-right: 1px solid var(--border, #333);
color: var(--text, #e7e7e7);
font: 13px/1.4 system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
z-index: 500;
box-shadow: 2px 0 8px rgba(0,0,0,0.4);
}
.mc-rt-header {
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border, #333);
position: relative;
}
.mc-rt-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding-right: 36px; /* leave room for close button */
}
.mc-rt-back-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--accent, #06b6d4);
text-decoration: none;
padding: 2px 6px;
border-radius: 3px;
}
.mc-rt-back-link:hover {
background: var(--bg-hover, rgba(120,160,255,0.12));
text-decoration: underline;
}
.mc-rt-back-link:focus {
outline: 2px solid var(--accent, #06b6d4);
outline-offset: 1px;
}
.mc-rt-title { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
.mc-rt-meta { font-size: 11px; color: var(--text-muted, #94a3b8); margin-top: 2px; }
.mc-rt-multipath-chip {
margin-top: 4px;
padding: 4px 8px;
background: var(--surface-2, #232323);
border: 1px solid var(--border, #333);
border-radius: 4px;
font-size: 11px;
color: var(--text, #cbd5e1);
font-family: ui-monospace, Menlo, monospace;
}
.mc-rt-multipath-chip b { color: var(--text, #fff); }
.mc-rt-multipath-key {
margin-top: 3px;
font-size: 10px;
color: var(--text-muted, #94a3b8);
font-style: italic;
font-family: system-ui, sans-serif;
}
/* Multi-path picker — Click a path to isolate it on the map. */
.mc-rt-paths {
margin-top: 6px;
background: var(--surface-2, #232323);
border: 1px solid var(--border, #333);
border-radius: 4px;
font-size: 11px;
max-height: 180px;
overflow: hidden;
}
.mc-rt-paths[open] { overflow: auto; max-height: 180px; }
.mc-rt-paths-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
cursor: pointer;
font-size: 10px;
color: var(--text-muted, #94a3b8);
text-transform: uppercase;
letter-spacing: 0.5px;
list-style: none;
font-weight: 600;
}
.mc-rt-paths-header::-webkit-details-marker { display: none; }
.mc-rt-paths-header::before {
content: '▾';
margin-right: 4px;
font-size: 9px;
transition: transform 120ms;
}
.mc-rt-paths:not([open]) .mc-rt-paths-header::before { transform: rotate(-90deg); }
.mc-rt-path-clear {
background: transparent;
border: 1px solid var(--border, #444);
color: var(--text-muted, #94a3b8);
font-size: 9px;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mc-rt-path-clear:hover {
background: var(--bg-hover, rgba(120,160,255,0.12));
color: var(--text, #fff);
}
.mc-rt-path-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--border, #333);
}
.mc-rt-path-row {
display: grid;
grid-template-columns: 36px 1fr auto;
column-gap: 6px;
align-items: center;
padding: 4px 8px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-family: ui-monospace, Menlo, monospace;
}
.mc-rt-path-row:hover {
background: var(--bg-hover, rgba(120,160,255,0.08));
}
.mc-rt-path-row.mc-rt-path-active {
background: var(--bg-hover, rgba(120,160,255,0.18));
border-left: 3px solid var(--accent, #06b6d4);
padding-left: 5px;
}
.mc-rt-path-row:focus {
outline: 2px solid var(--accent, #06b6d4);
outline-offset: -2px;
}
.mc-rt-path-count {
font-weight: 600;
font-size: 10px;
color: var(--text-muted, #94a3b8);
text-align: right;
}
.mc-rt-path-hops {
font-size: 10px;
color: var(--text, #cbd5e1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-rt-path-obs {
font-size: 9px;
color: var(--text-muted, #94a3b8);
font-family: system-ui, sans-serif;
max-width: 80px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-rt-spark-wrap {
margin: 8px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.mc-rt-spark-title {
font-size: 10px;
color: var(--text-muted, #94a3b8);
display: flex;
justify-content: space-between;
align-items: center;
}
.mc-rt-spark-title b { color: var(--text, #e7e7e7); font-weight: 600; }
.mc-rt-spark { display: block; cursor: pointer; }
.mc-rt-spark-dot { cursor: pointer; }
.mc-rt-spark-dot:hover { r: 3; }
.mc-rt-spark-tooltip {
position: absolute;
background: var(--surface-2, #2a2a2a);
border: 1px solid var(--border, #444);
border-radius: 4px;
padding: 4px 8px;
font: 11px ui-monospace, Menlo, monospace;
color: var(--text, #e7e7e7);
pointer-events: none;
z-index: 9999;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
}
.mc-rt-close {
position: absolute; top: 10px; right: 10px;
background: transparent; border: 1px solid var(--border, #333);
color: var(--text, #e7e7e7); border-radius: 4px;
width: 26px; height: 26px; cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
.mc-rt-close:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); }
.mc-rt-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
.mc-rt-pinned { padding: 2px 0; }
.mc-rt-pinned-top { border-bottom: 1px solid var(--border, #333); }
.mc-rt-pinned-bottom { border-top: 1px solid var(--border, #333); }
.mc-rt-pinned .mc-rt-row { background: var(--surface-2, #232323); font-weight: 600; }
.mc-rt-pinned .mc-rt-row::before {
content: '';
display: inline-block;
font-size: 9px; letter-spacing: 1px;
color: var(--text-muted, #94a3b8);
text-transform: uppercase;
}
.mc-rt-pinned-top .mc-rt-row::before { content: 'SRC'; padding-right: 4px; }
.mc-rt-pinned-bottom .mc-rt-row::before { content: 'DST'; padding-right: 4px; }
.mc-rt-row {
position: relative;
display: grid;
grid-template-columns: 4px 22px 18px 1fr auto;
grid-template-rows: auto 4px;
column-gap: 6px;
align-items: center;
padding: 4px 12px 4px 0;
cursor: pointer;
font-family: inherit;
border-bottom: 1px solid rgba(255,255,255,0.02);
}
.mc-rt-row:hover,
.mc-rt-row:focus,
.mc-rt-row.mc-rt-row-active {
background: var(--bg-hover, rgba(120,160,255,0.08));
outline: none;
}
.mc-rt-stripe {
grid-column: 1; grid-row: 1 / -1;
width: 4px; height: 100%;
background: var(--mc-rt-row-color, transparent);
}
.mc-rt-seq {
grid-column: 2; grid-row: 1;
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
color: var(--text-muted, #94a3b8);
text-align: right;
}
.mc-rt-glyph {
grid-column: 3; grid-row: 1;
text-align: center; font-size: 12px;
}
.mc-rt-name {
grid-column: 4; grid-row: 1;
font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mc-rt-obs-chip {
display: inline-block;
font-size: 9px; padding: 0 4px;
background: var(--surface-2, #2a2a2a);
border: 1px solid var(--border, #444);
border-radius: 8px;
margin-left: 4px;
color: var(--text-muted, #94a3b8);
font-family: ui-monospace, Menlo, monospace;
}
.mc-rt-status-chip {
display: inline-block;
font-size: 9px; padding: 0 4px;
border-radius: 8px;
margin-left: 4px;
font-weight: 600;
white-space: nowrap;
}
.mc-rt-status-nogps {
background: rgba(245, 158, 11, 0.18);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.4);
}
.mc-rt-status-unknown {
background: rgba(148, 163, 184, 0.18);
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.4);
}
.mc-rt-status-payload {
background: rgba(6, 182, 212, 0.15);
color: #67e8f9;
border: 1px solid rgba(6, 182, 212, 0.35);
font-style: italic;
}
.mc-rt-distlabel {
grid-column: 5; grid-row: 1;
font-family: ui-monospace, Menlo, monospace;
font-size: 10px;
color: var(--text-muted, #94a3b8);
}
.mc-rt-distbar-wrap {
grid-column: 2 / -1; grid-row: 2;
height: 3px;
background: transparent;
margin-top: 2px;
}
.mc-rt-distbar {
height: 100%;
border-radius: 1.5px;
}
.mc-rt-unresolved .mc-rt-name { color: var(--text-muted, #94a3b8); font-style: italic; }
/* Drill-in expanding panel (hop detail) */
.mc-rt-row.mc-rt-row-expanded {
background: var(--bg-hover, rgba(120,160,255,0.12));
}
.mc-rt-detail-panel {
grid-column: 1 / -1;
grid-row: 3;
padding: 8px 10px 10px;
background: var(--surface-2, #1d1d1d);
border-top: 1px solid var(--border, #333);
font-size: 11px;
line-height: 1.5;
color: var(--text, #e7e7e7);
margin-top: 2px;
}
.mc-rt-row { grid-template-rows: auto 4px auto; }
.mc-rt-detail-loading,
.mc-rt-detail-na { color: var(--text-muted, #94a3b8); font-style: italic; font-size: 10px; }
.mc-rt-detail-row1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin-bottom: 4px; }
.mc-rt-detail-name { font-weight: 700; color: var(--text, #fff); font-size: 12px; }
.mc-rt-detail-warn {
background: rgba(245, 158, 11, 0.18);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.4);
padding: 0 4px;
border-radius: 3px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mc-rt-detail-meta {
font-family: ui-monospace, Menlo, monospace;
font-size: 10px;
color: var(--text-muted, #94a3b8);
}
.mc-rt-detail-label {
display: inline-block;
width: 50px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #94a3b8);
}
.mc-rt-detail-snr,
.mc-rt-detail-relay,
.mc-rt-detail-also { margin: 2px 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.mc-rt-detail-spark { vertical-align: middle; color: var(--text, #cbd5e1); }
.mc-rt-detail-spark-meta { font-size: 9px; color: var(--text-muted, #94a3b8); font-family: ui-monospace, Menlo, monospace; }
.mc-rt-detail-link { color: var(--accent, #06b6d4); text-decoration: none; }
.mc-rt-detail-link:hover { text-decoration: underline; }
.mc-rt-detail-link:focus {
outline: 2px solid var(--accent, #06b6d4);
outline-offset: 2px;
border-radius: 3px;
}
.mc-rt-detail-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--surface-2, #232323);
border: 1px solid var(--border, #333);
border-radius: 4px;
font-weight: 600;
font-size: 11px;
transition: background 120ms, border-color 120ms;
}
.mc-rt-detail-action:hover {
background: var(--bg-hover, rgba(120,160,255,0.12));
border-color: var(--accent, #06b6d4);
text-decoration: none;
}
.mc-rt-route-badge {
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, #444);
border-radius: 8px;
padding: 1px 5px;
font-family: ui-monospace, Menlo, monospace;
font-size: 9px;
color: var(--text-muted, #94a3b8);
font-weight: 500;
}
/* Marker styles — no chips, just shape. */
.mc-rt-marker-icon { background: transparent !important; border: none !important; }
.mc-rt-marker { position: relative; line-height: 0; cursor: pointer; transition: transform 120ms ease-out; }
.mc-rt-marker:hover,
.mc-rt-marker.mc-rt-hover { transform: scale(1.5); z-index: 1000 !important; }
.mc-rt-marker:focus { outline: 2px solid #06b6d4; outline-offset: 2px; border-radius: 50%; }
/* packet-context block (type chip + 3-5 facts). Above multi-path. */
.mc-rt-ctx {
margin: 6px 0 4px;
padding: 6px 8px;
background: var(--surface-2, #1f1f1f);
border-left: 3px solid var(--accent, #06b6d4);
border-radius: 0 4px 4px 0;
font-size: 11px;
line-height: 1.4;
}
.mc-rt-ctx-chip {
display: inline-block;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #94a3b8);
margin-bottom: 4px;
}
.mc-rt-ctx-glyph {
font-size: 12px;
margin-right: 2px;
vertical-align: middle;
}
.mc-rt-ctx-facts { display: flex; flex-direction: column; gap: 2px; }
.mc-rt-ctx-line { color: var(--text, #cbd5e1); }
.mc-rt-ctx-line b { color: var(--text, #fff); font-weight: 600; }
.mc-rt-ctx-arrow { color: var(--text-muted, #94a3b8); margin: 0 4px; }
.mc-rt-ctx-meta { color: var(--text-muted, #94a3b8); font-size: 10px; }
.mc-rt-ctx-mono { font-family: ui-monospace, Menlo, monospace; font-size: 10px; color: var(--text-muted, #94a3b8); }
.mc-rt-ctx-quote {
font-style: italic;
color: var(--text, #fff);
padding-left: 6px;
border-left: 2px solid var(--border, #444);
font-family: ui-monospace, Menlo, monospace;
font-size: 10px;
}
1,2,3 on the map without scrubbing the sidebar. Origin (square) + dest
(triangle) are shape-differentiated and don't need numeric labels. */
.mc-rt-marker-seq {
position: absolute;
top: 1px;
left: 14px; /* to the right of the marker dot, no overlap */
background: var(--surface, #1a1a1a);
color: var(--text, #fff);
border: 1px solid var(--border, #666);
border-radius: 5px;
min-width: 13px;
height: 11px;
padding: 0 2px;
font: 700 8px/11px ui-monospace, Menlo, monospace;
text-align: center;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
z-index: 2;
white-space: nowrap;
}
/* Hover/focus on a marker pops its seq label so it's never hidden by
neighbors at high density. */
.mc-rt-marker:hover .mc-rt-marker-seq,
.mc-rt-marker:focus .mc-rt-marker-seq,
.mc-rt-marker.mc-rt-hover .mc-rt-marker-seq {
z-index: 1000;
transform: scale(1.4);
transform-origin: left center;
}
/* Mobile: map dominates (75vh), sidebar is a compact bottom strip (25vh)
showing just packet type + hop count + distance + summary. Operator's job:
see the route on the map first, scroll the strip for hop list. */
/* Mobile bottom-sheet handle (visible only on mobile) — hidden by default */
.mc-rt-mobile-handle { display: none; }
.mc-rt-collapsed-label { display: none; }
/* Desktop resize handle on the right edge of the sidebar */
.mc-rt-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
z-index: 10;
background: transparent;
transition: background 120ms;
}
.mc-rt-resize-handle:hover,
.mc-rt-resize-handle:focus {
background: var(--accent, #06b6d4);
opacity: 0.4;
}
/* Desktop collapse button sits on the RIGHT edge of the sidebar,
vertically centered. Standard Material/Drive-style affordance: chevron
pointing into the panel = collapse, out of the panel = expand. */
.mc-rt-collapse-btn {
position: absolute;
top: 50%;
right: -14px;
transform: translateY(-50%);
background: var(--surface-2, #232323);
border: 1px solid var(--border, #333);
color: var(--text-muted, #94a3b8);
font-size: 12px;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
z-index: 12;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
box-shadow: 1px 0 3px rgba(0,0,0,0.4);
}
.mc-rt-collapse-btn:hover {
background: var(--bg-hover, rgba(120,160,255,0.12));
color: var(--text, #fff);
border-color: var(--accent, #06b6d4);
}
/* Vertical "ROUTE" label shown only when collapsed */
.mc-rt-collapsed-label {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
transform-origin: center;
white-space: nowrap;
font-size: 11px;
letter-spacing: 2px;
font-weight: 700;
color: var(--text-muted, #94a3b8);
text-transform: uppercase;
cursor: pointer;
user-select: none;
}
/* Collapsed state on desktop */
.mc-rt-sidebar.mc-rt-collapsed {
width: 36px !important;
min-width: 36px;
}
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-header,
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-list,
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-pinned,
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-resize-handle { display: none; }
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapsed-label { display: block; }
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapse-btn {
top: 50%;
right: -14px;
transform: translateY(-50%);
}
/* When sidebar is collapsed, expand map to fill */
body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
left: 36px !important;
width: calc(100% - 36px) !important;
}
@media (max-width: 767px) {
/* Mobile: hide desktop collapse + resize affordances (mobile uses bottom-sheet) */
body.mc-route-active .mc-rt-collapse-btn,
body.mc-route-active .mc-rt-resize-handle,
body.mc-route-active .mc-rt-collapsed-label { display: none !important; }
body.mc-route-active #leaflet-map {
position: fixed !important;
top: 52px !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
/* iOS Safari/Edge: use dvh (dynamic viewport) so URL bar collapse
doesn't leave a stale layout. Fall back to vh for browsers that
don't support dvh yet. */
bottom: calc(116px + env(safe-area-inset-bottom, 0px)) !important;
height: auto !important;
z-index: 100 !important;
}
body.mc-route-active.mc-rt-mobile-sheet-expanded #leaflet-map {
bottom: calc(75vh + 56px + env(safe-area-inset-bottom, 0px)) !important;
}
body.mc-route-active .map-controls-toggle {
position: fixed !important;
top: 60px !important;
right: 8px !important;
z-index: 1100 !important;
/* Force overlay — no normal-flow row consumption */
margin: 0 !important;
width: 36px !important;
height: 36px !important;
}
body.mc-route-active .map-controls {
position: fixed !important;
top: 100px !important;
right: 8px !important;
left: 8px !important;
width: auto !important;
max-height: 60vh !important;
z-index: 1090 !important;
overflow-y: auto;
}
.mc-rt-sidebar {
position: fixed;
top: auto !important;
left: 0 !important;
right: 0;
/* Sit ABOVE the bottom-nav (56px) + iOS safe-area inset */
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
width: 100%;
height: 60px;
max-height: 60px;
transition: height 240ms cubic-bezier(0.4, 0, 0.2, 1);
border-right: none;
border-top: 1px solid var(--border, #333);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.5);
overflow: hidden;
z-index: 1190; /* below bottom-nav (1200) but above content */
}
.mc-rt-sidebar.mc-rt-mobile-expanded {
height: 75vh;
max-height: 75vh;
overflow-y: auto;
}
/* Bigger touch target — full sheet header tappable, large chevron */
.mc-rt-mobile-handle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px 8px;
cursor: pointer;
background: var(--surface, #1a1a1a);
user-select: none;
height: 60px;
box-sizing: border-box;
position: relative;
/* Prevent the page from scrolling when swiping on this area */
touch-action: none;
}
.mc-rt-mobile-grip {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 6px;
background: var(--text-muted, #94a3b8);
border-radius: 3px;
opacity: 0.6;
/* Large tap target around the grip */
cursor: grab;
}
.mc-rt-mobile-grip::before {
content: '';
position: absolute;
top: -12px;
left: -20px;
right: -20px;
bottom: -12px;
}
.mc-rt-mobile-chevron {
font-size: 22px;
color: var(--text-muted, #94a3b8);
margin-top: 8px;
padding: 4px 8px;
transition: transform 240ms;
/* Make the chevron itself a generous tap target */
min-width: 32px;
text-align: center;
}
.mc-rt-mobile-summary {
flex: 1;
font-size: 11px;
color: var(--text, #cbd5e1);
font-family: ui-monospace, Menlo, monospace;
margin-top: 10px;
line-height: 1.3;
max-height: 36px;
overflow: hidden;
}
.mc-rt-mobile-hex {
color: var(--text-muted, #94a3b8);
font-size: 9px;
display: block;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-mobile-chevron {
transform: rotate(180deg);
}
/* Hide full content when collapsed; show when expanded */
.mc-rt-sidebar .mc-rt-header,
.mc-rt-sidebar .mc-rt-list,
.mc-rt-sidebar .mc-rt-pinned { display: none; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-header { display: block; padding: 8px 12px 6px; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-list { display: block; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-pinned { display: block; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-ctx { margin: 4px 0 2px; padding: 4px 6px; font-size: 11px; }
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-row { padding: 3px 10px 3px 0; font-size: 11px; }
}
+1588
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -505,6 +505,14 @@ input[type="week"] {
position: sticky; top: 0; z-index: 1100;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
flex-wrap: nowrap; overflow: hidden; min-width: 0;
/* #1413: enforce a minimum horizontal gap between flex children of
.top-nav (.nav-left and .nav-right). Without this the only thing
pushing nav-right away from nav-left was justify-content: space-
between, which collapses to zero whenever nav-left's intrinsic
content fills the row including the historic vw~1101..1599 band
where .nav-stats reappears (display:flex) and shoves nav-right
leftward to overlap the .nav-more-btn. */
column-gap: 16px;
}
/* #1403: removed overflow:hidden — was clipping flex children offscreen (vertical) AND clipping the More dropdown contents. The original purpose was to prevent horizontal overflow during Priority+ measurement (#1066) — that purpose is served by .top-nav itself which still has overflow:hidden. */
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; }
@@ -594,7 +602,7 @@ input[type="week"] {
}
}
.nav-links { display: flex; align-items: center; gap: var(--space-xs); }
.nav-links { display: flex; align-items: center; gap: var(--space-xs); flex: 1 1 auto; min-width: 0; }
.nav-link {
color: var(--nav-text-muted); text-decoration: none;
padding: 14px clamp(8px, 0.6vw + 4px, 14px); font-size: var(--fs-sm);
+8
View File
@@ -29,6 +29,14 @@ node test-observers-headings.js
node test-marker-outline-weight.js
node test-traces.js
# #1418 — route-view v2 (Tufte) coverage
node test-issue-1418-raw-hex-extraction.js
node test-issue-1418-edge-weights.js
node test-issue-1418-cb-preset-ramp.js
node test-issue-1418-spider-fan.js
node test-issue-1418-deeplink-hops-channels.js
node test-issue-1418-polish-review.js
echo ""
echo "═══════════════════════════════════════"
echo " All tests passed"
+229
View File
@@ -0,0 +1,229 @@
/**
* #1412 customizer nodeColors must NOT auto-push server config into
* ROLE_COLORS overrides, or it defeats CB-preset propagation.
*
* Bug (CDP-verified on staging): PR #1408 made window.ROLE_COLORS a live
* getter that reads --mc-role-* CSS vars. cb-presets.applyPreset() writes
* those vars, so consumers SHOULD see new colors. But customize-v2.js:553
* runs early on every page load and pushes effectiveConfig.nodeColors
* (server config, legacy April palette) into the override map, which the
* getter prefers over CSS vars. Net effect: ROLE_COLORS is frozen on the
* legacy palette forever; presets only update the CSS, not the JS.
*
* Fix: server-config nodeColors must only write --node-* CSS var (legacy
* compat for anything still reading --node-*). It must NOT touch the
* override map. User-chosen colors in the customizer continue to win via
* setRoleColorOverride() (explicit, intentional override).
*
* Test strategy: extract the actual code block from customize-v2.js that
* processes effectiveConfig.nodeColors, run it in a vm sandbox with a
* legacy-palette config, apply preset "deut", assert ROLE_COLORS reflects
* the preset (not the server config).
*
* Mutation guard: re-introducing the `window.ROLE_COLORS[role] = nc[role]`
* write to customize-v2.js makes the first test fail.
*/
'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 rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
const cv2Src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
// Browser-ish sandbox (CSS var setProperty/getPropertyValue).
function makeSandbox() {
const root = {
style: {
_vars: {},
setProperty(k, v) { this._vars[k] = String(v); },
getPropertyValue(k) { return this._vars[k] || ''; },
removeProperty(k) { delete this._vars[k]; }
},
getAttribute() { return null; },
setAttribute() {}
};
const body = {
_attrs: {},
setAttribute(k, v) { this._attrs[k] = v; },
getAttribute(k) { return this._attrs[k] || null; },
removeAttribute(k) { delete this._attrs[k]; },
dataset: {}
};
const sandbox = {
window: null,
document: {
documentElement: root,
body: body,
readyState: 'complete',
getElementById() { return null; },
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
head: { appendChild() {} },
addEventListener() {},
},
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
addEventListener() {},
dispatchEvent() { return true; },
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
matchMedia: function () { return { matches: false }; },
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
Event: function (type) { this.type = type; },
getComputedStyle: function () {
return { getPropertyValue: function (k) { return (root.style._vars[k] || ''); } };
}
};
sandbox.window = sandbox;
return { sandbox, root, body };
}
// ─── Extract the two nodeColors-processing blocks from customize-v2.js. ───
// We want to execute the REAL source so reverting the fix breaks the test.
// Block 1: the effective-config apply path (≈ line 550).
// Block 2: the early-overrides apply path (≈ line 2146).
function extractBlock(src, anchor) {
const idx = src.indexOf(anchor);
if (idx === -1) throw new Error('anchor not found: ' + anchor);
// Walk forward to the matching closing brace of the surrounding `if (nc) { ... }`.
// Slice forward a generous window then balance braces from the first '{' after anchor.
const start = src.indexOf('{', idx);
if (start === -1) throw new Error('open brace not found after anchor');
let depth = 0, end = -1;
for (let i = start; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
if (end === -1) throw new Error('matching close brace not found');
return src.slice(idx, end + 1);
}
// Block A — main effective-config push: `var nc = effectiveConfig.nodeColors;`
const blockA = extractBlock(cv2Src, 'var nc = effectiveConfig.nodeColors;');
// Block B — early overrides: `if (earlyOverrides.nodeColors) {`
const blockB = extractBlock(cv2Src, 'if (earlyOverrides.nodeColors) {');
console.log('\n=== #1412 A: server-config nodeColors does NOT clobber preset ROLE_COLORS ===');
{
const env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
// Simulate user choosing the "deut" preset.
env.sandbox.window.MeshCorePresets.applyPreset('deut');
// CSS var should be IBM orange now.
assert(env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase() === '#fe6100',
'precondition: --mc-role-repeater is #FE6100 after applyPreset("deut")');
// Now simulate customize-v2 picking up the server config (legacy palette).
const setupBlockA =
'var root = document.documentElement.style;\n' +
'var effectiveConfig = { nodeColors: { repeater: "#dc2626", companion: "#2563eb", room: "#16a34a", sensor: "#d97706", observer: "#8b5cf6" } };\n' +
blockA + '\n';
vm.runInContext(setupBlockA, env.sandbox);
// The --node-* CSS vars should still be written for legacy consumers.
assert(env.root.style.getPropertyValue('--node-repeater') === '#dc2626',
'--node-repeater CSS var is still written (legacy compat preserved)');
// The KEY assertion: ROLE_COLORS must still reflect the preset, NOT the
// server-config legacy palette.
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(got === '#fe6100',
'ROLE_COLORS.repeater === #FE6100 after server-config push (got ' + got + ')');
const gotCompanion = String(env.sandbox.window.ROLE_COLORS.companion).toLowerCase();
assert(gotCompanion !== '#2563eb',
'ROLE_COLORS.companion is NOT the server-config legacy #2563eb (got ' + gotCompanion + ')');
}
console.log('\n=== #1412 B: early-overrides path also stays out of ROLE_COLORS override map ===');
{
const env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
env.sandbox.window.MeshCorePresets.applyPreset('deut');
const setupBlockB =
'var root = document.documentElement.style;\n' +
'var earlyOverrides = { nodeColors: { repeater: "#dc2626", companion: "#2563eb" } };\n' +
blockB + '\n';
// earlyOverrides path also writes --node-* and (per fix) only --node-*.
// The extracted block may not write --node-* — that's fine; we only care
// it does NOT push into the override map.
try { vm.runInContext(setupBlockB, env.sandbox); }
catch (e) { /* if the block touches APIs we didn't stub, ignore the
override-map assertion below is what matters */ }
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(got === '#fe6100',
'ROLE_COLORS.repeater === #FE6100 after early-overrides push (got ' + got + ')');
}
console.log('\n=== #1412 C: explicit setRoleColorOverride() still wins (user customizer pick) ===');
{
const env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
env.sandbox.window.MeshCorePresets.applyPreset('deut');
// User manually picks a node color in the customizer.
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(got === '#ff00ff',
'after setRoleColorOverride("repeater","#ff00ff") ROLE_COLORS.repeater === #ff00ff (got ' + got + ')');
// Clearing the override lets the preset show through again.
env.sandbox.window.setRoleColorOverride('repeater', '');
const got2 = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(got2 === '#fe6100',
'after clearing override, ROLE_COLORS.repeater reverts to preset #FE6100 (got ' + got2 + ')');
}
console.log('\n=== #1412 D: customize.js per-key node-color picker uses setRoleColorOverride ===');
{
// Static guard: the legacy customizer (customize.js) handlers for the node
// color pickers must call setRoleColorOverride(key, value) — NOT mutate
// ROLE_COLORS directly. The proxy-on-read trick in roles.js handles direct
// assignment, but going through the explicit API keeps semantics obvious
// and lets us delete the proxy layer later.
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize.js'), 'utf8');
// Grep for the two affected handlers (data-node input handler + reset).
// Locate the input[data-node] handler — slice forward through the inner forEach callback.
const nodeInputStart = customizeSrc.indexOf("querySelectorAll('input[data-node]')");
const nodeInputHandler = nodeInputStart >= 0 ? [customizeSrc.slice(nodeInputStart, nodeInputStart + 800)] : null;
assert(nodeInputHandler, 'node color input handler block found in customize.js');
if (nodeInputHandler) {
assert(/setRoleColorOverride\s*\(/.test(nodeInputHandler[0]),
'node color input handler calls setRoleColorOverride()');
assert(!/window\.ROLE_COLORS\s*\[[^\]]+\]\s*=/.test(nodeInputHandler[0]),
'node color input handler does NOT assign window.ROLE_COLORS[key] = … directly');
}
const nodeResetStart = customizeSrc.indexOf("querySelectorAll('[data-reset-node]')");
const nodeResetHandler = nodeResetStart >= 0 ? [customizeSrc.slice(nodeResetStart, nodeResetStart + 800)] : null;
assert(nodeResetHandler, 'node color reset handler block found in customize.js');
if (nodeResetHandler) {
assert(/setRoleColorOverride\s*\(/.test(nodeResetHandler[0]),
'node color reset handler calls setRoleColorOverride()');
assert(!/window\.ROLE_COLORS\[/.test(nodeResetHandler[0]),
'node color reset handler does NOT write window.ROLE_COLORS[key] directly');
}
}
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/* Issue #1413 More button overlaps nav-stats badge at vw~1200px.
*
* Symptom: at viewport ~1101..1599px on a non-mobile page (e.g.
* /#/packets), the ".nav-more-btn" (in .nav-left) and ".nav-stats"
* (in .nav-right) overlap horizontally. CDP-confirmed: at vw=1200,
* .nav-more-btn rect (x=499..556) sat on top of .nav-stats (x=502..961),
* a ~54px x-axis overlap. Visually the stats badge number rendered on
* top of the "More" text and the chevron.
*
* Acceptance (from issue #1413):
* - At vw=1101..1920 (sample step), .nav-more-btn.right + GAP <=
* .nav-stats.left, where GAP >= 8px.
* - At vw <= 1100, .nav-stats is display:none (no change).
* - Nav doesn't horizontally scroll at any viewport.
*
* Root cause: .top-nav uses display:flex with justify-content:
* space-between, but .nav-left had no flex-grow and .nav-links had no
* flex-grow either, so .nav-left only consumed its content's intrinsic
* width. .nav-right (flex-shrink:0) then sat at its natural position
* computed from total content and the JS Priority+ fits() check
* succeeded based on intrinsic widths that under-reported the real
* collision because .top-nav has overflow:hidden masking it.
*
* Fix (verified via CDP at vw 1101..1920): `.nav-links { flex: 1 1
* auto; min-width: 0 }` + `.top-nav { column-gap: 16px }`. Reverting
* either part of the fix reintroduces overlap at vw=1200.
*
* Mutation guard: revert the CSS fix this test fails at vw=1200.
*/
'use strict';
const assert = require('node:assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const WIDTHS = [1101, 1200, 1366, 1440, 1600, 1920];
const HEIGHT = 800;
const MIN_GAP_PX = 8;
async function main() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-issue-1413-nav-overlap-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1413-nav-overlap-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
for (const w of WIDTHS) {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-links');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
// Settle layout: two consecutive frames identical for nav-right.
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-right');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.right === r2.right && r1.left === r2.left);
}));
});
}, null, { timeout: 5000 });
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const data = await page.evaluate(() => {
const more = document.querySelector('.nav-more-btn');
const stats = document.querySelector('.nav-stats');
const moreVisible = more && getComputedStyle(more).display !== 'none' &&
getComputedStyle(more.parentElement).display !== 'none' &&
!more.parentElement.classList.contains('is-hidden');
const statsVisible = stats && getComputedStyle(stats).display !== 'none';
const mb = more ? more.getBoundingClientRect() : null;
const sb = stats ? stats.getBoundingClientRect() : null;
const topNav = document.querySelector('.top-nav');
const tnScrollW = topNav ? topNav.scrollWidth : 0;
const tnClientW = topNav ? topNav.clientWidth : 0;
return {
moreVisible, statsVisible,
more: mb ? { x: mb.x, right: mb.right, w: mb.width } : null,
stats: sb ? { x: sb.x, right: sb.right, w: sb.width } : null,
tnScrollW, tnClientW,
};
});
let status = 'PASS';
const reasons = [];
// Acceptance: if both visible, more.right + 8 <= stats.left.
if (data.moreVisible && data.statsVisible && data.more && data.stats) {
const gap = data.stats.x - data.more.right;
if (gap < MIN_GAP_PX) {
status = 'FAIL';
reasons.push(`overlap: more.right=${data.more.right.toFixed(1)} stats.left=${data.stats.x.toFixed(1)} gap=${gap.toFixed(1)} (need >= ${MIN_GAP_PX})`);
}
}
// No horizontal scroll in nav.
if (data.tnScrollW > data.tnClientW + 1) {
status = 'FAIL';
reasons.push(`top-nav h-scroll: scrollW=${data.tnScrollW} clientW=${data.tnClientW}`);
}
if (status === 'FAIL') {
failures++;
console.error(`vw=${w} #/packets ${status}: ${reasons.join('; ')}`);
} else {
passes++;
console.log(`vw=${w} #/packets PASS (more.right=${data.more && data.more.right.toFixed(1)} stats.left=${data.stats && data.stats.x.toFixed(1)})`);
}
}
await browser.close();
console.log(`\ntest-issue-1413-nav-overlap-e2e.js: ${passes} pass, ${failures} fail`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-issue-1413-nav-overlap-e2e.js: ERROR', err); process.exit(1); });
+164
View File
@@ -0,0 +1,164 @@
/**
* #1418 cb-presets.js writes --mc-rt-ramp-0..4 + fires cb-preset-changed.
*
* `route-view.js` reads CSS vars --mc-rt-ramp-0..4 to color the edge gradient
* via getComputedStyle. When the user switches color-blind preset,
* applyPreset() must:
* 1. Write 5 ramp stops from preset.routeRamp (or fallback viridis).
* 2. Fire a cb-preset-changed CustomEvent so route-view.js recolorRoute
* can walk .mc-rt-edge / .mc-rt-row / .mc-rt-spark-dot live.
*
* Pattern mirrors test-issue-1407-cb-preset-propagation.js sandbox shape.
*/
'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 rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
const routeSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
console.log('\n=== #1418 ramp A: route-view reads --mc-rt-ramp-* CSS vars ===');
assert(/--mc-rt-ramp-/.test(routeSrc),
'route-view.js references --mc-rt-ramp-* CSS vars');
assert(/cb-preset-changed/.test(routeSrc),
'route-view.js listens for cb-preset-changed event');
// Selectors recolorRoute touches
assert(/mc-rt-edge|mc-rt-spark-dot|mc-rt-row/.test(routeSrc),
'recolorRoute touches mc-rt-edge / mc-rt-spark-dot / mc-rt-row classes');
console.log('\n=== #1418 ramp B: cb-presets writes ramp stops ===');
function makeSandbox() {
const root = {
style: {
_vars: {},
setProperty(k, v) { this._vars[k] = String(v); },
getPropertyValue(k) { return this._vars[k] || ''; },
removeProperty(k) { delete this._vars[k]; }
},
getAttribute() { return null; },
setAttribute() {}
};
const body = {
_attrs: {},
setAttribute(k, v) { this._attrs[k] = v; },
getAttribute(k) { return this._attrs[k] || null; },
removeAttribute(k) { delete this._attrs[k]; }
};
const listeners = {};
const storage = {
_data: {},
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
setItem(k, v) { this._data[k] = String(v); },
removeItem(k) { delete this._data[k]; }
};
const sandbox = {
window: null,
document: {
documentElement: root, body: body, readyState: 'complete',
getElementById() { return null; },
createElement() {
return { _children: [], style: {}, textContent: '', id: '',
setAttribute() {}, appendChild(c) { this._children.push(c); } };
},
head: { appendChild() {} },
addEventListener() {}
},
localStorage: storage,
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
fetch: function () { return Promise.resolve({ ok: false }); },
matchMedia: function () { return { matches: false, addEventListener() {}, addListener() {} }; },
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
Event: function (type) { this.type = type; },
getComputedStyle: function () {
return { getPropertyValue: function (k) { return root.style._vars[k] || ''; } };
}
};
sandbox.window = sandbox;
return { sandbox, root, body, storage, listeners };
}
let env;
try {
env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
} catch (e) {
assert(false, 'sandbox load failed: ' + e.message);
}
const MCP = env && env.sandbox.window.MeshCorePresets;
assert(!!MCP, 'MeshCorePresets exported');
if (MCP) {
console.log('\n --- ramp-stop count for every preset ---');
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
MCP.applyPreset(id);
let stopsSet = 0;
for (let i = 0; i < 5; i++) {
const v = env.root.style.getPropertyValue('--mc-rt-ramp-' + i);
if (/^#[0-9a-f]{6}$/i.test(v)) stopsSet++;
}
assert(stopsSet === 5,
'preset "' + id + '" sets all 5 ramp stops (--mc-rt-ramp-0..4) — got ' + stopsSet);
});
console.log('\n --- preset routeRamp values land in CSS vars ---');
MCP.applyPreset('default');
const preset0 = MCP.list.find(p => p.id === 'default');
for (let i = 0; i < 5; i++) {
const expected = preset0.routeRamp[i].toLowerCase();
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
assert(actual === expected,
'default --mc-rt-ramp-' + i + ' = ' + expected + ' (got ' + actual + ')');
}
console.log('\n --- switching preset rewrites all 5 stops ---');
MCP.applyPreset('deut');
const deut = MCP.list.find(p => p.id === 'deut');
let allRewritten = true;
for (let i = 0; i < 5; i++) {
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
if (actual !== deut.routeRamp[i].toLowerCase()) allRewritten = false;
}
assert(allRewritten, 'switching to deut overwrites every ramp stop');
console.log('\n --- achromat ramp is luminance (B/W) ---');
MCP.applyPreset('achromat');
const achr = MCP.list.find(p => p.id === 'achromat');
// Achromat ramp is the gray luminance ramp per cb-presets.js line 170.
const stop0 = env.root.style.getPropertyValue('--mc-rt-ramp-0').toLowerCase();
const stop4 = env.root.style.getPropertyValue('--mc-rt-ramp-4').toLowerCase();
assert(stop0 === '#222222', 'achromat ramp[0] === #222222 (got ' + stop0 + ')');
assert(stop4 === '#eeeeee', 'achromat ramp[4] === #eeeeee (got ' + stop4 + ')');
}
console.log('\n=== #1418 ramp C: applyPreset fires cb-preset-changed event ===');
if (MCP) {
let fired = false, detailId = null;
env.sandbox.addEventListener('cb-preset-changed', function (ev) {
fired = true;
detailId = ev.detail && ev.detail.id;
});
MCP.applyPreset('prot');
assert(fired === true, 'cb-preset-changed event fired on applyPreset()');
assert(detailId === 'prot', 'event detail.id === applied preset id (got ' + detailId + ')');
}
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+219
View File
@@ -0,0 +1,219 @@
/**
* #1418 map.js loadRouteFromDeepLink:
* - Hop resolution priority (server resolved_path > HopResolver > raw).
* - GRP_TXT channel hash name resolution (enc_ placeholder, SHA-256 byte
* match for keyed channels, fallback to "channel 0x<HEX>").
*
* The deep-link loader is a giant async function; we don't run it end-to-end.
* Instead we verify:
* 1. Source invariants: priority order is unambiguous in code.
* 2. Replica of the chosen-path resolution logic, exercised on fixtures.
* 3. Replica of the channel-match predicate (the same `find` callback).
* 4. Live SubtleCrypto comparison: SHA-256(name)[0] === target byte
* reproduced via node's built-in crypto.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1418 hop-priority A: source invariants (3-tier priority) ===');
// Priority comment is documented; assert the structural keywords are in order.
const priorityBlock = mapSrc.match(/Priority:[\s\S]{0,800}rawHops/);
assert(!!priorityBlock,
'priority block documented in map.js');
if (priorityBlock) {
const blk = priorityBlock[0];
const iResolved = blk.indexOf('resolved_path');
const iHopRes = blk.indexOf('HopResolver');
const iRaw = blk.indexOf('raw');
assert(iResolved >= 0 && iHopRes >= 0 && iRaw >= 0,
'priority block mentions all three: resolved_path, HopResolver, raw');
assert(iResolved < iHopRes && iHopRes < iRaw,
'priority order in comment: resolved_path → HopResolver → raw');
}
// Structural code path: resolved_path branch checked first, then HopResolver,
// then naked rawHops fallback.
assert(/if\s*\(\s*Array\.isArray\(resolvedHops\)[^\)]*\)\s*\{[\s\S]{0,200}\}\s*else if\s*\(\s*window\.HopResolver/.test(mapSrc),
'code structure: if (resolvedHops valid) else if (window.HopResolver) else (rawHops)');
console.log('\n=== #1418 hop-priority B: replica of chosen-path selection ===');
// Replicate the chooseChosenPath logic exactly. window.HopResolver shim
// returns a per-pubkey dict; resolveResult[h] is consulted per raw hop.
function chooseChosenPath(rawHops, resolvedHopsRaw, hopResolver) {
let resolvedHops = null;
try {
if (resolvedHopsRaw) {
resolvedHops = typeof resolvedHopsRaw === 'string' ? JSON.parse(resolvedHopsRaw) : resolvedHopsRaw;
}
} catch (_) {}
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
return rawHops.map((h, i) => resolvedHops[i] || h);
}
if (hopResolver && typeof hopResolver.resolve === 'function' && rawHops.length) {
try {
const result = hopResolver.resolve(rawHops);
return rawHops.map(h => {
const r = result ? result[h] : null;
return r && r.pubkey ? r.pubkey : h;
});
} catch (_) { return rawHops; }
}
return rawHops;
}
const rawHops = ['AA', 'BB', 'CC'];
// Tier 1: server resolved_path takes priority over HopResolver
const serverResolved = ['AAFULL1', 'BBFULL2', 'CCFULL3'];
const naiveResolver = { resolve: () => ({ AA: { pubkey: 'WRONG_A' }, BB: { pubkey: 'WRONG_B' }, CC: { pubkey: 'WRONG_C' }}) };
let chosen = chooseChosenPath(rawHops, serverResolved, naiveResolver);
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
'server resolved_path wins over HopResolver (returns ' + JSON.stringify(chosen) + ')');
// Tier 1 with JSON string input (server returns it stringified sometimes)
chosen = chooseChosenPath(rawHops, JSON.stringify(serverResolved), naiveResolver);
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
'server resolved_path accepts JSON-string input (parses it)');
// Tier 2: no resolved_path → use HopResolver
const smartResolver = { resolve: () => ({ AA: { pubkey: 'AAFULL_DIFF' }, BB: { pubkey: 'BBFULL_DIFF' }, CC: { pubkey: 'CCFULL_DIFF' }}) };
chosen = chooseChosenPath(rawHops, null, smartResolver);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
'no resolved_path → HopResolver result used (returns ' + JSON.stringify(chosen) + ')');
// HopResolver returns different from naive prefix → values change
chosen = chooseChosenPath(['AB'], null, { resolve: () => ({ AB: { pubkey: 'ABcorrect123' } }) });
assert(chosen[0] === 'ABcorrect123',
'HopResolver overrides naive prefix when it returns a longer pubkey');
// HopResolver throws → fallback to raw
chosen = chooseChosenPath(rawHops, null, { resolve: () => { throw new Error('boom'); } });
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
'HopResolver throw → fallback to rawHops');
// Tier 3: no resolved_path, no HopResolver → raw prefixes
chosen = chooseChosenPath(rawHops, null, null);
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
'no resolved_path AND no HopResolver → raw prefixes returned as-is');
// Length mismatch: resolved_path is wrong length → falls through to HopResolver
chosen = chooseChosenPath(rawHops, ['only_one'], smartResolver);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
'resolved_path with mismatched length → falls through to HopResolver');
// Per-element falsy in resolved_path → falls back to raw for THAT index
chosen = chooseChosenPath(rawHops, ['AAFULL1', null, 'CCFULL3'], null);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL1', 'BB', 'CCFULL3']),
'per-index null in resolved_path → falls back to raw for that index only');
console.log('\n=== #1418 channel A: GRP_TXT match predicate (sync part) ===');
// Replica of the channel-find predicate from loadRouteFromDeepLink.
function findChannelSync(chList, wantHex) {
const wantUp = String(wantHex).toUpperCase();
return chList.find(c => {
const ch = String(c.hash || '').toUpperCase();
const nm = String(c.name || '').toUpperCase();
return ch.startsWith(wantUp) ||
ch === 'ENC_' + wantUp ||
nm.includes('0X' + wantUp);
}) || null;
}
const channels = [
{ hash: 'public_full_hash_AB...', name: 'Public' },
{ hash: 'enc_77', name: 'Encrypted (0x77)', encrypted: true },
{ hash: 'unknown', name: 'channel 0xCD' }
];
// hash starts with target hex
let m = findChannelSync([{ hash: 'AB1234', name: 'Test' }], 'AB');
assert(m && m.name === 'Test', 'finds channel where hash starts with target hex');
// enc_<HEX> placeholder
m = findChannelSync(channels, '77');
assert(m && m.name === 'Encrypted (0x77)',
'matches enc_<HEX> placeholder ("enc_77") for encrypted channel');
// name contains "0x<HEX>"
m = findChannelSync(channels, 'CD');
assert(m && m.name === 'channel 0xCD',
'matches name containing "0x<HEX>" placeholder');
// Case-insensitive
m = findChannelSync([{ hash: 'enc_ff', name: 'lower' }], 'FF');
assert(m && m.name === 'lower', 'case-insensitive match on enc_<HEX>');
// No match → null (caller falls back to "channel 0x<HEX>")
m = findChannelSync(channels, 'XX');
assert(m === null, 'no match → null (so caller renders "channel 0x<HEX>" fallback)');
console.log('\n=== #1418 channel B: SHA-256(name)[0] keyed-channel match ===');
// The async fallback (SubtleCrypto) computes SHA-256(name)[0] and checks
// it against the target byte. Reproduce in node and verify the formula
// matches the firmware/decoder convention (first byte of SHA-256).
function sha256Byte0(name) {
const buf = crypto.createHash('sha256').update(name, 'utf8').digest();
return buf[0].toString(16).padStart(2, '0').toUpperCase();
}
// Known channel name → its derived byte
const wellKnown = ['Public', 'Test Channel', 'mesh-control', 'general'];
wellKnown.forEach(name => {
const byte = sha256Byte0(name);
assert(/^[0-9A-F]{2}$/.test(byte),
'SHA-256("' + name + '")[0] = 0x' + byte + ' (valid 2-hex)');
});
// Construct a fixture where we deliberately want to match channel "Public"
const target = sha256Byte0('Public');
// Simulate the async match loop: walk the channel list, hash each name,
// return the one whose first byte === target.
function findChannelAsync(chList, wantHex) {
const wantUp = String(wantHex).toUpperCase();
for (const c of chList) {
if (c.encrypted) continue;
if (!c.name) continue;
if (sha256Byte0(c.name) === wantUp) return c;
}
return null;
}
const result = findChannelAsync([
{ name: 'Public' },
{ name: 'Other' },
{ name: 'Public', encrypted: true } // would match but encrypted → skipped
], target);
assert(result && result.name === 'Public' && !result.encrypted,
'SHA-256 match: returns first non-encrypted channel whose name SHA-256[0] === target byte');
// Source invariants: the async block exists in map.js
assert(/window\.crypto\.subtle/.test(mapSrc), 'map.js uses window.crypto.subtle for SHA-256 fallback');
assert(/'SHA-256'/.test(mapSrc), 'map.js requests SHA-256 specifically');
assert(/if\s*\(c\.encrypted\)\s*continue/.test(mapSrc),
'async loop skips already-known encrypted/placeholder channels');
assert(/byteHex\s*===\s*wantUp/.test(mapSrc),
'async loop compares first-byte hex to target (byteHex === wantUp)');
console.log('\n=== #1418 channel C: fallback label format ===');
// When no match found, caller renders "Encrypted (0x<HEX>)" for encrypted,
// "channel 0x<HEX>" otherwise. Just guard the literal templates exist.
assert(/Encrypted \(0x/.test(mapSrc),
'encrypted-channel fallback label "Encrypted (0x..." present in map.js');
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+135
View File
@@ -0,0 +1,135 @@
/**
* #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 (originhop1 or last-hopdest) 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);
+114
View File
@@ -0,0 +1,114 @@
/**
* #1418 / PR #1423 polish-review guards.
*
* Source-grep guards for the polish-review findings addressed on the
* route-view feature branch. Each guard pins one finding so future edits
* can't silently regress the fix.
*
* Findings covered (see PR #1423 review comments for full context):
* - resize listener leak (carmack/munger)
* - 5-staggered-timer fit storm (tufte/doshi)
* - empty catch {} swallowing errors (torvalds)
* - _detailCache unbounded (carmack) LRU(50)
* - recolorRoute walks document.querySelectorAll (torvalds) scoped
* - deep-link silent failure (doshi) toast on empty paths
* - innerHTML row re-wire factored (dijkstra) wireRow helper
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const rvSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== A. resize listener leak fix (carmack/munger) ===');
// Single resize listener attached via window.__mc_routeResizeRefit stash,
// torn down on next render() + on teardownIfNavigatedAway.
assert(/window\.__mc_routeResizeRefit/.test(rvSrc),
'resize handler stashed on window.__mc_routeResizeRefit for dedupe');
assert(/removeEventListener\(['"]resize['"],\s*window\.__mc_routeResizeRefit\)/.test(rvSrc),
'prior resize handler removed before attaching new one');
// Old buggy pattern (anonymous resize listener with no removal) must be gone.
const anonResize = rvSrc.match(/window\.addEventListener\(['"]resize['"]\s*,\s*function/g) || [];
assert(anonResize.length === 0,
'no anonymous window.resize listeners (all go via __mc_routeResizeRefit) — found ' + anonResize.length);
console.log('\n=== B. fit-storm collapse to rAF (tufte/doshi) ===');
// The 5-staggered (0/300/800/1600/2800) and 3-staggered (0/200/600/1400)
// timers MUST be gone. Single requestAnimationFrame is the replacement.
const bigFitStorm = /setTimeout\(\s*refit\s*,\s*(?:300|800|1600|2800)\s*\)/.test(rvSrc);
assert(!bigFitStorm, 'no setTimeout(refit, 300|800|1600|2800) staggered fit storm');
const isoFitStorm = /setTimeout\(\s*doFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
assert(!isoFitStorm, 'no setTimeout(doFit, 200|600|1400) staggered isolate-fit storm');
const restoreFitStorm = /setTimeout\(\s*_restoreFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
assert(!restoreFitStorm, 'no setTimeout(_restoreFit, 200|600|1400) staggered restore-fit storm');
assert(/requestAnimationFrame\(\s*refit\s*\)/.test(rvSrc),
'requestAnimationFrame(refit) is the new initial-settle path');
assert(/requestAnimationFrame\(\s*doFit\s*\)/.test(rvSrc),
'requestAnimationFrame(doFit) replaces isolate-path staggered timers');
assert(/new ResizeObserver/.test(rvSrc),
'ResizeObserver attached to map container for layout-settle re-fit');
console.log('\n=== C. ResizeObserver lifecycle (carmack) ===');
assert(/window\.__mc_routeResizeObserver/.test(rvSrc),
'ResizeObserver stashed on window.__mc_routeResizeObserver for dedupe');
assert(/__mc_routeResizeObserver[^;]*\.disconnect\(\)/.test(rvSrc),
'ResizeObserver disconnected on render() re-entry + teardown');
console.log('\n=== D. _detailCache LRU bound (carmack) ===');
assert(/_detailCache\s*=\s*new\s+Map\(\)/.test(rvSrc),
'_detailCache is a Map (LRU-capable) not a plain object');
assert(/DETAIL_CACHE_MAX/.test(rvSrc),
'DETAIL_CACHE_MAX constant defined (LRU bound)');
assert(/_detailCache\.size\s*>=?\s*DETAIL_CACHE_MAX/.test(rvSrc),
'LRU eviction guard checks _detailCache.size against DETAIL_CACHE_MAX');
console.log('\n=== E. catch {} silent swallow → console.warn (torvalds) ===');
// Empty `catch (e) {}` (no body) count should be near zero. A handful may
// remain where the catch is genuinely a "best-effort" no-op — but the
// review flagged 20+ silent swallows; we should be down to ≤5 after the pass.
// Empty `catch (e) {}` (no body) count for full-block catches (e). The
// inline `} catch (_) {}` no-op removers are intentional (marker may
// already be detached). The review flagged 20+ silent block swallows;
// after the pass the remaining ones must be legitimately benign
// (localStorage may be disabled, marker may have been removed in a race).
const blockEmptyCatches = (rvSrc.match(/\}\s*catch\s*\(\s*e\s*\)\s*\{\s*\}/g) || []).length;
assert(blockEmptyCatches <= 8,
'block-style silent `} catch (e) {}` reduced to ≤8 (was 20+) — current: ' + blockEmptyCatches);
assert(/console\.warn\(['"]\[route-view\]/.test(rvSrc),
'at least one [route-view] console.warn breadcrumb present');
console.log('\n=== F. recolorRoute scoped to sidebar (torvalds) ===');
// The walks must be scoped to the active sidebar root, not document-wide.
// We allow document.querySelectorAll for `.mc-rt-sidebar` (the tear-down)
// but NOT for `.mc-rt-edge` / `.mc-rt-row` / `.mc-rt-spark-dot`.
const docEdges = /document\.querySelectorAll\(['"]\.mc-rt-edge['"]\)/.test(rvSrc);
assert(!docEdges, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-edge)');
const docRows = /document\.querySelectorAll\(['"]\.mc-rt-row['"]\)/.test(rvSrc);
assert(!docRows, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-row)');
console.log('\n=== G. deep-link empty-paths toast (doshi) ===');
// When allPaths.length === 0, surface a sidebar/console message instead of
// silently bailing.
assert(/allPaths\.length\s*===\s*0[\s\S]{0,400}(?:console\.warn|alert|toast|showToast|notif)/i.test(mapSrc),
'deep-link empty-paths path emits a console.warn / toast (no silent return)');
console.log('\n=== H. wireRow row-wireup helper (dijkstra) ===');
assert(/function\s+wireRow\s*\(\s*row\s*\)/.test(rvSrc),
'wireRow(row) helper centralizes row event wiring');
assert(/sidebar\._wireRow\s*=\s*wireRow/.test(rvSrc),
'wireRow stashed on sidebar so restoreAllPaths can reuse');
assert(/newRowEls\.forEach\(\s*sidebar\._wireRow/.test(rvSrc),
'restoreAllPaths re-wires rows via sidebar._wireRow (not inline duplicate)');
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+165
View File
@@ -0,0 +1,165 @@
/**
* #1418 map.js loadRouteFromDeepLink raw_hex byte extraction.
*
* The deep-link loader peeks at chosen.raw_hex when decoded JSON is empty,
* to extract src/destHash and (for GRP_TXT) channel_hash. Wire layout per
* cmd/ingestor/decoder.go:
* byte0=route+type, byte1=path_len, then path bytes, then ...
*
* TXT_MSG (type 2): destHash + srcHash bytes after path
* RESPONSE (type 1): destHash + srcHash bytes after path
* ANON_REQ (type 7): destHash ONLY (no srcHash byte sender anonymous)
* PATH (type 8): destHash + srcHash bytes after path
* GRP_TXT (type 5): channel_hash byte after path
*
* This test asserts behavior by replicating the exact extraction logic
* from public/map.js and exercising it on hand-built raw_hex fixtures
* built to mirror real wire packets.
*
* Source invariants (string grep on map.js) also guarded so any code-move
* that drops the extraction is caught.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1418 raw_hex A: source invariants in map.js ===');
assert(/TYPES_WITH_DST_SRC\s*=\s*\[\s*1\s*,\s*2\s*,\s*7\s*,\s*8\s*\]/.test(mapSrc),
'TYPES_WITH_DST_SRC = [1, 2, 7, 8] (RESPONSE, TXT_MSG, ANON_REQ, PATH)');
assert(/payload_type\s*!==\s*7/.test(mapSrc),
'ANON_REQ (type 7) special-cased to skip srcHash extraction');
assert(/payload_type\s*===\s*5/.test(mapSrc),
'GRP_TXT (type 5) branch present for channel_hash extraction');
assert(/PAYLOAD_TYPE_MAP\s*=\s*\{[^}]*0:\s*'REQ'[^}]*1:\s*'RESPONSE'[^}]*2:\s*'TXT_MSG'/m.test(mapSrc),
'PAYLOAD_TYPE_MAP covers 0=REQ, 1=RESPONSE, 2=TXT_MSG');
assert(/5:\s*'GRP_TXT'[^}]*7:\s*'ANON_REQ'[^}]*8:\s*'PATH'/m.test(mapSrc),
'PAYLOAD_TYPE_MAP covers 5=GRP_TXT, 7=ANON_REQ, 8=PATH');
// Polish review (djb): pathLen MUST be bounded before slicing. A crafted
// pathLen=200 byte would surface random body bytes as srcHash/destHash.
// Cap at MeshCore wire max of 64 hops in BOTH the TXT-family branch and
// the GRP_TXT channel-hash branch.
assert((mapSrc.match(/pathLen[^>]*>\s*64/g) || []).length >= 2,
'raw_hex pathLen capped at >64 in both TXT and GRP_TXT branches (#1423 review/djb)');
assert(/Number\.isFinite\(pathLen\)/.test(mapSrc),
'raw_hex pathLen guarded with Number.isFinite (rejects NaN from non-hex byte)');
console.log('\n=== #1418 raw_hex B: replica extractor reproduces map.js logic ===');
// Pure replica of the extractor inside loadRouteFromDeepLink. If map.js's
// logic changes, this replica MUST be updated and the diff explained.
function extractSrcDst(rawHex, payloadType) {
const TYPES = [1, 2, 7, 8];
if (TYPES.indexOf(payloadType) < 0) return { src: null, dst: null };
try {
const pathLen = parseInt(rawHex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
return { src: null, dst: null };
}
const destOff = 4 + pathLen * 2;
if (rawHex.length < destOff + 2) return { src: null, dst: null };
const dst = rawHex.slice(destOff, destOff + 2).toUpperCase();
let src = null;
if (payloadType !== 7 && rawHex.length >= destOff + 4) {
src = rawHex.slice(destOff + 2, destOff + 4).toUpperCase();
}
return { src, dst };
} catch (_) { return { src: null, dst: null }; }
}
function extractChannelHash(rawHex, payloadType) {
if (payloadType !== 5) return null;
try {
const pathLen = parseInt(rawHex.slice(2, 4), 16);
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) return null;
const chOff = 4 + pathLen * 2;
if (rawHex.length < chOff + 2) return null;
return rawHex.slice(chOff, chOff + 2).toUpperCase();
} catch (_) { return null; }
}
// Build a hex string: route+type byte, path_len, path bytes, then payload.
function build(routeType, pathBytes, payloadBytes) {
const lenHex = pathBytes.length.toString(16).padStart(2, '0');
return routeType + lenHex + pathBytes.join('') + payloadBytes.join('');
}
// Fixture 1: TXT_MSG (type 2), 2 path hops AB,CD, destHash=42, srcHash=99
const txt = build('02', ['AB', 'CD'], ['42', '99', 'FF', 'EE']);
let r = extractSrcDst(txt, 2);
assert(r.dst === '42' && r.src === '99',
'TXT_MSG (type 2) extracts destHash=42, srcHash=99 after 2-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
// Fixture 2: RESPONSE (type 1), 0-hop path
const resp = build('01', [], ['7A', '3C']);
r = extractSrcDst(resp, 1);
assert(r.dst === '7A' && r.src === '3C',
'RESPONSE (type 1) extracts destHash + srcHash on 0-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
// Fixture 3: ANON_REQ (type 7) — destHash present, srcHash MUST be null
const anon = build('07', ['11'], ['DD', 'BB', 'CC']);
r = extractSrcDst(anon, 7);
assert(r.dst === 'DD', 'ANON_REQ (type 7) extracts destHash=DD');
assert(r.src === null, 'ANON_REQ (type 7) MUST NOT extract srcHash (anonymous sender) — got ' + r.src);
// Fixture 4: PATH (type 8) carries both hashes
const pathPkt = build('08', ['AA', 'BB', 'CC'], ['11', '22']);
r = extractSrcDst(pathPkt, 8);
assert(r.dst === '11' && r.src === '22',
'PATH (type 8) extracts destHash + srcHash after 3-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
// Fixture 5: GRP_TXT (type 5) — channel_hash extraction, NOT src/dst
const grp = build('05', ['77'], ['AB', 'XX']);
const ch = extractChannelHash(grp, 5);
assert(ch === 'AB', 'GRP_TXT (type 5) extracts channel_hash=AB after 1-hop path (got ' + ch + ')');
r = extractSrcDst(grp, 5);
assert(r.src === null && r.dst === null,
'GRP_TXT (type 5) is NOT in TYPES_WITH_DST_SRC — extractor returns nulls');
// Fixture 6: non-extracting types (REQ=0, ACK=3, ADVERT=4, MULTIPART=10, …)
[0, 3, 4, 6, 9, 10, 11, 12].forEach(function (pt) {
r = extractSrcDst('00' + '00' + 'FFFF', pt);
assert(r.src === null && r.dst === null,
'payload_type=' + pt + ' (not in TYPES_WITH_DST_SRC) → no extraction');
});
// Edge case: raw_hex too short (path length claims more bytes than present)
r = extractSrcDst('02' + '04' + 'AB', 2); // claims 4-hop path, only 1 byte payload
assert(r.src === null && r.dst === null, 'truncated raw_hex → null extraction (no crash)');
// Polish review (djb): malicious pathLen=200 (0xC8) MUST be rejected even
// when the body is long enough to slice. Without the cap, the extractor
// would surface random body bytes as src/destHash strings in the UI.
const evil = '02' + 'C8' + 'AB'.repeat(500); // pathLen=200, plenty of body to slice
r = extractSrcDst(evil, 2);
assert(r.src === null && r.dst === null,
'malicious pathLen=200 → rejected, no OOB-style byte surfacing');
const evilCh = extractChannelHash('05' + 'C8' + 'AB'.repeat(500), 5);
assert(evilCh === null, 'malicious pathLen=200 (GRP_TXT) → rejected');
// Boundary: pathLen=64 (max) still works; 65 rejected.
const okBig = '02' + '40' + 'AB'.repeat(64) + 'EE' + 'FF';
r = extractSrcDst(okBig, 2);
assert(r.dst === 'EE' && r.src === 'FF', 'pathLen=64 (max allowed) still extracts');
const tooBig = '02' + '41' + 'AB'.repeat(65) + 'EE' + 'FF';
r = extractSrcDst(tooBig, 2);
assert(r.src === null && r.dst === null, 'pathLen=65 → rejected (above wire max of 64)');
console.log('\n=== #1418 raw_hex C: channel_hash NOT extracted for non-GRP_TXT ===');
[0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12].forEach(function (pt) {
const v = extractChannelHash('05' + '00' + 'AB', pt);
assert(v === null, 'payload_type=' + pt + ' returns null channel_hash');
});
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+143
View File
@@ -0,0 +1,143 @@
/**
* #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);