mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-23 05:25:19 +00:00
fix(traces): fix path graph legibility and overlapping edges (#1134)
## Summary - Drop prefix-only paths from path graph: partial observations (same packet seen at 1, 2, 4, 5 hops as it propagated) were treated as separate routes, producing long shortcut edges to Dest that visually obscured the actual relay chain. Now filters out any path that is a strict prefix of a longer observed path before building the graph. - Fix invisible node labels: intermediate hop nodes used white text on `--surface-2` background, making labels invisible in the light theme. Labels now appear below circles and use `var(--text)` for theme-aware contrast. Increased SVG height and node radius to give labels room; intermediate fill uses a subtle accent tint with accent border. ## Test plan - [ ] Open a TRACE packet's path graph with a node that has multiple partial observations — verify no spurious shortcut edges - [ ] Check path graph in light theme — verify intermediate hop labels are visible - [ ] Check path graph in dark theme — verify no regression 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1080,6 +1080,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// Belt-and-braces null guards (#1105 MINOR 4): the outer block measures
|
||||
// and mutates all of these; if any are missing the layout math throws
|
||||
// before we can fall back gracefully.
|
||||
let navPriorityFn = null;
|
||||
if (navMoreBtn && navMoreMenu && navMoreWrap && navLeft && navRightEl && linksContainer && navTop) {
|
||||
// Measure available room and decide which links overflow.
|
||||
// Algorithm: try to fit all links inline. If the link strip doesn't
|
||||
@@ -1244,6 +1245,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Run once on load, again after fonts settle (label widths shift),
|
||||
// and on resize (debounced via rAF).
|
||||
navPriorityFn = applyNavPriority;
|
||||
applyNavPriority();
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(applyNavPriority);
|
||||
@@ -1467,6 +1469,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs${formatVersionBadge(stats.version, stats.commit, stats.engine, stats.buildTime)}`;
|
||||
el.querySelectorAll('.stat-val').forEach(s => s.classList.add('updated'));
|
||||
setTimeout(() => { el.querySelectorAll('.stat-val').forEach(s => s.classList.remove('updated')); }, 600);
|
||||
if (navPriorityFn) requestAnimationFrame(navPriorityFn);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
+50
-13
@@ -69,14 +69,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract ALL unique paths from observations
|
||||
const allPaths = [];
|
||||
// Extract unique paths from observations.
|
||||
// Drop partial paths that are a prefix of a longer observed path — these are
|
||||
// the same packet seen at intermediate relay nodes before it reached the final
|
||||
// hop, not genuinely different routes. Keeping them creates confusing long
|
||||
// "shortcut" edges in the path graph that visually obscure the actual route.
|
||||
const allPathsRaw = [];
|
||||
for (const t of traceData) {
|
||||
try {
|
||||
const hops = JSON.parse(t.path_json || '[]');
|
||||
if (hops.length > 0) allPaths.push({ hops, observer: obsLabel(t) });
|
||||
if (hops.length > 0) allPathsRaw.push({ hops, observer: obsLabel(t) });
|
||||
} catch {}
|
||||
}
|
||||
const allPaths = dedupePrefixPaths(allPathsRaw);
|
||||
// Fallback to packet-level path
|
||||
if (allPaths.length === 0) {
|
||||
for (const p of packets) {
|
||||
@@ -94,13 +99,13 @@
|
||||
try { decoded = JSON.parse(packetMeta.decoded_json); } catch {}
|
||||
}
|
||||
|
||||
renderResults(results, allPaths, decoded);
|
||||
renderResults(results, allPaths, allPathsRaw, decoded);
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div class="trace-empty" style="color:#ef4444">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(container, allPaths, decoded) {
|
||||
function renderResults(container, allPaths, allPathsRaw, decoded) {
|
||||
const uniqueObservers = [...new Set(traceData.map(t => t.observer))];
|
||||
const typeName = packetMeta ? payloadTypeName(packetMeta.payload_type) : '—';
|
||||
const typeClass = packetMeta ? payloadTypeColor(packetMeta.payload_type) : 'unknown';
|
||||
@@ -136,12 +141,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${allPaths.length > 0 ? renderPathGraph(allPaths) : ''}
|
||||
${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw) : ''}
|
||||
${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPathGraph(allPaths) {
|
||||
function renderPathGraph(allPaths, allPathsRaw) {
|
||||
// Collect unique nodes and edges across all observed paths
|
||||
const nodeSet = new Set();
|
||||
const edgeMap = new Map(); // "from→to" => Set of observer labels
|
||||
@@ -159,6 +164,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Attribute prefix observers to the edges they witnessed.
|
||||
// Prefix paths are dropped from allPaths to avoid spurious layout edges, but
|
||||
// their observers still corroborated the shared prefix segment — credit them
|
||||
// to the edges that exist in the full-path graph.
|
||||
const allPathsSet = new Set(allPaths);
|
||||
for (const entry of allPathsRaw) {
|
||||
if (allPathsSet.has(entry)) continue;
|
||||
const chain = ['Origin', ...entry.hops]; // no 'Dest': prefix stopped here
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const key = chain[i] + '→' + chain[i + 1];
|
||||
if (edgeMap.has(key)) edgeMap.get(key).add(entry.observer);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = [...nodeSet];
|
||||
// Assign positions: lay out nodes left to right by their earliest appearance in any path
|
||||
const order = new Map();
|
||||
@@ -185,7 +204,7 @@
|
||||
const colCount = maxCol + 1;
|
||||
const svgW = Math.max(600, colCount * 200);
|
||||
const maxRows = Math.max(...[...colGroups.values()].map(g => g.length));
|
||||
const svgH = Math.max(120, maxRows * 60 + 40);
|
||||
const svgH = Math.max(160, maxRows * 80 + 60);
|
||||
const colSpacing = svgW / (colCount + 1);
|
||||
|
||||
// Compute node positions
|
||||
@@ -238,12 +257,16 @@
|
||||
let nodesSvg = '';
|
||||
for (const [node, pos] of nodePos) {
|
||||
const isEndpoint = node === 'Origin' || node === 'Dest';
|
||||
const r = isEndpoint ? 18 : 14;
|
||||
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--surface-2, #374151)';
|
||||
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--border, #4b5563)';
|
||||
const label = isEndpoint ? node : node;
|
||||
const r = isEndpoint ? 18 : 16;
|
||||
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent-bg, rgba(59,130,246,0.12))';
|
||||
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent, #3b82f6)';
|
||||
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="${isEndpoint ? 10 : 9}" font-weight="${isEndpoint ? 700 : 500}">${escapeHtml(label)}</text>`;
|
||||
if (isEndpoint) {
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="10" font-weight="700">${escapeHtml(node)}</text>`;
|
||||
} else {
|
||||
// Label below the circle so it doesn't fight for space inside the small node
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + r + 14}" text-anchor="middle" fill="var(--text, #111827)" font-size="10" font-weight="500">${escapeHtml(node)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Legend: unique paths
|
||||
@@ -295,5 +318,19 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function dedupePrefixPaths(rawPaths) {
|
||||
return rawPaths.filter(({ hops }) => {
|
||||
const sig = JSON.stringify(hops);
|
||||
return !rawPaths.some(other => {
|
||||
if (other.hops.length <= hops.length) return false;
|
||||
return JSON.stringify(other.hops.slice(0, hops.length)) === sig;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.TracesHelpers = { dedupePrefixPaths };
|
||||
}
|
||||
|
||||
registerPage('traces', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -25,6 +25,7 @@ node test-channel-qr-wiring.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-analytics-channels-integration.js
|
||||
node test-observers-headings.js
|
||||
node test-traces.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/* Unit tests for traces.js helpers (tested via VM sandbox) */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ❌ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '', addEventListener() {} }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: () => {}, clearTimeout: () => {},
|
||||
setInterval: () => {}, clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] || null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map, Set, Promise, URLSearchParams,
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
registerPage: () => {},
|
||||
payloadTypeName: () => '',
|
||||
payloadTypeColor: () => '',
|
||||
escapeHtml: s => s,
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadTracesJs(ctx) {
|
||||
vm.runInContext(fs.readFileSync('public/traces.js', 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
// ===== dedupePrefixPaths tests =====
|
||||
console.log('\n=== traces.js: dedupePrefixPaths ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadTracesJs(ctx);
|
||||
const { dedupePrefixPaths } = ctx.TracesHelpers;
|
||||
|
||||
test('two strict-prefix observations: only longer kept', () => {
|
||||
const a = { hops: ['x', 'y'], observer: 'A' };
|
||||
const b = { hops: ['x', 'y', 'z'], observer: 'B' };
|
||||
const result = dedupePrefixPaths([a, b]);
|
||||
assert.deepStrictEqual(result, [b]);
|
||||
});
|
||||
|
||||
test('two identical-length identical-path observations: both kept', () => {
|
||||
const a = { hops: ['x', 'y'], observer: 'A' };
|
||||
const b = { hops: ['x', 'y'], observer: 'B' };
|
||||
const result = dedupePrefixPaths([a, b]);
|
||||
assert.deepStrictEqual(result, [a, b]);
|
||||
});
|
||||
|
||||
test('two divergent paths: both kept', () => {
|
||||
const a = { hops: ['x', 'y'], observer: 'A' };
|
||||
const b = { hops: ['x', 'z'], observer: 'B' };
|
||||
const result = dedupePrefixPaths([a, b]);
|
||||
assert.deepStrictEqual(result, [a, b]);
|
||||
});
|
||||
|
||||
test('empty hops array: not dropped (no superseder possible)', () => {
|
||||
const a = { hops: [], observer: 'A' };
|
||||
const b = { hops: ['x'], observer: 'B' };
|
||||
const result = dedupePrefixPaths([a, b]);
|
||||
// a has length 0, b has length 1; b.slice(0,0) = [] === [] so a IS a prefix of b
|
||||
// a should be dropped
|
||||
assert.ok(!result.includes(a), 'empty-hops path should be dropped when superseded');
|
||||
assert.ok(result.includes(b));
|
||||
});
|
||||
|
||||
test('three-level prefix chain (A⊂B⊂C): only C kept', () => {
|
||||
const a = { hops: ['x'], observer: 'A' };
|
||||
const b = { hops: ['x', 'y'], observer: 'B' };
|
||||
const c = { hops: ['x', 'y', 'z'], observer: 'C' };
|
||||
const result = dedupePrefixPaths([a, b, c]);
|
||||
assert.deepStrictEqual(result, [c]);
|
||||
});
|
||||
|
||||
test('multiple observers on identical full path: all kept', () => {
|
||||
const a = { hops: ['x', 'y', 'z'], observer: 'A' };
|
||||
const b = { hops: ['x', 'y', 'z'], observer: 'B' };
|
||||
const c = { hops: ['x', 'y', 'z'], observer: 'C' };
|
||||
const result = dedupePrefixPaths([a, b, c]);
|
||||
assert.deepStrictEqual(result, [a, b, c]);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` traces.js: ${passed} passed, ${failed} failed`);
|
||||
console.log(`${'═'.repeat(40)}\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user