Compare commits

...

1 Commits

Author SHA1 Message Date
Kpa-clawbot
86127d5021 feat: add hash size distribution by number of repeaters
Add a new 'By Repeaters' view to the hash size distribution card that
shows how many unique repeaters advertise each hash size, complementing
the existing per-packet distribution.

Backend: compute distributionByRepeaters from byNode data in
/api/analytics/hash-sizes endpoint.

Frontend: render a second bar chart under the existing distribution
showing repeater counts with percentages.

Tests: verify distributionByRepeaters is present and correctly typed.
2026-03-29 22:03:55 +00:00
4 changed files with 60 additions and 27 deletions

View File

@@ -876,6 +876,26 @@
</div>`;
}).join('')}
</div>
${data.distributionByRepeaters ? (() => {
const dr = data.distributionByRepeaters;
const totalRepeaters = (dr[1] || 0) + (dr[2] || 0) + (dr[3] || 0);
const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0';
const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1);
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
return `<h4 style="margin:16px 0 4px">By Repeaters</h4>
<p class="text-muted">${totalRepeaters.toLocaleString()} unique repeaters</p>
<div class="hash-bars">
${[1, 2, 3].map(size => {
const count = dr[size] || 0;
const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0);
return `<div class="hash-bar-row">
<div class="hash-bar-label"><strong>${size}-byte</strong></div>
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]};opacity:0.7"></div></div>
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${rpct(count)}%)</span></div>
</div>`;
}).join('')}
</div>`;
})() : ''}
</div>
<div class="analytics-card flex-1">
<h3>📈 Hash Size Over Time</h3>

View File

@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774786038">
<link rel="stylesheet" href="home.css?v=1774786038">
<link rel="stylesheet" href="live.css?v=1774786038">
<link rel="stylesheet" href="style.css?v=1774821783">
<link rel="stylesheet" href="home.css?v=1774821783">
<link rel="stylesheet" href="live.css?v=1774821783">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,29 +81,29 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774786038"></script>
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774786038"></script>
<script src="hop-resolver.js?v=1774786038"></script>
<script src="hop-display.js?v=1774786038"></script>
<script src="app.js?v=1774786038"></script>
<script src="home.js?v=1774786038"></script>
<script src="packet-filter.js?v=1774786038"></script>
<script src="packets.js?v=1774786038"></script>
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774821783"></script>
<script src="customize.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774821783"></script>
<script src="hop-resolver.js?v=1774821783"></script>
<script src="hop-display.js?v=1774821783"></script>
<script src="app.js?v=1774821783"></script>
<script src="home.js?v=1774821783"></script>
<script src="packet-filter.js?v=1774821783"></script>
<script src="packets.js?v=1774821783"></script>
<script src="map.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>

View File

@@ -1987,9 +1987,17 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
.sort(([, a], [, b]) => b.packets - a.packets)
.map(([name, data]) => ({ name, ...data }));
// Distribution by number of repeaters advertising each hash size
const distributionByRepeaters = { 1: 0, 2: 0, 3: 0 };
for (const [, v] of Object.entries(byNode)) {
const s = v.hashSize;
if (s >= 1 && s <= 3) distributionByRepeaters[s]++;
}
const _hsResult = {
total: packets.length,
distribution,
distributionByRepeaters,
hourly,
topHops,
multiByteNodes

View File

@@ -583,6 +583,11 @@ seedTestData();
await t('GET /api/analytics/hash-sizes', async () => {
const r = await request(app).get('/api/analytics/hash-sizes').expect(200);
assert(typeof r.body === 'object', 'should return hash sizes');
assert(r.body.distributionByRepeaters, 'should include distributionByRepeaters');
assert(typeof r.body.distributionByRepeaters === 'object', 'distributionByRepeaters should be an object');
for (const s of [1, 2, 3]) {
assert(typeof r.body.distributionByRepeaters[s] === 'number', `distributionByRepeaters[${s}] should be a number`);
}
});
await t('GET /api/analytics/hash-sizes with region', async () => {