/* === CoreScope: node-hop-analytics.js === */
'use strict';
// Hop count at this node (issue #1812): for every flood packet the node
// forwarded, the number of hops the packet already had when the node's
// flood.max check ran. Data: GET /api/nodes/{pubkey}/hop_analytics?days=N.
(function () {
// One filter per repeater CLI limit (firmware src/helpers/RoutingPolicy.h):
// flood.max applies to every flood, flood.max.unscoped to un-scoped floods,
// flood.max.advert to adverts.
const FILTERS = [
{ id: 'flood', label: 'flood.max', tag: null, noun: 'flood packets' },
{ id: 'flood_unscoped', label: 'flood.max.unscoped', tag: 'unscoped', noun: 'un-scoped flood packets' },
{ id: 'flood_adverts', label: 'flood.max.advert', tag: 'advert', noun: 'flood adverts' },
];
let chart = null;
function filterById(id) {
return FILTERS.find(f => f.id === id) || FILTERS[0];
}
function filterHops(packets, filterId) {
const tag = filterById(filterId).tag;
const out = [];
for (const p of packets || []) {
if (!tag || (p.tags && p.tags.indexOf(tag) >= 0)) out.push(Number(p.hops));
}
return out;
}
function hopHistogram(values) {
let max = -1;
for (const v of values) if (v > max) max = v;
const counts = new Array(max + 1).fill(0);
for (const v of values) counts[v]++;
return counts;
}
// Quartiles by linear interpolation between closest ranks, Tukey whiskers at
// 1.5 IQR. Hop counts are small integers, so sorting a copy stays cheap.
function hopBoxStats(values) {
if (!values.length) return null;
const s = values.slice().sort((a, b) => a - b);
const q = p => {
const h = (s.length - 1) * p;
const lo = Math.floor(h);
return lo + 1 < s.length ? s[lo] + (h - lo) * (s[lo + 1] - s[lo]) : s[lo];
};
const q1 = q(0.25), median = q(0.5), q3 = q(0.75);
const lowFence = q1 - 1.5 * (q3 - q1), highFence = q3 + 1.5 * (q3 - q1);
let whiskerLow = q1, whiskerHigh = q3, outliers = 0;
for (const v of s) {
if (v < lowFence || v > highFence) { outliers++; continue; }
if (v < whiskerLow) whiskerLow = v;
if (v > whiskerHigh) whiskerHigh = v;
}
return { n: s.length, min: s[0], q1, median, q3, max: s[s.length - 1], whiskerLow, whiskerHigh, outliers };
}
function plural(n, word) {
return n + ' ' + word + (n === 1 ? '' : 's');
}
function renderHopSection(data, filterId) {
const filter = filterById(filterId);
const values = filterHops(data.packets, filter.id);
const stats = hopBoxStats(values);
const ambiguous = Number(data.ambiguous) || 0;
const chips = FILTERS.map(f =>
``
).join('');
const summary = stats
? `${plural(stats.n, 'packet')} (${filter.noun}) · median ${stats.median} · middle half ${stats.q1} to ${stats.q3} · max ${stats.max} hops`
: '';
const body = stats
? ``
: `
No forwarded ${filter.noun} attributed to this node in this window.
`;
const note = ambiguous
? `${plural(ambiguous, 'packet')} left out: the path prefix of this node is shared with another node there, so its hop position is unknown.
`
: '';
return `
${chips}
${summary}
${body}
${note}`;
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// Box plot drawn in the chart's top padding, on the histogram's own x axis:
// the category scale puts hop count h at index h, so fractional quartiles are
// interpolated between neighbouring category centres.
function boxPlotPlugin(stats) {
return {
id: 'hopBoxPlot',
afterDatasetsDraw(c) {
const x = c.scales.x;
const px = v => {
const lo = Math.floor(v);
const a = x.getPixelForValue(lo);
return v === lo ? a : a + (v - lo) * (x.getPixelForValue(lo + 1) - a);
};
const ctx = c.ctx;
const mid = c.chartArea.top - 16;
ctx.save();
ctx.strokeStyle = cssVar('--text');
ctx.fillStyle = cssVar('--accent-bg');
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(px(stats.whiskerLow), mid); ctx.lineTo(px(stats.q1), mid);
ctx.moveTo(px(stats.q3), mid); ctx.lineTo(px(stats.whiskerHigh), mid);
ctx.moveTo(px(stats.whiskerLow), mid - 5); ctx.lineTo(px(stats.whiskerLow), mid + 5);
ctx.moveTo(px(stats.whiskerHigh), mid - 5); ctx.lineTo(px(stats.whiskerHigh), mid + 5);
ctx.stroke();
ctx.fillRect(px(stats.q1), mid - 8, px(stats.q3) - px(stats.q1), 16);
ctx.strokeRect(px(stats.q1), mid - 8, px(stats.q3) - px(stats.q1), 16);
ctx.beginPath();
ctx.moveTo(px(stats.median), mid - 8); ctx.lineTo(px(stats.median), mid + 8);
ctx.stroke();
ctx.restore();
}
};
}
function drawChart(data, filterId) {
if (chart) { chart.destroy(); chart = null; }
const canvas = document.getElementById('hopCountChart');
if (!canvas) return;
const values = filterHops(data.packets, filterId);
const counts = hopHistogram(values);
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: counts.map((_, h) => String(h)),
datasets: [{ label: 'Packets', data: counts, backgroundColor: cssVar('--accent'), borderWidth: 0 }]
},
options: {
responsive: true,
layout: { padding: { top: 32 } },
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Hops already in the path when this node forwarded' } },
y: { beginAtZero: true, title: { display: true, text: 'Packets' } }
}
},
plugins: [boxPlotPlugin(hopBoxStats(values))]
});
}
function render(container, data, filterId) {
container.innerHTML = renderHopSection(data, filterId);
container.querySelectorAll('[data-hop-filter]').forEach(btn => {
btn.addEventListener('click', () => render(container, data, btn.dataset.hopFilter));
});
drawChart(data, filterId);
}
async function load(container, pubkey, days) {
destroy();
let data;
try {
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/hop_analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics });
} catch (e) {
container.innerHTML = 'Hop counts unavailable: ' + escapeHtml(e.message) + '
';
return;
}
if (!container.isConnected) return;
render(container, data, 'flood');
}
function destroy() {
if (chart) { chart.destroy(); chart = null; }
}
window.NodeHopAnalytics = { FILTERS, filterHops, hopHistogram, hopBoxStats, renderHopSection, load, destroy };
})();