diff --git a/db_test.go b/db_test.go
index 4e3702f..6184211 100644
--- a/db_test.go
+++ b/db_test.go
@@ -106,6 +106,159 @@ func TestUpsertMapReportInsertsAndUpdatesSameNode(t *testing.T) {
}
}
+func TestListMapReportsFiltersByBounds(t *testing.T) {
+ st := openTestStore(t)
+ defer st.Close()
+
+ inside := mapReportTestRecord("inside")
+ inside["from"] = "!00000001"
+ inside["from_num"] = uint32(1)
+ inside["latitude"] = 10.5
+ inside["longitude"] = 20.5
+ outside := mapReportTestRecord("outside")
+ outside["from"] = "!00000002"
+ outside["from_num"] = uint32(2)
+ outside["latitude"] = 50.0
+ outside["longitude"] = 20.5
+ missingCoords := mapReportTestRecord("missing coords")
+ missingCoords["from"] = "!00000003"
+ missingCoords["from_num"] = uint32(3)
+ delete(missingCoords, "latitude")
+ delete(missingCoords, "longitude")
+
+ for _, record := range []map[string]any{inside, outside, missingCoords} {
+ if err := st.UpsertMapReport(record); err != nil {
+ t.Fatalf("UpsertMapReport() error = %v", err)
+ }
+ }
+
+ minLat, maxLat := 10.0, 11.0
+ minLng, maxLng := 20.0, 21.0
+ opts := listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}
+ rows, err := st.ListMapReports(opts)
+ if err != nil {
+ t.Fatalf("ListMapReports() error = %v", err)
+ }
+ if len(rows) != 1 || rows[0].NodeID != "!00000001" {
+ t.Fatalf("ListMapReports() = %+v, want only !00000001", rows)
+ }
+ total, err := st.CountMapReports(opts)
+ if err != nil || total != 1 {
+ t.Fatalf("CountMapReports() = %d, %v, want 1, nil", total, err)
+ }
+}
+
+func TestListMapReportsFiltersAcrossAntimeridian(t *testing.T) {
+ st := openTestStore(t)
+ defer st.Close()
+
+ west := mapReportTestRecord("west")
+ west["from"] = "!00000001"
+ west["from_num"] = uint32(1)
+ west["latitude"] = 0.0
+ west["longitude"] = 175.0
+ east := mapReportTestRecord("east")
+ east["from"] = "!00000002"
+ east["from_num"] = uint32(2)
+ east["latitude"] = 0.0
+ east["longitude"] = -175.0
+ outside := mapReportTestRecord("outside")
+ outside["from"] = "!00000003"
+ outside["from_num"] = uint32(3)
+ outside["latitude"] = 0.0
+ outside["longitude"] = 0.0
+
+ for _, record := range []map[string]any{west, east, outside} {
+ if err := st.UpsertMapReport(record); err != nil {
+ t.Fatalf("UpsertMapReport() error = %v", err)
+ }
+ }
+
+ minLat, maxLat := -10.0, 10.0
+ minLng, maxLng := 170.0, -170.0
+ rows, err := st.ListMapReports(listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng})
+ if err != nil {
+ t.Fatalf("ListMapReports() error = %v", err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("ListMapReports() length = %d, want 2: %+v", len(rows), rows)
+ }
+ seen := map[string]bool{}
+ for _, row := range rows {
+ seen[row.NodeID] = true
+ }
+ if !seen["!00000001"] || !seen["!00000002"] || seen["!00000003"] {
+ t.Fatalf("seen nodes = %+v, want west/east only", seen)
+ }
+}
+
+func TestListMapReportViewportReturnsPointsBelowThreshold(t *testing.T) {
+ st := openTestStore(t)
+ defer st.Close()
+
+ for index := 0; index < 3; index++ {
+ record := mapReportTestRecord("point")
+ record["from"] = "!0000000" + string(rune('1'+index))
+ record["from_num"] = uint32(index + 1)
+ record["latitude"] = float64(index)
+ record["longitude"] = float64(index)
+ if err := st.UpsertMapReport(record); err != nil {
+ t.Fatalf("UpsertMapReport() error = %v", err)
+ }
+ }
+
+ minLat, maxLat := -1.0, 5.0
+ minLng, maxLng := -1.0, 5.0
+ result, err := st.ListMapReportViewport(mapReportViewportOptions{
+ ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
+ Zoom: 8,
+ Limit: 1000,
+ ClusterThreshold: 10,
+ TargetCells: 64,
+ })
+ if err != nil {
+ t.Fatalf("ListMapReportViewport() error = %v", err)
+ }
+ if result.Mode != "points" || result.Total != 3 || len(result.Points) != 3 || len(result.Clusters) != 0 {
+ t.Fatalf("viewport result = %+v, want 3 points", result)
+ }
+}
+
+func TestListMapReportViewportReturnsClustersAboveThreshold(t *testing.T) {
+ st := openTestStore(t)
+ defer st.Close()
+
+ for index := 0; index < 4; index++ {
+ record := mapReportTestRecord("cluster")
+ record["from"] = "!0000000" + string(rune('1'+index))
+ record["from_num"] = uint32(index + 1)
+ record["latitude"] = 10.0 + float64(index)*0.01
+ record["longitude"] = 20.0 + float64(index)*0.01
+ if err := st.UpsertMapReport(record); err != nil {
+ t.Fatalf("UpsertMapReport() error = %v", err)
+ }
+ }
+
+ minLat, maxLat := 9.0, 11.0
+ minLng, maxLng := 19.0, 21.0
+ result, err := st.ListMapReportViewport(mapReportViewportOptions{
+ ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
+ Zoom: 4,
+ Limit: 1000,
+ ClusterThreshold: 2,
+ TargetCells: 1,
+ })
+ if err != nil {
+ t.Fatalf("ListMapReportViewport() error = %v", err)
+ }
+ if result.Mode != "clusters" || result.Total != 4 || len(result.Clusters) != 1 || result.Clusters[0].Count != 4 {
+ t.Fatalf("viewport result = %+v, want one cluster with count 4", result)
+ }
+ if result.Clusters[0].Latitude < 10 || result.Clusters[0].Latitude > 10.1 || result.Clusters[0].Longitude < 20 || result.Clusters[0].Longitude > 20.1 {
+ t.Fatalf("cluster center = %v/%v, want center near inserted points", result.Clusters[0].Latitude, result.Clusters[0].Longitude)
+ }
+}
+
func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) {
st := openTestStore(t)
defer st.Close()
diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue
index f776ce4..3b1bcba 100644
--- a/meshmap_frontend/src/App.vue
+++ b/meshmap_frontend/src/App.vue
@@ -1,6 +1,6 @@
@@ -266,7 +320,7 @@ onBeforeUnmount(() => {
使用帮助
- 节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}
+ 节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}
管理
@@ -316,9 +370,12 @@ onBeforeUnmount(() => {
@delete-message="deleteMessage"
/>
{
return getJSON(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
}
-export function getMapReports(limit = 500, offset = 0): Promise> {
- return getJSON>(listPath('/api/map-reports', limit, offset))
+export function getMapReports(limit = 500, offset = 0, bounds?: MapBoundsQuery): Promise> {
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
+ if (bounds) {
+ params.set('min_lat', String(bounds.min_lat))
+ params.set('max_lat', String(bounds.max_lat))
+ params.set('min_lng', String(bounds.min_lng))
+ params.set('max_lng', String(bounds.max_lng))
+ }
+ return getJSON>(`/api/map-reports?${params.toString()}`)
}
export function getMapReportById(nodeId: string): Promise {
return getJSON(`/api/map-reports/${encodeURIComponent(nodeId)}`)
}
+export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit = 1000): Promise {
+ const params = new URLSearchParams({
+ min_lat: String(bounds.min_lat),
+ max_lat: String(bounds.max_lat),
+ min_lng: String(bounds.min_lng),
+ max_lng: String(bounds.max_lng),
+ zoom: String(zoom),
+ limit: String(limit),
+ })
+ return getJSON(`/api/map-reports/viewport?${params.toString()}`)
+}
+
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise> {
return getJSON>(listPath('/api/text-messages', limit, offset, nodeId))
}
diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue
index 280412f..d5866b1 100644
--- a/meshmap_frontend/src/components/MeshMap.vue
+++ b/meshmap_frontend/src/components/MeshMap.vue
@@ -2,18 +2,24 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
-import type { MapNode } from '../types'
+import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types'
-const props = defineProps<{
- nodes: MapNode[]
+const props = withDefaults(defineProps<{
+ items: MapRenderable[]
selectedNodeId: string | null
isAdmin: boolean
-}>()
+ autoFit?: boolean
+ loading?: boolean
+}>(), {
+ autoFit: true,
+ loading: false,
+})
const emit = defineEmits<{
'select-node': [nodeId: string]
'clear-node': []
'delete-node': [nodeId: string]
+ 'bounds-change': [payload: MapBoundsChangePayload]
}>()
const mapEl = ref(null)
@@ -48,8 +54,10 @@ onMounted(async () => {
closeNodeMenu()
emit('clear-node')
})
+ map.on('moveend', emitBoundsChange)
markerLayer = L.layerGroup().addTo(map)
renderMarkers(true)
+ emitBoundsChange()
})
onBeforeUnmount(() => {
@@ -61,7 +69,7 @@ onBeforeUnmount(() => {
})
watch(
- () => [props.nodes, props.selectedNodeId] as const,
+ () => [props.items, props.selectedNodeId] as const,
() => renderMarkers(false),
{ deep: true },
)
@@ -95,6 +103,22 @@ function handleKeydown(event: KeyboardEvent) {
}
}
+function emitBoundsChange() {
+ if (!map) {
+ return
+ }
+ const bounds = map.getBounds()
+ emit('bounds-change', {
+ bounds: {
+ min_lat: clamp(bounds.getSouth(), -90, 90),
+ max_lat: clamp(bounds.getNorth(), -90, 90),
+ min_lng: normalizeLongitude(bounds.getWest()),
+ max_lng: normalizeLongitude(bounds.getEast()),
+ },
+ zoom: map.getZoom(),
+ })
+}
+
function renderMarkers(forceFit: boolean) {
if (!map || !markerLayer) {
return
@@ -102,7 +126,14 @@ function renderMarkers(forceFit: boolean) {
markerLayer.clearLayers()
const bounds = L.latLngBounds([])
- for (const node of props.nodes) {
+ for (const item of props.items) {
+ if (item.type === 'cluster') {
+ const marker = buildClusterMarker(item)
+ marker.addTo(markerLayer)
+ bounds.extend([item.latitude, item.longitude])
+ continue
+ }
+ const node = item
const selected = node.node_id === props.selectedNodeId
const marker = L.marker([node.latitude, node.longitude], {
icon: L.divIcon({
@@ -127,12 +158,33 @@ function renderMarkers(forceFit: boolean) {
bounds.extend([node.latitude, node.longitude])
}
- if (props.nodes.length > 0 && (forceFit || !hasFitBounds)) {
+ if (props.autoFit && props.items.length > 0 && (forceFit || !hasFitBounds)) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 })
hasFitBounds = true
}
}
+function buildClusterMarker(cluster: MapClusterNode): L.Marker {
+ const size = clusterIconSize(cluster.count)
+ const marker = L.marker([cluster.latitude, cluster.longitude], {
+ icon: L.divIcon({
+ className: `cluster-marker ${clusterClass(cluster.count)}`,
+ html: `${formatCount(cluster.count)}`,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ }),
+ title: `${cluster.count} 个坐标`,
+ })
+ marker.bindPopup(buildClusterPopupHTML(cluster), { maxWidth: 260, className: 'node-detail-popup' })
+ marker.on('click', () => {
+ closeNodeMenu()
+ if (map) {
+ map.setView([cluster.latitude, cluster.longitude], Math.min(map.getZoom() + 2, map.getMaxZoom()))
+ }
+ })
+ return marker
+}
+
function buildNodePopupHTML(node: MapNode): string {
const info = node.nodeinfo
const report = node.map_report
@@ -157,6 +209,61 @@ function buildNodePopupHTML(node: MapNode): string {
`
}
+function buildClusterPopupHTML(cluster: MapClusterNode): string {
+ return `
+
+ `
+}
+
+function clusterIconSize(count: number): number {
+ if (count >= 1000) {
+ return 58
+ }
+ if (count >= 100) {
+ return 50
+ }
+ if (count >= 10) {
+ return 42
+ }
+ return 34
+}
+
+function clusterClass(count: number): string {
+ if (count >= 1000) {
+ return 'cluster-large'
+ }
+ if (count >= 100) {
+ return 'cluster-medium'
+ }
+ return 'cluster-small'
+}
+
+function formatCount(count: number): string {
+ return count >= 1000 ? `${Math.round(count / 100) / 10}k` : String(count)
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value))
+}
+
+function normalizeLongitude(value: number): number {
+ let normalized = value
+ while (normalized < -180) {
+ normalized += 360
+ }
+ while (normalized > 180) {
+ normalized -= 360
+ }
+ return normalized
+}
+
function nodeColor(nodeId: string): string {
let hash = 0
for (let index = 0; index < nodeId.length; index += 1) {
@@ -193,7 +300,8 @@ function escapeHTML(value: string): string {
- 暂无可显示坐标的节点
+ 正在加载当前区域坐标...
+ 暂无可显示坐标的节点