mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-22 10:25:13 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user