From 5b6a1a60a1e7fb8611e564162ac130ae83222a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Thu, 4 Jun 2026 14:23:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E7=BC=A9=E6=94=BE=E8=9E=8D?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db_test.go | 153 ++++++++++++++++++++ meshmap_frontend/src/App.vue | 99 ++++++++++--- meshmap_frontend/src/api.ts | 25 +++- meshmap_frontend/src/components/MeshMap.vue | 124 +++++++++++++++- meshmap_frontend/src/style.css | 30 ++++ meshmap_frontend/src/types.ts | 45 ++++++ store_query.go | 150 ++++++++++++++++++- web.go | 136 ++++++++++++++++- 8 files changed, 728 insertions(+), 34 deletions(-) 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(() => { @@ -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 ` +
+ 聚合坐标 +
+
数量
${cluster.count}
+
经度
${cluster.longitude.toFixed(5)}
+
纬度
${cluster.latitude.toFixed(5)}
+
+
+ ` +} + +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 {