feat(live): clickable path overlay — packet info popup (closes #771 M2)#923
feat(live): clickable path overlay — packet info popup (closes #771 M2)#923efiten wants to merge 3 commits intoKpa-clawbot:masterfrom
Conversation
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Expert Review — PR #923: Clickable Path Overlay
Reviewer: The Optimizer (Carmack persona)
PR: #923 — feat(live): clickable path overlay — packet info popup (closes #771 M2)
Verdict: Changes Requested
Merge conflicts: Yes (CONFLICTING state — must resolve before merge)
Carmack Review
The data flow is clean — animation completes, registers a clickable polyline, prune on interval. Good separation. But there are allocation and complexity concerns in what is effectively a hot path (animations complete frequently on an active mesh).
Must-Fix
1. pruneClickablePaths allocates on every call via .filter() + reassignment
Called both inside registerClickablePath (every animation completion) AND on the prune interval. With active meshes producing dozens of animations per second, this creates GC pressure.
Fix: mutate in place. Walk the array once, splice expired entries, then trim from index 0 if over max. One pass, zero allocations.
function pruneClickablePaths() {
const cutoff = Date.now() - CLICKABLE_PATH_TTL_MS;
for (let i = clickablePaths.length - 1; i >= 0; i--) {
if (clickablePaths[i].addedAt < cutoff) {
try { clickablePaths[i].poly.remove(); } catch (_) {}
clickablePaths.splice(i, 1);
}
}
while (clickablePaths.length > CLICKABLE_PATH_MAX) {
try { clickablePaths[0].poly.remove(); } catch (_) {}
clickablePaths.shift();
}
}This also eliminates the awkward paths parameter and the need to reassign clickablePaths at two call sites. The function owns the module-level array directly.
2. Two .map() calls in animation completion callback
const latLngs = hopPositions.map(hp => hp.pos);
const hopNames = hopPositions.map(hp => hp.name || ...);Two array allocations per animation completion. Fuse into a single loop:
const latLngs = [], hopNames = [];
for (const hp of hopPositions) {
latLngs.push(hp.pos);
hopNames.push(hp.name || (hp.key ? hp.key.slice(0,8) : '?'));
}3. buildClickablePathPopupHtml uses inline styles instead of CSS classes
Not a perf issue per se, but violates the repo's AGENTS.md rule: "All colors MUST use CSS variables. Never hardcode #hex values outside of :root definitions." The color:#fff, color:var(--text-muted,#6b7280) partially complies but style="background:${color}" injects a raw hex. Use a CSS class and set the background via style attribute only for the dynamic color (acceptable), but move static styles to live.css.
4. Popup Date.now() drift in buildClickablePathPopupHtml
The "X seconds ago" is computed at click time relative to tsMs, but tsMs is set at animation-start time (first._ts || Date.now()). If the animation takes 2-3s, the popup's "ago" will be off by the animation duration. Minor but trivially fixable: use the entry's addedAt (registration time = animation end) instead of pktMeta.ts for the "ago" display, or document that it's relative to packet receive time (which is arguably more correct — in that case, add a comment).
5. Test: "shows relative time" assertion is too weak
assert.ok(html.includes('s ago') || html.includes('ago'), 'should show relative time');This would pass if the function returned garbage containing the word "ago". Assert the actual expected value: with a 10s offset, expect '10s ago'.
6. Missing test: FIFO eviction order
The eviction test checks the final count is 50 but doesn't verify that the oldest entries were evicted (FIFO). Add an assertion that remaining[0].addedAt equals the expected youngest-evicted-entry's successor.
Out of Scope
- The
weight: 12invisible polyline hit target is generous — on dense maps with many paths this could create overlapping click targets. Worth a follow-up issue for M3 to add z-index management or a click disambiguation popup.
Summary: 6 must-fix items. Core logic is sound but allocations in the hot path need tightening, and tests need sharper assertions. Resolve merge conflicts first.
…oops, extract CSS, sharpen tests - pruneClickablePaths: operates on module-level array directly (no params, no return); both call sites updated - Animation completion: fuse two .map() calls into single loop (one allocation) - buildClickablePathPopupHtml: move static styles to live.css classes; keep only dynamic background color inline; add comment that tsMs is packet receive time - Tests: assert exact '10s ago' string; rewrite prune tests to use exposed _liveClickablePaths array; add FIFO eviction order assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…loses Kpa-clawbot#771 M2) After a path animation completes, registers an invisible clickable polyline (weight 12, opacity 0) on a dedicated clickablePathsLayer for 30s. Clicking shows a Leaflet popup with type badge, hop chain, relative time, and a link to the packets page. Popup auto-dismisses after 20s. Max 50 paths retained (FIFO eviction); TTL pruning runs on the existing _pruneInterval. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
page.$() captures an element handle at query time. When the nodes page WebSocket auto-refresh fires between the querySelector and the .click() call, the table is re-rendered and the element is detached, causing "Element is not attached to the DOM". Replace both occurrences with page.click(selector), which re-queries the DOM at click time and retries until the element is stable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oops, extract CSS, sharpen tests - pruneClickablePaths: operates on module-level array directly (no params, no return); both call sites updated - Animation completion: fuse two .map() calls into single loop (one allocation) - buildClickablePathPopupHtml: move static styles to live.css classes; keep only dynamic background color inline; add comment that tsMs is packet receive time - Tests: assert exact '10s ago' string; rewrite prune tests to use exposed _liveClickablePaths array; add FIFO eviction order assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9b4ebd0 to
29e2a46
Compare
After a path animation completes, keeps an invisible clickable polyline on the map for 30s. Clicking it shows a compact Leaflet popup with type badge, hop chain, relative time, and a link to the full packets page. Popup auto-dismisses after 20s.
Changes
clickablePathsLayer: new Leaflet layer for invisible hit-target polylinesbuildClickablePathPopupHtml(): pure function generating popup HTML (type badge, hop chain, time, hash link)pruneClickablePaths(): TTL (30s) + FIFO eviction (max 50); runs on existing_pruneIntervalregisterClickablePath(): adds invisible polyline with click → popup handleranimatePath(): accepts optionalpktMeta(hash,ts); callsregisterClickablePathon completionclickablePathsLayerandclickablePathsTests
7 new unit tests; 77 pass, 0 regressions.
Closes #771 (M2 of 3)