diff --git a/cmd/server/store.go b/cmd/server/store.go
index b2ffec40..2df6de40 100644
--- a/cmd/server/store.go
+++ b/cmd/server/store.go
@@ -247,6 +247,82 @@ type distHopRecord struct {
tx *StoreTx
}
+// dedupeHopsByPair groups hops by unordered node-pair, keeps the max-distance
+// record per pair, and computes obsCount / bestSnr / medianSnr. limit caps the
+// number of returned entries (sorted by distance descending).
+func dedupeHopsByPair(hops []distHopRecord, limit int) []map[string]interface{} {
+ type pairAgg struct {
+ best *distHopRecord
+ obsCount int
+ maxSNR *float64
+ snrs []float64
+ }
+ pairMap := make(map[string]*pairAgg)
+ for i := range hops {
+ h := &hops[i]
+ pk1, pk2 := h.FromPk, h.ToPk
+ if pk1 > pk2 {
+ pk1, pk2 = pk2, pk1
+ }
+ key := pk1 + "|" + pk2
+ agg, ok := pairMap[key]
+ if !ok {
+ agg = &pairAgg{}
+ pairMap[key] = agg
+ }
+ agg.obsCount++
+ if h.SNR != nil {
+ agg.snrs = append(agg.snrs, *h.SNR)
+ if agg.maxSNR == nil || *h.SNR > *agg.maxSNR {
+ v := *h.SNR
+ agg.maxSNR = &v
+ }
+ }
+ if agg.best == nil || h.Dist > agg.best.Dist {
+ agg.best = h
+ }
+ }
+ type pairEntry struct {
+ key string
+ agg *pairAgg
+ }
+ pairs := make([]pairEntry, 0, len(pairMap))
+ for k, v := range pairMap {
+ pairs = append(pairs, pairEntry{k, v})
+ }
+ sort.Slice(pairs, func(i, j int) bool { return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist })
+ result := make([]map[string]interface{}, 0, min(limit, len(pairs)))
+ for i, pe := range pairs {
+ if i >= limit {
+ break
+ }
+ h := pe.agg.best
+ var medianSNR *float64
+ if len(pe.agg.snrs) > 0 {
+ sorted := make([]float64, len(pe.agg.snrs))
+ copy(sorted, pe.agg.snrs)
+ sort.Float64s(sorted)
+ mid := len(sorted) / 2
+ if len(sorted)%2 == 0 {
+ v := (sorted[mid-1] + sorted[mid]) / 2
+ medianSNR = &v
+ } else {
+ v := sorted[mid]
+ medianSNR = &v
+ }
+ }
+ result = append(result, map[string]interface{}{
+ "fromName": h.FromName, "fromPk": h.FromPk,
+ "toName": h.ToName, "toPk": h.ToPk,
+ "dist": h.Dist, "type": h.Type,
+ "bestSnr": floatPtrOrNil(pe.agg.maxSNR), "medianSnr": floatPtrOrNil(medianSNR),
+ "obsCount": pe.agg.obsCount,
+ "hash": h.Hash, "timestamp": h.Timestamp,
+ })
+ }
+ return result
+}
+
type distPathRecord struct {
Hash string
TotalDist float64
@@ -5114,21 +5190,7 @@ func (s *PacketStore) computeAnalyticsDistance(region string) map[string]interfa
}
}
- // Sort and pick top hops
- sort.Slice(filteredHops, func(i, j int) bool { return filteredHops[i].Dist > filteredHops[j].Dist })
- topHops := make([]map[string]interface{}, 0)
- for i := range filteredHops {
- if i >= 50 {
- break
- }
- h := &filteredHops[i]
- topHops = append(topHops, map[string]interface{}{
- "fromName": h.FromName, "fromPk": h.FromPk,
- "toName": h.ToName, "toPk": h.ToPk,
- "dist": h.Dist, "type": h.Type,
- "snr": floatPtrOrNil(h.SNR), "hash": h.Hash, "timestamp": h.Timestamp,
- })
- }
+ topHops := dedupeHopsByPair(filteredHops, 20)
// Sort and pick top paths
sort.Slice(filteredPaths, func(i, j int) bool { return filteredPaths[i].TotalDist > filteredPaths[j].TotalDist })
diff --git a/cmd/server/store_tophops_test.go b/cmd/server/store_tophops_test.go
new file mode 100644
index 00000000..6fbbd141
--- /dev/null
+++ b/cmd/server/store_tophops_test.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+ "testing"
+)
+
+func f64(v float64) *float64 { return &v }
+
+func TestDedupeTopHopsByPair(t *testing.T) {
+ hops := []distHopRecord{
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: f64(5.0), Hash: "h1", Timestamp: "t1"},
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: f64(8.0), Hash: "h2", Timestamp: "t2"},
+ {FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 80, Type: "R↔R", SNR: f64(3.0), Hash: "h3", Timestamp: "t3"},
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 70, Type: "R↔R", SNR: f64(6.0), Hash: "h4", Timestamp: "t4"},
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 60, Type: "R↔R", SNR: f64(4.0), Hash: "h5", Timestamp: "t5"},
+ {FromPk: "CCC", ToPk: "DDD", FromName: "C", ToName: "D", Dist: 50, Type: "C↔R", SNR: f64(7.0), Hash: "h6", Timestamp: "t6"},
+ }
+
+ result := dedupeHopsByPair(hops, 20)
+
+ if len(result) != 2 {
+ t.Fatalf("expected 2 entries, got %d", len(result))
+ }
+
+ // First entry: A↔B pair, max distance = 100, obsCount = 5
+ ab := result[0]
+ if ab["dist"].(float64) != 100 {
+ t.Errorf("expected dist 100, got %v", ab["dist"])
+ }
+ if ab["obsCount"].(int) != 5 {
+ t.Errorf("expected obsCount 5, got %v", ab["obsCount"])
+ }
+ if ab["hash"].(string) != "h1" {
+ t.Errorf("expected hash h1 (from max-dist record), got %v", ab["hash"])
+ }
+ if ab["bestSnr"].(float64) != 8.0 {
+ t.Errorf("expected bestSnr 8.0, got %v", ab["bestSnr"])
+ }
+ // medianSnr of [3,4,5,6,8] = 5.0
+ if ab["medianSnr"].(float64) != 5.0 {
+ t.Errorf("expected medianSnr 5.0, got %v", ab["medianSnr"])
+ }
+
+ // Second entry: C↔D pair
+ cd := result[1]
+ if cd["dist"].(float64) != 50 {
+ t.Errorf("expected dist 50, got %v", cd["dist"])
+ }
+ if cd["obsCount"].(int) != 1 {
+ t.Errorf("expected obsCount 1, got %v", cd["obsCount"])
+ }
+}
+
+func TestDedupeTopHopsReversePairMerges(t *testing.T) {
+ hops := []distHopRecord{
+ {FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 50, Type: "R↔R", Hash: "h1"},
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 80, Type: "R↔R", Hash: "h2"},
+ }
+ result := dedupeHopsByPair(hops, 20)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(result))
+ }
+ if result[0]["obsCount"].(int) != 2 {
+ t.Errorf("expected obsCount 2, got %v", result[0]["obsCount"])
+ }
+ if result[0]["dist"].(float64) != 80 {
+ t.Errorf("expected dist 80, got %v", result[0]["dist"])
+ }
+}
+
+func TestDedupeTopHopsNilSNR(t *testing.T) {
+ hops := []distHopRecord{
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: nil, Hash: "h1"},
+ {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: nil, Hash: "h2"},
+ }
+ result := dedupeHopsByPair(hops, 20)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(result))
+ }
+ if result[0]["bestSnr"] != nil {
+ t.Errorf("expected bestSnr nil, got %v", result[0]["bestSnr"])
+ }
+ if result[0]["medianSnr"] != nil {
+ t.Errorf("expected medianSnr nil, got %v", result[0]["medianSnr"])
+ }
+}
+
+func TestDedupeTopHopsLimit(t *testing.T) {
+ // Generate 25 unique pairs, verify limit=20 caps output
+ hops := make([]distHopRecord, 25)
+ for i := range hops {
+ hops[i] = distHopRecord{
+ FromPk: "A", ToPk: string(rune('a' + i)),
+ Dist: float64(i), Type: "R↔R", Hash: "h",
+ }
+ }
+ result := dedupeHopsByPair(hops, 20)
+ if len(result) != 20 {
+ t.Errorf("expected 20 entries, got %d", len(result))
+ }
+}
+
+func TestDedupeTopHopsEvenMedian(t *testing.T) {
+ // Even count: median = avg of two middle values
+ hops := []distHopRecord{
+ {FromPk: "A", ToPk: "B", Dist: 10, Type: "R↔R", SNR: f64(2.0), Hash: "h1"},
+ {FromPk: "A", ToPk: "B", Dist: 20, Type: "R↔R", SNR: f64(4.0), Hash: "h2"},
+ {FromPk: "A", ToPk: "B", Dist: 30, Type: "R↔R", SNR: f64(6.0), Hash: "h3"},
+ {FromPk: "A", ToPk: "B", Dist: 40, Type: "R↔R", SNR: f64(8.0), Hash: "h4"},
+ }
+ result := dedupeHopsByPair(hops, 20)
+ // sorted SNR: [2,4,6,8], median = (4+6)/2 = 5.0
+ if result[0]["medianSnr"].(float64) != 5.0 {
+ t.Errorf("expected medianSnr 5.0, got %v", result[0]["medianSnr"])
+ }
+}
diff --git a/cmd/server/testdata/golden/shapes.json b/cmd/server/testdata/golden/shapes.json
index d715e89a..4556ddd5 100644
--- a/cmd/server/testdata/golden/shapes.json
+++ b/cmd/server/testdata/golden/shapes.json
@@ -42,14 +42,20 @@
"type": {
"type": "string"
},
- "snr": {
- "type": "number"
- },
"hash": {
"type": "string"
},
"timestamp": {
"type": "string"
+ },
+ "bestSnr": {
+ "type": "number"
+ },
+ "medianSnr": {
+ "type": "number"
+ },
+ "obsCount": {
+ "type": "number"
}
}
}
@@ -1580,4 +1586,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/cmd/server/types.go b/cmd/server/types.go
index 76ea0392..fc4cbd1d 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -678,7 +678,9 @@ type DistanceHop struct {
ToPk string `json:"toPk"`
Dist float64 `json:"dist"`
Type string `json:"type"`
- SNR interface{} `json:"snr"`
+ BestSnr interface{} `json:"bestSnr"`
+ MedianSnr interface{} `json:"medianSnr"`
+ ObsCount int `json:"obsCount"`
Hash string `json:"hash"`
Timestamp string `json:"timestamp"`
}
diff --git a/public/analytics.js b/public/analytics.js
index 9eb080d0..27e8fd4d 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -1937,15 +1937,18 @@
}
// Top hops leaderboard
- html += `
🏆 Top 20 Longest Hops
| # | From | To | Distance (${distUnitLabel}) | Type | SNR | Packet | |
`;
+ html += `🏆 Top 20 Longest Hops
| # | From | To | Distance (${distUnitLabel}) | Type | Obs | Best SNR | Median SNR | Packet | |
`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `${esc(h.fromName)}` : esc(h.fromName || '?');
const toLink = h.toPk ? `${esc(h.toName)}` : esc(h.toName || '?');
- const snr = h.snr != null ? h.snr + ' dB' : '—';
+ const bestSnr = h.bestSnr != null ? Number(h.bestSnr).toFixed(1) + ' dB' : '—';
+ const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : '—';
+ const obs = h.obsCount != null ? h.obsCount : 1;
const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}…` : '—';
const mapBtn = h.fromPk && h.toPk ? `` : '';
- html += `| ${i+1} | ${fromLink} | ${toLink} | ${formatDistance(h.dist)} | ${esc(h.type)} | ${snr} | ${pktLink} | ${mapBtn} |
`;
+ const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : '';
+ html += `| ${i+1} | ${fromLink} | ${toLink} | ${formatDistance(h.dist)} | ${esc(h.type)} | ${obs} | ${bestSnr} | ${medianSnr} | ${pktLink} | ${mapBtn} |
`;
});
html += `
`;