mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-21 18:09:50 +00:00
feat(web-viewer): hide flood hop buckets under 0.1% of the series
The flood tail decays over roughly twenty hops in bars under a pixel tall. Buckets holding less than 0.1% of the series are no longer drawn, which on the live mesh takes the axis from 64 buckets to 44. What is left out is reported, not dropped: a line under the chart reads "1,467 further flood packets (0.9%) sit in 20 hop buckets below 0.1% each, and are not drawn." An unannounced cut would be the same lie as a silently truncated category list. Two details that matter for honesty: Percentages still divide by the whole series, never by the drawn subset, so removing the tail cannot inflate the bars that remain. The smallest surviving bar on live data is 0.118%, which is what it was before. A withheld bucket inside the axis is null rather than zero. Zero would claim no packets travelled that far, which is a different and false statement; null draws nothing and says nothing. Padding gaps stay zero, because there it is true. The threshold applies to the flood series only — the node series is small enough to draw in full — and the underlying computation still covers the whole 64-hop protocol range.
This commit is contained in:
@@ -99,6 +99,12 @@ MIX_ROWS = 8
|
||||
# at the far end; one such node exists in the live history.
|
||||
MAX_PLOTTED_HOPS = 64
|
||||
|
||||
# Flood hop buckets carrying less than this share of the series are not drawn.
|
||||
# The tail decays for ~20 hops in bars under a pixel tall, which reads as noise;
|
||||
# the amount withheld is reported alongside the chart rather than dropped
|
||||
# quietly.
|
||||
FLOOD_MIN_SHARE_PCT = 0.1
|
||||
|
||||
# Metrics that are already a ratio: a period "total" has to be the mean of the
|
||||
# daily values, not their sum — adding percentages together means nothing.
|
||||
RATIO_METRICS = frozenset({"multibyte_share"})
|
||||
@@ -856,17 +862,52 @@ class DashboardStatsService:
|
||||
)
|
||||
)
|
||||
|
||||
totals = {key: sum(count for _, count in series) for key, series in distribution.items()}
|
||||
|
||||
# The flood tail is a long thin decay — on the live mesh it runs to 63
|
||||
# hops in bars under a pixel tall. Hide the buckets carrying less than
|
||||
# FLOOD_MIN_SHARE_PCT of the series, but count what was hidden: an
|
||||
# unannounced cut is the same lie as a truncated category list.
|
||||
flood_total = totals["flood_packets"]
|
||||
hidden_packets = 0
|
||||
hidden_buckets = 0
|
||||
if flood_total:
|
||||
kept = []
|
||||
for hop, count in distribution["flood_packets"]:
|
||||
if count and (count / flood_total) * 100 < FLOOD_MIN_SHARE_PCT:
|
||||
hidden_packets += count
|
||||
hidden_buckets += 1
|
||||
kept.append([hop, None])
|
||||
else:
|
||||
kept.append([hop, count])
|
||||
distribution["flood_packets"] = kept
|
||||
|
||||
# Pad both onto one contiguous axis so the bars line up, bounded by the
|
||||
# hops that actually carry something: an axis that runs on past the last
|
||||
# observation spends its width on nothing.
|
||||
# hops that actually show something: an axis that runs on past the last
|
||||
# drawn bar spends its width on nothing.
|
||||
populated = [
|
||||
hop for series in distribution.values() for hop, count in series if count
|
||||
hop
|
||||
for series in distribution.values()
|
||||
for hop, count in series
|
||||
if count
|
||||
]
|
||||
if populated:
|
||||
low, high = min(populated), max(populated)
|
||||
for key, series in distribution.items():
|
||||
counts = dict(series)
|
||||
distribution[key] = [[hop, counts.get(hop, 0)] for hop in range(low, high + 1)]
|
||||
# Gaps pad with 0 ("no packets at this hop"), which is a
|
||||
# different claim from the None used above for "withheld".
|
||||
distribution[key] = [
|
||||
[hop, counts.get(hop, 0)] for hop in range(low, high + 1)
|
||||
]
|
||||
|
||||
distribution["totals"] = totals
|
||||
distribution["flood_hidden"] = {
|
||||
"packets": hidden_packets,
|
||||
"buckets": hidden_buckets,
|
||||
"share_pct": round(hidden_packets / flood_total * 100, 2) if flood_total else 0,
|
||||
"threshold_pct": FLOOD_MIN_SHARE_PCT,
|
||||
}
|
||||
return distribution
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -522,15 +522,25 @@
|
||||
this.charts[canvasId].destroy();
|
||||
delete this.charts[canvasId];
|
||||
}
|
||||
const withheld = (hops || {}).flood_hidden || {};
|
||||
setText('hops-hidden-note', withheld.packets
|
||||
? formatNumber(withheld.packets) + ' further flood packets (' +
|
||||
withheld.share_pct + '%) sit in ' + withheld.buckets +
|
||||
' hop buckets below ' + withheld.threshold_pct + '% each, and are not drawn.'
|
||||
: '');
|
||||
|
||||
if (nodes.length === 0 && flood.length === 0) return;
|
||||
|
||||
const labels = (nodes.length ? nodes : flood).map((b) => b[0]);
|
||||
const share = (series) => {
|
||||
const total = series.reduce((sum, b) => sum + b[1], 0);
|
||||
return { total, values: series.map((b) => (total ? (b[1] / total) * 100 : 0)) };
|
||||
};
|
||||
const nodeShare = share(nodes);
|
||||
const floodShare = share(flood);
|
||||
const totals = (hops || {}).totals || {};
|
||||
// Percentages are shares of the whole series, not of the bars that
|
||||
// survived the display threshold — otherwise hiding the tail would
|
||||
// silently inflate every remaining bar.
|
||||
const share = (series, total) => series.map(
|
||||
(b) => (b[1] === null || !total ? null : (b[1] / total) * 100)
|
||||
);
|
||||
const nodeShare = share(nodes, totals.nodes);
|
||||
const floodShare = share(flood, totals.flood_packets);
|
||||
|
||||
// A path can be 64 hops at one byte per hop, so the axis may carry
|
||||
// 64 grouped categories. Rounded corners and default bar padding
|
||||
@@ -540,7 +550,7 @@
|
||||
if (nodes.length) {
|
||||
datasets.push({
|
||||
label: 'Nodes (adverts, 7d)',
|
||||
data: nodeShare.values,
|
||||
data: nodeShare,
|
||||
counts: nodes.map((b) => b[1]),
|
||||
unit: 'nodes',
|
||||
backgroundColor: COLOR.accent,
|
||||
@@ -552,7 +562,7 @@
|
||||
if (flood.length) {
|
||||
datasets.push({
|
||||
label: 'Flood packets (' + (packetWindowLabel || 'retained') + ')',
|
||||
data: floodShare.values,
|
||||
data: floodShare,
|
||||
counts: flood.map((b) => b[1]),
|
||||
unit: 'packets',
|
||||
backgroundColor: COLOR.flood,
|
||||
@@ -578,8 +588,9 @@
|
||||
title: (items) => items[0].label + ' hops away',
|
||||
label: (ctx) => {
|
||||
const count = ctx.dataset.counts[ctx.dataIndex];
|
||||
if (count === null || count === undefined) return null;
|
||||
return ctx.dataset.label + ': ' + formatNumber(count) + ' ' +
|
||||
ctx.dataset.unit + ' (' + ctx.parsed.y.toFixed(1) + '%)';
|
||||
ctx.dataset.unit + ' (' + ctx.parsed.y.toFixed(2) + '%)';
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1072,8 +1083,11 @@
|
||||
'had already travelled when it arrived, over whatever the packet stream retains ' +
|
||||
'(typically 3 days). Because one counts nodes and the other counts packets, both ' +
|
||||
'are drawn as a share of their own total; the tooltip gives the raw counts. ' +
|
||||
'Flood packets carry no sender identity, so they cannot be reduced to a shortest ' +
|
||||
'path per node the way adverts can.',
|
||||
'Hop buckets holding under 0.1% of the flood series are omitted — that tail ' +
|
||||
'decays for around twenty hops in bars under a pixel tall — and the amount left ' +
|
||||
'out is stated beneath the chart. Percentages stay shares of the full series, ' +
|
||||
'not of the drawn subset. Flood packets carry no sender identity, so they cannot ' +
|
||||
'be reduced to a shortest path per node the way adverts can.',
|
||||
};
|
||||
|
||||
function initTooltips() {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<canvas id="hopsHistogramChart"
|
||||
aria-label="Nodes and flood packets by hop distance"></canvas>
|
||||
</div>
|
||||
<p class="dashboard-note mt-2 mb-0" id="hops-hidden-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user