diff --git a/public/analytics.js b/public/analytics.js
index 6b36b50..15b3d5a 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -69,7 +69,7 @@
RF / Signal
Topology
Channels
- Hash Sizes
+ Repeater Hashes
Route Patterns
@@ -743,23 +743,78 @@
const oneByteHops = topHops.filter(h => h.size === 1);
if (!oneByteHops.length) { el.innerHTML = '
No 1-byte hops
'; return; }
try {
- const nodesData = await api('/nodes?limit=500');
+ const nodesData = await api('/nodes?limit=2000');
const nodes = nodesData.nodes || [];
const collisions = [];
for (const hop of oneByteHops) {
const prefix = hop.hex.toLowerCase();
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
- if (matches.length > 1) collisions.push({ hop: hop.hex, count: hop.count, matches });
+ if (matches.length > 1) {
+ // Calculate pairwise distances for classification
+ const withCoords = matches.filter(m => m.lat && m.lon && !(m.lat === 0 && m.lon === 0));
+ let maxDistKm = 0;
+ let classification = 'unknown';
+ if (withCoords.length >= 2) {
+ for (let i = 0; i < withCoords.length; i++) {
+ for (let j = i + 1; j < withCoords.length; j++) {
+ const dLat = (withCoords[i].lat - withCoords[j].lat) * 111;
+ const dLon = (withCoords[i].lon - withCoords[j].lon) * 85;
+ const d = Math.sqrt(dLat * dLat + dLon * dLon);
+ if (d > maxDistKm) maxDistKm = d;
+ }
+ }
+ if (maxDistKm < 50) classification = 'local';
+ else if (maxDistKm < 200) classification = 'regional';
+ else classification = 'distant';
+ } else if (withCoords.length < 2) {
+ classification = 'incomplete';
+ }
+ collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
+ }
}
if (!collisions.length) { el.innerHTML = 'No collisions detected
'; return; }
+
+ // Sort: distant first (most interesting), then regional, local, incomplete
+ const classOrder = { distant: 0, regional: 1, local: 2, incomplete: 3, unknown: 4 };
+ collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
+
el.innerHTML = ``;
+ Hop Appearances Max Distance Assessment Colliding Nodes
+ ${collisions.map(c => {
+ let badge, tooltip;
+ if (c.classification === 'local') {
+ badge = '🏘️ Local ';
+ tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
+ } else if (c.classification === 'regional') {
+ badge = '⚡ Regional ';
+ tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
+ } else if (c.classification === 'distant') {
+ badge = '🌐 Distant ';
+ tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
+ } else {
+ badge = '❓ Unknown ';
+ tooltip = 'Not enough coordinate data to classify';
+ }
+ const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '— ';
+ return `
+ ${c.hop}
+ ${c.count.toLocaleString()}
+ ${distStr}
+ ${badge}
+ ${c.matches.map(m => {
+ const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
+ ? ` (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)}) `
+ : ' (no coords) ';
+ return `${esc(m.name || m.public_key.slice(0,12))} ${loc}`;
+ }).join(' ')}
+ `;
+ }).join('')}
+
+
+ 🏘️ Local <50km: true prefix collision, same mesh area
+ ⚡ Regional 50–200km: edge of LoRa range, possible atmospheric propagation
+ 🌐 Distant >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
+
`;
} catch { el.innerHTML = 'Failed to load
'; }
}