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 @@ - + @@ -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 = ` - - ${collisions.map(c => ` - - - - `).join('')} -
HopAppearancesColliding Nodes
${c.hop}${c.count.toLocaleString()}${c.matches.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', ')}
`; + HopAppearancesMax DistanceAssessmentColliding 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
'; } }