diff --git a/public/analytics.js b/public/analytics.js
index 7851bdb2c..b69b451f4 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -71,6 +71,40 @@
return ``;
}
+ // Packets / Hour (#2193): a long history rolls up to coarser bars (at most
+ // 180, so about 4 units each in barChart's plot) with about 10 labels.
+ // Under "All data" (no opts.window) the chart starts on the first UTC day
+ // with at least 2% of a typical recent day (the median of the seven days
+ // before the last one), at its first hour with a tenth of a typical hour: a
+ // short store also keeps a few old packets that were heard again, weeks
+ // before the rest. The card then says where it starts. In a picked window
+ // the server has already cut the data to it, so nothing is cropped.
+ function packetsPerHourCard(pph, opts) {
+ const icon = '';
+ if (!pph || !pph.length) return { html: `
${icon} Packets / Hour
No packets in this period.
`, from: null };
+ const at = (h) => Date.parse(h.hour.replace(' ', 'T') + ':00:00Z');
+ const days = new Map();
+ pph.forEach((h) => days.set(h.hour.slice(0, 10), (days.get(h.hour.slice(0, 10)) || 0) + h.count));
+ const recent = [...days.values()].slice(-8, -1).sort((a, b) => a - b);
+ const floor = (recent.length ? recent[recent.length >> 1] : 0) * 0.02;
+ const day = [...days.keys()].find((d) => days.get(d) >= floor);
+ const first = at(pph[0]);
+ const start = opts && opts.window ? first : at(pph.find((h) => h.hour.slice(0, 10) >= day && h.count >= floor * 5 / 24) || pph[0]);
+ const last = at(pph[pph.length - 1]), spanH = (last - start) / 3600e3 + 1;
+ const [bh, name] = [[1, 'Hour'], [6, '6 hours'], [24, 'Day']].find(([b]) => spanH / b <= 180) || [168, 'Week'];
+ const step = bh * 3600e3, from = Math.floor(start / step) * step, counts = [];
+ for (let t = from; t <= last; t += step) counts.push(0);
+ pph.forEach((h) => { const k = Math.floor((at(h) - from) / step); if (k >= 0) counts[k] += h.count; });
+ const every = [1, 3, 6, 12, 24, 48, 72, 168, 336, 672, 1344].find((e) => e >= bh && counts.length * bh / e <= 10) || 2688;
+ const labels = counts.map((_, i) => {
+ const t = from + i * step, d = new Date(t), hh = d.getUTCHours();
+ if ((t / 3600e3) % every) return '';
+ return spanH > 48 && (every >= 24 || hh === 0) ? d.toISOString().slice(5, 10) : String(hh).padStart(2, '0') + 'h';
+ });
+ const since = from > first ? ` since ${new Date(from).toISOString().slice(5, 10)}` : '';
+ return { html: `
${icon} Packets / ${name}${since}
${barChart(counts, labels, 'var(--accent)')}`, from };
+ }
+
function barChart(data, labels, colors, w = 800, h = 220, pad = 40) {
const max = Math.max(...data, 1);
const barW = Math.max(1, Math.min((w - pad * 2) / data.length - 2, 30));
@@ -260,6 +294,9 @@
// from window.ROLE_COLORS, role swatches use CSS tokens, and the one
// cached value (_labelColor) is re-read on restart.
if (_currentTab === 'neighbor-graph' && _ngState) { startGraphRenderer(); return; }
+ // Before the answers land there is nothing to re-render; loadAnalytics()
+ // renders the tab with the current theme when they do.
+ if (!_analyticsData.rfData) return;
renderTab(_currentTab);
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
@@ -299,7 +336,7 @@
api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })),
]);
- _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData };
+ _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData, window: twVal };
renderTab(_currentTab);
} catch (e) {
document.getElementById('analyticsContent').innerHTML =
@@ -405,28 +442,7 @@