diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 0a9a5d33..e17cd9e6 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -138,6 +138,7 @@ jobs:
node test-issue-1619-feed-detail-card-draggable.js
node test-xss-escape-sinks.js
node test-preflight-xss-gate.js
+ node test-traces.js
- name: 🛡️ Preflight XSS gate — actual --diff check (PR only)
# The fixture self-test above (test-preflight-xss-gate.js) only
diff --git a/public/traces.js b/public/traces.js
index d3c02416..e0221d43 100644
--- a/public/traces.js
+++ b/public/traces.js
@@ -147,12 +147,12 @@
- ${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw) : ''}
+ ${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw, decoded) : ''}
${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''}
`;
}
- function renderPathGraph(allPaths, allPathsRaw) {
+ function renderPathGraph(allPaths, allPathsRaw, decoded) {
// Collect unique nodes and edges across all observed paths
const nodeSet = new Set();
const edgeMap = new Map(); // "from→to" => Set of observer labels
@@ -261,6 +261,29 @@
}
let nodesSvg = '';
+ // Per-hop SNR overlay: TRACE packets only, numeric labels at hop midpoints
+ // (Tufte: no double-encoding via color/thickness — number is the signal).
+ const snrValues = (decoded && decoded.type === 'TRACE' && Array.isArray(decoded.snrValues))
+ ? decoded.snrValues : null;
+ if (snrValues && snrValues.length > 0) {
+ // Build the hop chain from the first observed full path (Origin → hops → Dest).
+ const firstPath = allPaths[0];
+ if (firstPath && Array.isArray(firstPath.hops)) {
+ const chain = ['Origin', ...firstPath.hops, 'Dest'];
+ const hopCount = Math.min(snrValues.length, chain.length - 1);
+ for (let i = 0; i < hopCount; i++) {
+ const p1 = nodePos.get(chain[i]);
+ const p2 = nodePos.get(chain[i + 1]);
+ if (!p1 || !p2) continue;
+ const v = snrValues[i];
+ if (v == null || !Number.isFinite(Number(v))) continue;
+ const mx = (p1.x + p2.x) / 2;
+ const my = (p1.y + p2.y) / 2 - 6;
+ const label = Number(v).toFixed(1);
+ nodesSvg += `${label}`;
+ }
+ }
+ }
for (const [node, pos] of nodePos) {
const isEndpoint = node === 'Origin' || node === 'Dest';
const r = isEndpoint ? 18 : 16;
@@ -335,7 +358,7 @@
}
if (typeof window !== 'undefined') {
- window.TracesHelpers = { dedupePrefixPaths };
+ window.TracesHelpers = { dedupePrefixPaths, renderPathGraph };
}
registerPage('traces', { init, destroy });
diff --git a/test-traces.js b/test-traces.js
index a5d36ff0..1809ec20 100644
--- a/test-traces.js
+++ b/test-traces.js
@@ -119,6 +119,45 @@ console.log('\n=== traces.js: dedupePrefixPaths ===');
});
}
+// ===== renderPathGraph: per-hop SNR overlay (#1004 Phase 2 of #979) =====
+console.log('\n=== traces.js: renderPathGraph hop-SNR overlay ===');
+{
+ const ctx = makeSandbox();
+ loadTracesJs(ctx);
+ const { renderPathGraph } = ctx.TracesHelpers;
+ assert.strictEqual(typeof renderPathGraph, 'function', 'renderPathGraph must be exported');
+
+ const paths = [{ hops: ['R1', 'R2'], observer: 'OBS' }];
+ const decodedTrace = { type: 'TRACE', snrValues: [-3.5, -7.0, -12.25] };
+ const decodedNonTrace = { type: 'CHAN', snrValues: [-3.5, -7.0] };
+
+ test('TRACE with snrValues emits labels with values', () => {
+ const html = renderPathGraph(paths, paths, decodedTrace);
+ assert.ok(/class="hop-snr"/.test(html), 'expected in output');
+ // Each numeric value should appear in a hop-snr label.
+ const labels = html.match(/]*class="hop-snr"[^>]*>([^<]+)<\/text>/g) || [];
+ assert.ok(labels.length >= 1, 'expected at least one hop-snr label');
+ const joined = labels.join(' ');
+ assert.ok(/-3\.5/.test(joined), 'expected -3.5 in hop-snr label, got: ' + joined);
+ assert.ok(/-7(\.0)?/.test(joined), 'expected -7 in hop-snr label, got: ' + joined);
+ });
+
+ test('non-TRACE packet: hop-snr labels are ABSENT even when snrValues present', () => {
+ const html = renderPathGraph(paths, paths, decodedNonTrace);
+ assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render for non-TRACE');
+ });
+
+ test('TRACE with empty snrValues: no hop-snr labels', () => {
+ const html = renderPathGraph(paths, paths, { type: 'TRACE', snrValues: [] });
+ assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render when snrValues empty');
+ });
+
+ test('decoded omitted: no hop-snr labels (back-compat)', () => {
+ const html = renderPathGraph(paths, paths);
+ assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render when decoded omitted');
+ });
+}
+
// ===== SUMMARY =====
console.log(`\n${'═'.repeat(40)}`);
console.log(` traces.js: ${passed} passed, ${failed} failed`);