地图缩放融合
This commit is contained in:
+153
@@ -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) {
|
func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
@@ -12,7 +12,7 @@ import HelpPage from './components/HelpPage.vue'
|
|||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
||||||
|
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const adminPath = currentPath
|
const adminPath = currentPath
|
||||||
@@ -30,7 +30,8 @@ const error = ref('')
|
|||||||
const selectedNodeId = ref<string | null>(null)
|
const selectedNodeId = ref<string | null>(null)
|
||||||
const health = ref<HealthStatus | null>(null)
|
const health = ref<HealthStatus | null>(null)
|
||||||
const nodeInfoSource = ref<NodeInfo[]>([])
|
const nodeInfoSource = ref<NodeInfo[]>([])
|
||||||
const mapReportSource = ref<MapReport[]>([])
|
const mapViewportItems = ref<MapViewportItem[]>([])
|
||||||
|
const mapViewportMode = ref<'points' | 'clusters'>('points')
|
||||||
const pagedNodeInfo = ref<NodeInfo[]>([])
|
const pagedNodeInfo = ref<NodeInfo[]>([])
|
||||||
const nodePage = ref(1)
|
const nodePage = ref(1)
|
||||||
const nodePageSize = 25
|
const nodePageSize = 25
|
||||||
@@ -41,7 +42,13 @@ const chatLoadingOlder = ref(false)
|
|||||||
const chatHasMore = ref(true)
|
const chatHasMore = ref(true)
|
||||||
const chatInitialized = ref(false)
|
const chatInitialized = ref(false)
|
||||||
const positions = ref<PositionRecord[]>([])
|
const positions = ref<PositionRecord[]>([])
|
||||||
|
const currentMapBounds = ref<MapBoundsQuery | null>(null)
|
||||||
|
const currentMapZoom = ref(2)
|
||||||
|
const mapReportsLoading = ref(false)
|
||||||
|
const mapReportTotal = ref(0)
|
||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
|
let mapBoundsTimer: number | undefined
|
||||||
|
let mapReportRequestSeq = 0
|
||||||
|
|
||||||
const nodesById = computed<NodeInfoById>(() => {
|
const nodesById = computed<NodeInfoById>(() => {
|
||||||
const map = new Map<string, NodeInfo>()
|
const map = new Map<string, NodeInfo>()
|
||||||
@@ -54,21 +61,31 @@ const nodesById = computed<NodeInfoById>(() => {
|
|||||||
return Object.fromEntries(map)
|
return Object.fromEntries(map)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapNodes = computed<MapNode[]>(() => {
|
const mapItems = computed<MapRenderable[]>(() => {
|
||||||
return mapReportSource.value
|
return mapViewportItems.value
|
||||||
.filter((report) => report.latitude != null && report.longitude != null)
|
.filter((item) => item.type === 'cluster' || (item.latitude != null && item.longitude != null))
|
||||||
.map((report) => {
|
.map((item) => {
|
||||||
const nodeinfo = nodesById.value[report.node_id] ?? null
|
if (item.type === 'cluster') {
|
||||||
return {
|
return {
|
||||||
node_id: report.node_id,
|
type: 'cluster',
|
||||||
label: report.short_name || report.long_name || nodeinfo?.short_name || nodeinfo?.long_name || report.node_id,
|
cluster_id: item.cluster_id,
|
||||||
latitude: report.latitude as number,
|
latitude: item.latitude,
|
||||||
longitude: report.longitude as number,
|
longitude: item.longitude,
|
||||||
altitude: report.altitude,
|
count: item.count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodeinfo = nodesById.value[item.node_id] ?? null
|
||||||
|
return {
|
||||||
|
type: 'node',
|
||||||
|
node_id: item.node_id,
|
||||||
|
label: item.short_name || item.long_name || nodeinfo?.short_name || nodeinfo?.long_name || item.node_id,
|
||||||
|
latitude: item.latitude as number,
|
||||||
|
longitude: item.longitude as number,
|
||||||
|
altitude: item.altitude,
|
||||||
source: 'map_report',
|
source: 'map_report',
|
||||||
updated_at: report.updated_at,
|
updated_at: item.updated_at,
|
||||||
nodeinfo,
|
nodeinfo,
|
||||||
map_report: report,
|
map_report: item,
|
||||||
latest_position: null,
|
latest_position: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -142,23 +159,57 @@ async function loadNodePage(page: number, showLoading = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMapReportsForBounds(bounds: MapBoundsQuery, zoom: number, showLoading = true) {
|
||||||
|
const requestSeq = ++mapReportRequestSeq
|
||||||
|
if (showLoading) {
|
||||||
|
mapReportsLoading.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await getMapReportViewport(bounds, zoom)
|
||||||
|
if (requestSeq !== mapReportRequestSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapViewportItems.value = response.items
|
||||||
|
mapViewportMode.value = response.mode
|
||||||
|
mapReportTotal.value = response.total
|
||||||
|
} catch (err) {
|
||||||
|
if (requestSeq === mapReportRequestSeq) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === mapReportRequestSeq && showLoading) {
|
||||||
|
mapReportsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapBoundsChange(payload: MapBoundsChangePayload) {
|
||||||
|
currentMapBounds.value = payload.bounds
|
||||||
|
currentMapZoom.value = payload.zoom
|
||||||
|
if (mapBoundsTimer !== undefined) {
|
||||||
|
window.clearTimeout(mapBoundsTimer)
|
||||||
|
}
|
||||||
|
mapBoundsTimer = window.setTimeout(() => {
|
||||||
|
loadMapReportsForBounds(payload.bounds, payload.zoom)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(showLoading = true) {
|
async function refresh(showLoading = true) {
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const [healthData, nodeInfoData, mapReportData, positionData] = await Promise.all([
|
const [healthData, nodeInfoData, positionData] = await Promise.all([
|
||||||
getHealth(),
|
getHealth(),
|
||||||
getNodeInfo(500, 0),
|
getNodeInfo(500, 0),
|
||||||
getMapReports(500, 0),
|
|
||||||
getPositions(500),
|
getPositions(500),
|
||||||
])
|
])
|
||||||
health.value = healthData
|
health.value = healthData
|
||||||
nodeInfoSource.value = nodeInfoData.items
|
nodeInfoSource.value = nodeInfoData.items
|
||||||
mapReportSource.value = mapReportData.items
|
|
||||||
positions.value = positionData.items
|
positions.value = positionData.items
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
currentMapBounds.value ? loadMapReportsForBounds(currentMapBounds.value, currentMapZoom.value, false) : Promise.resolve(),
|
||||||
loadNodePage(nodePage.value, showLoading),
|
loadNodePage(nodePage.value, showLoading),
|
||||||
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
|
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
|
||||||
])
|
])
|
||||||
@@ -205,7 +256,7 @@ async function deleteNodeById(nodeId: string) {
|
|||||||
await deleteNode(nodeId)
|
await deleteNode(nodeId)
|
||||||
nodeInfoSource.value = nodeInfoSource.value.filter((node) => node.node_id !== nodeId)
|
nodeInfoSource.value = nodeInfoSource.value.filter((node) => node.node_id !== nodeId)
|
||||||
pagedNodeInfo.value = pagedNodeInfo.value.filter((node) => node.node_id !== nodeId)
|
pagedNodeInfo.value = pagedNodeInfo.value.filter((node) => node.node_id !== nodeId)
|
||||||
mapReportSource.value = mapReportSource.value.filter((report) => report.node_id !== nodeId)
|
mapViewportItems.value = mapViewportItems.value.filter((item) => item.type === 'cluster' || item.node_id !== nodeId)
|
||||||
if (selectedNodeId.value === nodeId) {
|
if (selectedNodeId.value === nodeId) {
|
||||||
selectedNodeId.value = null
|
selectedNodeId.value = null
|
||||||
}
|
}
|
||||||
@@ -232,6 +283,9 @@ onBeforeUnmount(() => {
|
|||||||
if (refreshTimer !== undefined) {
|
if (refreshTimer !== undefined) {
|
||||||
window.clearInterval(refreshTimer)
|
window.clearInterval(refreshTimer)
|
||||||
}
|
}
|
||||||
|
if (mapBoundsTimer !== undefined) {
|
||||||
|
window.clearTimeout(mapBoundsTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -266,7 +320,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a class="topbar-link" href="/help">使用帮助</a>
|
<a class="topbar-link" href="/help">使用帮助</a>
|
||||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}</span>
|
||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -316,9 +370,12 @@ onBeforeUnmount(() => {
|
|||||||
@delete-message="deleteMessage"
|
@delete-message="deleteMessage"
|
||||||
/>
|
/>
|
||||||
<MeshMap
|
<MeshMap
|
||||||
:nodes="mapNodes"
|
:items="mapItems"
|
||||||
:selected-node-id="selectedNodeId"
|
:selected-node-id="selectedNodeId"
|
||||||
:is-admin="!!adminUser"
|
:is-admin="!!adminUser"
|
||||||
|
:auto-fit="false"
|
||||||
|
:loading="mapReportsLoading"
|
||||||
|
@bounds-change="handleMapBoundsChange"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@clear-node="selectedNodeId = null"
|
@clear-node="selectedNodeId = null"
|
||||||
@delete-node="deleteNodeById"
|
@delete-node="deleteNodeById"
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import type {
|
|||||||
IPBlockingRule,
|
IPBlockingRule,
|
||||||
IPBlockingRulePayload,
|
IPBlockingRulePayload,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
|
MapBoundsQuery,
|
||||||
MapReport,
|
MapReport,
|
||||||
|
MapViewportResponse,
|
||||||
NodeBlockingRule,
|
NodeBlockingRule,
|
||||||
NodeBlockingRulePayload,
|
NodeBlockingRulePayload,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
@@ -82,14 +84,33 @@ export function getNodeInfoById(nodeId: string): Promise<NodeInfo> {
|
|||||||
return getJSON<NodeInfo>(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
|
return getJSON<NodeInfo>(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
|
export function getMapReports(limit = 500, offset = 0, bounds?: MapBoundsQuery): Promise<ListResponse<MapReport>> {
|
||||||
return getJSON<ListResponse<MapReport>>(listPath('/api/map-reports', limit, offset))
|
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<ListResponse<MapReport>>(`/api/map-reports?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMapReportById(nodeId: string): Promise<MapReport> {
|
export function getMapReportById(nodeId: string): Promise<MapReport> {
|
||||||
return getJSON<MapReport>(`/api/map-reports/${encodeURIComponent(nodeId)}`)
|
return getJSON<MapReport>(`/api/map-reports/${encodeURIComponent(nodeId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit = 1000): Promise<MapViewportResponse> {
|
||||||
|
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<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
||||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,24 @@
|
|||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import type { MapNode } from '../types'
|
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
nodes: MapNode[]
|
items: MapRenderable[]
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}>()
|
autoFit?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}>(), {
|
||||||
|
autoFit: true,
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select-node': [nodeId: string]
|
'select-node': [nodeId: string]
|
||||||
'clear-node': []
|
'clear-node': []
|
||||||
'delete-node': [nodeId: string]
|
'delete-node': [nodeId: string]
|
||||||
|
'bounds-change': [payload: MapBoundsChangePayload]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mapEl = ref<HTMLElement | null>(null)
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
@@ -48,8 +54,10 @@ onMounted(async () => {
|
|||||||
closeNodeMenu()
|
closeNodeMenu()
|
||||||
emit('clear-node')
|
emit('clear-node')
|
||||||
})
|
})
|
||||||
|
map.on('moveend', emitBoundsChange)
|
||||||
markerLayer = L.layerGroup().addTo(map)
|
markerLayer = L.layerGroup().addTo(map)
|
||||||
renderMarkers(true)
|
renderMarkers(true)
|
||||||
|
emitBoundsChange()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -61,7 +69,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.nodes, props.selectedNodeId] as const,
|
() => [props.items, props.selectedNodeId] as const,
|
||||||
() => renderMarkers(false),
|
() => renderMarkers(false),
|
||||||
{ deep: true },
|
{ 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) {
|
function renderMarkers(forceFit: boolean) {
|
||||||
if (!map || !markerLayer) {
|
if (!map || !markerLayer) {
|
||||||
return
|
return
|
||||||
@@ -102,7 +126,14 @@ function renderMarkers(forceFit: boolean) {
|
|||||||
markerLayer.clearLayers()
|
markerLayer.clearLayers()
|
||||||
const bounds = L.latLngBounds([])
|
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 selected = node.node_id === props.selectedNodeId
|
||||||
const marker = L.marker([node.latitude, node.longitude], {
|
const marker = L.marker([node.latitude, node.longitude], {
|
||||||
icon: L.divIcon({
|
icon: L.divIcon({
|
||||||
@@ -127,12 +158,33 @@ function renderMarkers(forceFit: boolean) {
|
|||||||
bounds.extend([node.latitude, node.longitude])
|
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 })
|
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 })
|
||||||
hasFitBounds = true
|
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: `<span>${formatCount(cluster.count)}</span>`,
|
||||||
|
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 {
|
function buildNodePopupHTML(node: MapNode): string {
|
||||||
const info = node.nodeinfo
|
const info = node.nodeinfo
|
||||||
const report = node.map_report
|
const report = node.map_report
|
||||||
@@ -157,6 +209,61 @@ function buildNodePopupHTML(node: MapNode): string {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildClusterPopupHTML(cluster: MapClusterNode): string {
|
||||||
|
return `
|
||||||
|
<div class="node-popup">
|
||||||
|
<strong>聚合坐标</strong>
|
||||||
|
<dl>
|
||||||
|
<div><dt>数量</dt><dd>${cluster.count}</dd></div>
|
||||||
|
<div><dt>经度</dt><dd>${cluster.longitude.toFixed(5)}</dd></div>
|
||||||
|
<div><dt>纬度</dt><dd>${cluster.latitude.toFixed(5)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function nodeColor(nodeId: string): string {
|
||||||
let hash = 0
|
let hash = 0
|
||||||
for (let index = 0; index < nodeId.length; index += 1) {
|
for (let index = 0; index < nodeId.length; index += 1) {
|
||||||
@@ -193,7 +300,8 @@ function escapeHTML(value: string): string {
|
|||||||
<template>
|
<template>
|
||||||
<section class="map-panel panel">
|
<section class="map-panel panel">
|
||||||
<div ref="mapEl" class="map-container"></div>
|
<div ref="mapEl" class="map-container"></div>
|
||||||
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
|
<div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
|
||||||
|
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div>
|
||||||
<div
|
<div
|
||||||
v-if="menuNodeId"
|
v-if="menuNodeId"
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
|
|||||||
@@ -335,6 +335,36 @@ h3 {
|
|||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cluster-marker {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: white;
|
||||||
|
background: #2563eb;
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-marker span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-medium {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-large {
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
.node-detail-panel {
|
.node-detail-panel {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,40 @@ export interface MapReport {
|
|||||||
content_json: string
|
content_json: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapBoundsQuery {
|
||||||
|
min_lat: number
|
||||||
|
max_lat: number
|
||||||
|
min_lng: number
|
||||||
|
max_lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapBoundsChangePayload {
|
||||||
|
bounds: MapBoundsQuery
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewportPoint extends MapReport {
|
||||||
|
type: 'point'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewportCluster {
|
||||||
|
type: 'cluster'
|
||||||
|
cluster_id: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapViewportItem = MapViewportPoint | MapViewportCluster
|
||||||
|
|
||||||
|
export interface MapViewportResponse {
|
||||||
|
mode: 'points' | 'clusters'
|
||||||
|
items: MapViewportItem[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TextMessage {
|
export interface TextMessage {
|
||||||
id: number
|
id: number
|
||||||
from_id: string
|
from_id: string
|
||||||
@@ -77,6 +111,7 @@ export interface TelemetryRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MapNode {
|
export interface MapNode {
|
||||||
|
type: 'node'
|
||||||
node_id: string
|
node_id: string
|
||||||
label: string
|
label: string
|
||||||
latitude: number
|
latitude: number
|
||||||
@@ -89,6 +124,16 @@ export interface MapNode {
|
|||||||
latest_position: PositionRecord | null
|
latest_position: PositionRecord | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapClusterNode {
|
||||||
|
type: 'cluster'
|
||||||
|
cluster_id: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapRenderable = MapNode | MapClusterNode
|
||||||
|
|
||||||
export type NodeInfoById = Record<string, NodeInfo>
|
export type NodeInfoById = Record<string, NodeInfo>
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
|
|||||||
+148
-2
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -12,6 +14,34 @@ type listOptions struct {
|
|||||||
NodeID string
|
NodeID string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
Until *time.Time
|
Until *time.Time
|
||||||
|
MinLat *float64
|
||||||
|
MaxLat *float64
|
||||||
|
MinLng *float64
|
||||||
|
MaxLng *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapReportViewportOptions struct {
|
||||||
|
ListOptions listOptions
|
||||||
|
Zoom int
|
||||||
|
Limit int
|
||||||
|
ClusterThreshold int
|
||||||
|
TargetCells int
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapReportViewportResult struct {
|
||||||
|
Mode string
|
||||||
|
Total int64
|
||||||
|
Points []mapReportRecord
|
||||||
|
Clusters []mapReportClusterRecord
|
||||||
|
Limit int
|
||||||
|
Zoom int
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapReportClusterRecord struct {
|
||||||
|
ClusterID string
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) Ping() error {
|
func (s *store) Ping() error {
|
||||||
@@ -62,7 +92,7 @@ func (s *store) GetNodeInfo(nodeID string) (*nodeInfoRecord, error) {
|
|||||||
func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
|
func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = normalizeListOptions(opts)
|
||||||
var rows []mapReportRecord
|
var rows []mapReportRecord
|
||||||
q := applyNodeFilters(s.db.Model(&mapReportRecord{}), opts).
|
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
Offset(opts.Offset)
|
Offset(opts.Offset)
|
||||||
@@ -71,7 +101,7 @@ func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
|
|||||||
|
|
||||||
func (s *store) CountMapReports(opts listOptions) (int64, error) {
|
func (s *store) CountMapReports(opts listOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applyNodeFilters(s.db.Model(&mapReportRecord{}), opts)
|
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +113,107 @@ func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) {
|
|||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReportViewportResult, error) {
|
||||||
|
opts = normalizeMapReportViewportOptions(opts)
|
||||||
|
total, err := s.CountMapReports(opts.ListOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := &mapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom}
|
||||||
|
if total <= int64(opts.ClusterThreshold) {
|
||||||
|
var points []mapReportRecord
|
||||||
|
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Limit(opts.Limit)
|
||||||
|
if err := q.Find(&points).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Mode = "points"
|
||||||
|
result.Points = points
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
clusters, err := s.ListMapReportClusters(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Mode = "clusters"
|
||||||
|
result.Clusters = clusters
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapReportClusterRecord, error) {
|
||||||
|
opts = normalizeMapReportViewportOptions(opts)
|
||||||
|
cellSize := mapReportClusterCellSize(opts.ListOptions, opts.TargetCells)
|
||||||
|
var rows []struct {
|
||||||
|
LatBucket int64
|
||||||
|
LngBucket int64
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
||||||
|
Select("CAST((latitude + 90.0) / ? AS INTEGER) AS lat_bucket, CAST((longitude + 180.0) / ? AS INTEGER) AS lng_bucket, AVG(latitude) AS latitude, AVG(longitude) AS longitude, COUNT(*) AS count", cellSize, cellSize).
|
||||||
|
Group("lat_bucket, lng_bucket").
|
||||||
|
Order("count DESC").
|
||||||
|
Limit(opts.Limit)
|
||||||
|
if err := q.Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clusters := make([]mapReportClusterRecord, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
clusters = append(clusters, mapReportClusterRecord{
|
||||||
|
ClusterID: fmt.Sprintf("%d:%d", row.LatBucket, row.LngBucket),
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
Count: row.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return clusters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportViewportOptions {
|
||||||
|
if opts.Limit <= 0 {
|
||||||
|
opts.Limit = 1000
|
||||||
|
}
|
||||||
|
if opts.Limit > 2000 {
|
||||||
|
opts.Limit = 2000
|
||||||
|
}
|
||||||
|
if opts.ClusterThreshold <= 0 {
|
||||||
|
opts.ClusterThreshold = 500
|
||||||
|
}
|
||||||
|
if opts.ClusterThreshold > 5000 {
|
||||||
|
opts.ClusterThreshold = 5000
|
||||||
|
}
|
||||||
|
if opts.TargetCells <= 0 {
|
||||||
|
opts.TargetCells = 64
|
||||||
|
}
|
||||||
|
if opts.TargetCells > 256 {
|
||||||
|
opts.TargetCells = 256
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapReportClusterCellSize(opts listOptions, targetCells int) float64 {
|
||||||
|
latSpan := 180.0
|
||||||
|
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||||
|
latSpan = *opts.MaxLat - *opts.MinLat
|
||||||
|
}
|
||||||
|
lngSpan := 360.0
|
||||||
|
if opts.MinLng != nil && opts.MaxLng != nil {
|
||||||
|
if *opts.MinLng <= *opts.MaxLng {
|
||||||
|
lngSpan = *opts.MaxLng - *opts.MinLng
|
||||||
|
} else {
|
||||||
|
lngSpan = 180 - *opts.MinLng + *opts.MaxLng + 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span := math.Max(latSpan, lngSpan)
|
||||||
|
cellSize := span / float64(targetCells)
|
||||||
|
if cellSize < 0.0001 {
|
||||||
|
cellSize = 0.0001
|
||||||
|
}
|
||||||
|
return cellSize
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) DeleteNode(nodeID string) error {
|
func (s *store) DeleteNode(nodeID string) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{})
|
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{})
|
||||||
@@ -113,6 +244,21 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||||
|
q = applyNodeFilters(q, opts)
|
||||||
|
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||||
|
q = q.Where("latitude IS NOT NULL AND latitude >= ? AND latitude <= ?", *opts.MinLat, *opts.MaxLat)
|
||||||
|
}
|
||||||
|
if opts.MinLng != nil && opts.MaxLng != nil {
|
||||||
|
if *opts.MinLng <= *opts.MaxLng {
|
||||||
|
q = q.Where("longitude IS NOT NULL AND longitude >= ? AND longitude <= ?", *opts.MinLng, *opts.MaxLng)
|
||||||
|
} else {
|
||||||
|
q = q.Where("longitude IS NOT NULL AND (longitude >= ? OR longitude <= ?)", *opts.MinLng, *opts.MaxLng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) {
|
func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) {
|
||||||
var rows []textMessageRecord
|
var rows []textMessageRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
|
|||||||
@@ -295,8 +295,30 @@ func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registerMapReportRoutes(r gin.IRouter, store *store) {
|
func registerMapReportRoutes(r gin.IRouter, store *store) {
|
||||||
|
r.GET("/map-reports/viewport", func(c *gin.Context) {
|
||||||
|
opts, ok := parseMapReportViewportOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := store.ListMapReportViewport(opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(result.Points)+len(result.Clusters))
|
||||||
|
if result.Mode == "points" {
|
||||||
|
for _, row := range result.Points {
|
||||||
|
items = append(items, mapReportViewportPointDTO(row))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, row := range result.Clusters {
|
||||||
|
items = append(items, mapReportClusterDTO(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"mode": result.Mode, "items": items, "total": result.Total, "limit": result.Limit, "zoom": result.Zoom})
|
||||||
|
})
|
||||||
r.GET("/map-reports", func(c *gin.Context) {
|
r.GET("/map-reports", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseMapReportListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -385,6 +407,108 @@ func parseListOptions(c *gin.Context) (listOptions, bool) {
|
|||||||
return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, Since: since, Until: until}), true
|
return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, Since: since, Until: until}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseMapReportListOptions(c *gin.Context) (listOptions, bool) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
minLat, hasMinLat, ok := parseOptionalFloatQuery(c, "min_lat")
|
||||||
|
if !ok {
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
maxLat, hasMaxLat, ok := parseOptionalFloatQuery(c, "max_lat")
|
||||||
|
if !ok {
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
minLng, hasMinLng, ok := parseOptionalFloatQuery(c, "min_lng")
|
||||||
|
if !ok {
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
maxLng, hasMaxLng, ok := parseOptionalFloatQuery(c, "max_lng")
|
||||||
|
if !ok {
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
boundsCount := 0
|
||||||
|
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
||||||
|
if present {
|
||||||
|
boundsCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if boundsCount == 0 {
|
||||||
|
return opts, true
|
||||||
|
}
|
||||||
|
if boundsCount != 4 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
if minLat > maxLat {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
||||||
|
return listOptions{}, false
|
||||||
|
}
|
||||||
|
opts.MinLat = &minLat
|
||||||
|
opts.MaxLat = &maxLat
|
||||||
|
opts.MinLng = &minLng
|
||||||
|
opts.MaxLng = &maxLng
|
||||||
|
return opts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) {
|
||||||
|
opts, ok := parseMapReportListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
zoom, ok := parseIntQuery(c, "zoom", 0)
|
||||||
|
if !ok {
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if zoom < 0 || zoom > 24 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
limit, ok := parseIntQuery(c, "limit", 1000)
|
||||||
|
if !ok {
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
clusterThreshold, ok := parseIntQuery(c, "cluster_threshold", 500)
|
||||||
|
if !ok {
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
targetCells, ok := parseIntQuery(c, "target_cells", 64)
|
||||||
|
if !ok {
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
||||||
|
return mapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
return normalizeMapReportViewportOptions(mapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
||||||
|
value := c.Query(name)
|
||||||
|
if value == "" {
|
||||||
|
return 0, false, true
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||||
|
return 0, true, false
|
||||||
|
}
|
||||||
|
return parsed, true, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||||
value := c.Query(name)
|
value := c.Query(name)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -430,6 +554,16 @@ func mapReportDTO(row mapReportRecord) gin.H {
|
|||||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapReportViewportPointDTO(row mapReportRecord) gin.H {
|
||||||
|
item := mapReportDTO(row)
|
||||||
|
item["type"] = "point"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
|
||||||
|
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
||||||
|
}
|
||||||
|
|
||||||
func textMessageDTO(row textMessageRecord) gin.H {
|
func textMessageDTO(row textMessageRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user