feat(live): clickable path overlay — packet info popup (closes #771 M2) (#923)

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
polylines
- `buildClickablePathPopupHtml()`: pure function generating popup HTML
(type badge, hop chain, time, hash link)
- `pruneClickablePaths()`: TTL (30s) + FIFO eviction (max 50); runs on
existing `_pruneInterval`
- `registerClickablePath()`: adds invisible polyline with click → popup
handler
- `animatePath()`: accepts optional `pktMeta` (`hash`, `ts`); calls
`registerClickablePath` on completion
- Teardown clears `clickablePathsLayer` and `clickablePaths`

## Tests
7 new unit tests; 77 pass, 0 regressions.

Closes #771 (M2 of 3)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-05-21 05:56:58 +02:00
committed by GitHub
parent d0d1657b5c
commit 5cc7332583
4 changed files with 155 additions and 5 deletions
+8
View File
@@ -1267,3 +1267,11 @@
transition: none;
}
}
/* Clickable path popup */
.lc-path-popup { font-size: 12px; line-height: 1.6; min-width: 160px; }
.lc-path-badge { color: #fff; border-radius: 3px; padding: 1px 5px; font-size: 11px; font-weight: 600; }
.lc-path-time { margin-top: 4px; color: var(--text-muted); font-size: 11px; }
.lc-path-chain { margin-top: 4px; word-break: break-word; }
.lc-path-link-wrap { margin-top: 4px; }
.lc-path-link { font-size: 11px; }
+70 -5
View File
@@ -9,7 +9,11 @@
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer;
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer, clickablePathsLayer;
let clickablePaths = [];
const CLICKABLE_PATH_TTL_MS = 30000;
const CLICKABLE_PATH_MAX = 50;
const CLICKABLE_POPUP_DISMISS_MS = 20000;
let nodeMarkers = {};
let nodeData = {};
let packetCount = 0;
@@ -529,6 +533,52 @@
if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; }
}
function buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash) {
// tsMs is packet receive time — "ago" is relative to when the packet arrived, not when the animation ended
const secsAgo = Math.round((Date.now() - tsMs) / 1000);
const timeStr = secsAgo < 60 ? secsAgo + 's ago' : Math.round(secsAgo / 60) + 'm ago';
const chain = hopNames.join(' → ');
const link = hash ? `<a class="lc-path-link" href="#/packets/${hash}" style="color:${color}">full detail →</a>` : '';
return `<div class="lc-path-popup">
<span class="lc-path-badge" style="background:${color}">${typeName}</span>
<div class="lc-path-time">${timeStr}</div>
<div class="lc-path-chain">${chain}</div>
${link ? '<div class="lc-path-link-wrap">' + link + '</div>' : ''}
</div>`;
}
function pruneClickablePaths(now) {
const cutoff = 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();
}
}
function registerClickablePath(latLngs, typeName, color, hopNames, tsMs, hash) {
if (!clickablePathsLayer) return;
const poly = L.polyline(latLngs, { weight: 12, opacity: 0, interactive: true }).addTo(clickablePathsLayer);
const entry = { addedAt: Date.now(), poly };
clickablePaths.push(entry);
pruneClickablePaths(Date.now());
let dismissTimer = null;
poly.on('click', function(e) {
if (dismissTimer) clearTimeout(dismissTimer);
const html = buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash);
L.popup({ maxWidth: 280, className: 'path-info-popup' })
.setLatLng(e.latlng)
.setContent(html)
.openOn(map);
dismissTimer = setTimeout(() => { if (map) map.closePopup(); }, CLICKABLE_POPUP_DISMISS_MS);
});
}
function vcrSpeedCycle() {
const speeds = [1, 2, 4, 8];
const idx = speeds.indexOf(VCR.speed);
@@ -1125,6 +1175,7 @@
nodesLayer = L.layerGroup().addTo(map);
pathsLayer = L.layerGroup().addTo(map);
animLayer = L.layerGroup().addTo(map);
clickablePathsLayer = L.layerGroup().addTo(map);
injectSVGFilters();
await loadNodes();
@@ -2379,10 +2430,14 @@
for (var aKey in nodeActivity) {
if (!(aKey in nodeData)) delete nodeActivity[aKey];
}
pruneClickablePaths(Date.now());
}
// Expose for testing
window._livePruneStaleNodes = pruneStaleNodes;
window._liveBuildClickablePathPopupHtml = buildClickablePathPopupHtml;
window._livePruneClickablePaths = pruneClickablePaths;
window._liveClickablePaths = clickablePaths;
window._liveNodeMarkers = function() { return nodeMarkers; };
window._liveNodeData = function() { return nodeData; };
window._liveNodeActivity = function() { return nodeActivity; };
@@ -2611,6 +2666,7 @@
// --- Animate all unique paths simultaneously ---
// First path gets audio sync hook, rest are visual-only
var pktMeta = { hash: first.hash, ts: first._ts || Date.now() };
var firstPathDone = false;
for (var ai = 0; ai < allPaths.length; ai++) {
var onHop = null;
@@ -2629,7 +2685,7 @@
var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1);
var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted);
if (completedPositions.length >= 2) {
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop, first.hash);
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop, pktMeta);
} else if (completedPositions.length === 1) {
pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName);
}
@@ -2637,7 +2693,7 @@
drawDashedPath(remainingPositions, color);
}
} else {
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop, first.hash);
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop, pktMeta);
}
}
}
@@ -2746,7 +2802,7 @@
return raw.filter(h => h.pos != null);
}
function animatePath(hopPositions, typeName, color, rawHex, onHop, hash) {
function animatePath(hopPositions, typeName, color, rawHex, onHop, pktMeta) {
if (!animLayer || !pathsLayer) return;
if (activeAnims >= MAX_CONCURRENT_ANIMS) return;
activeAnims++;
@@ -2758,6 +2814,14 @@
activeAnims = Math.max(0, activeAnims - 1);
const countEl = document.getElementById('liveAnimCount');
if (countEl) countEl.textContent = activeAnims;
if (pktMeta && hopPositions.length >= 2) {
const latLngs = [], hopNames = [];
for (const hp of hopPositions) {
latLngs.push(hp.pos);
hopNames.push(hp.name || (hp.key ? hp.key.slice(0, 8) : '?'));
}
registerClickablePath(latLngs, typeName, color, hopNames, pktMeta.ts, pktMeta.hash);
}
return;
}
if (!animLayer) return;
@@ -3532,7 +3596,8 @@
}
_navCleanup = null;
}
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = clickablePathsLayer = null;
clickablePaths = [];
stopMatrixRain();
nodeMarkers = {}; nodeData = {};
activeNodeDetailKey = null;
+3
View File
@@ -261,6 +261,8 @@ async function run() {
// Test 5: Node detail loads (reuses nodes page from test 2)
await test('Node detail loads', async () => {
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
// Use page.click() instead of an element handle to avoid detached-element races
// when the WebSocket auto-refresh re-renders the table between querySelector and click.
await page.click('table tbody tr:not([id^=vscroll])');
// Wait for detail pane to appear
await page.waitForSelector('.node-detail');
@@ -275,6 +277,7 @@ async function run() {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
// Use page.click() to avoid detached-element race with WebSocket auto-refresh.
await page.click('table tbody tr:not([id^=vscroll])');
await page.waitForSelector('.node-detail');
// Find the Details link in the side panel
+74
View File
@@ -978,6 +978,80 @@ console.log('\n=== live.js: node filter ===');
});
}
// ===== Clickable paths (M2 — #771) =====
console.log('\n=== live.js: clickable paths ===');
{
const ctx = makeLiveSandbox();
const buildPopupHtml = ctx.window._liveBuildClickablePathPopupHtml;
assert.ok(buildPopupHtml, '_liveBuildClickablePathPopupHtml must be exposed');
test('buildClickablePathPopupHtml includes type badge with color', () => {
const html = buildPopupHtml('GRP_TXT', '#22c55e', ['NodeA', 'Rpt1', 'NodeB'], Date.now() - 5000);
assert.ok(html.includes('GRP_TXT'), 'should include type name');
assert.ok(html.includes('#22c55e'), 'should include type color');
});
test('buildClickablePathPopupHtml includes hop chain', () => {
const html = buildPopupHtml('ADVERT', '#6b7280', ['Alpha', 'Beta', 'Gamma'], Date.now() - 3000);
assert.ok(html.includes('Alpha'), 'should include first hop');
assert.ok(html.includes('Beta'), 'should include middle hop');
assert.ok(html.includes('Gamma'), 'should include last hop');
});
test('buildClickablePathPopupHtml includes packet link', () => {
const html = buildPopupHtml('GRP_TXT', '#22c55e', ['A', 'B'], Date.now() - 1000, 'abc123def');
assert.ok(html.includes('abc123def'), 'should include packet hash link');
});
test('buildClickablePathPopupHtml shows relative time', () => {
const html = buildPopupHtml('GRP_TXT', '#22c55e', ['A', 'B'], Date.now() - 10000);
assert.ok(html.includes('10s ago'), 'should show 10s ago');
});
const pruneClickablePaths = ctx.window._livePruneClickablePaths;
const clickablePaths = ctx.window._liveClickablePaths;
assert.ok(pruneClickablePaths, '_livePruneClickablePaths must be exposed');
assert.ok(Array.isArray(clickablePaths), '_liveClickablePaths must be exposed');
function loadPaths(entries) {
clickablePaths.splice(0, clickablePaths.length, ...entries);
}
test('pruneClickablePaths removes entries older than TTL', () => {
const now = Date.now();
loadPaths([
{ addedAt: now - 35000, poly: { remove() {} } },
{ addedAt: now - 5000, poly: { remove() {} } },
{ addedAt: now - 1000, poly: { remove() {} } },
]);
pruneClickablePaths(now);
assert.strictEqual(clickablePaths.length, 2, 'should remove paths older than 30s');
});
test('pruneClickablePaths keeps all entries within TTL', () => {
const now = Date.now();
loadPaths([
{ addedAt: now - 5000, poly: { remove() {} } },
{ addedAt: now - 1000, poly: { remove() {} } },
]);
pruneClickablePaths(now);
assert.strictEqual(clickablePaths.length, 2);
});
test('pruneClickablePaths enforces max 50 entries (FIFO eviction)', () => {
const now = Date.now();
// Match production insertion order: oldest at front (index 0), newest at back
// entries[0].addedAt = now-5100 (oldest), entries[51].addedAt = now (newest)
const entries = [];
for (let i = 51; i >= 0; i--) entries.push({ addedAt: now - i * 100, poly: { remove() {} } });
loadPaths(entries);
pruneClickablePaths(now);
assert.strictEqual(clickablePaths.length, 50, 'should evict oldest beyond 50');
// FIFO: the 2 oldest (addedAt now-5100 and now-5000) were shifted off; now-4900 is oldest remaining
assert.strictEqual(clickablePaths[0].addedAt, now - 49 * 100, 'oldest remaining should have addedAt = now-4900');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);