地图缩放融合
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
@@ -12,7 +12,7 @@ import HelpPage from './components/HelpPage.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.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 adminPath = currentPath
|
||||
@@ -30,7 +30,8 @@ const error = ref('')
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const health = ref<HealthStatus | null>(null)
|
||||
const nodeInfoSource = ref<NodeInfo[]>([])
|
||||
const mapReportSource = ref<MapReport[]>([])
|
||||
const mapViewportItems = ref<MapViewportItem[]>([])
|
||||
const mapViewportMode = ref<'points' | 'clusters'>('points')
|
||||
const pagedNodeInfo = ref<NodeInfo[]>([])
|
||||
const nodePage = ref(1)
|
||||
const nodePageSize = 25
|
||||
@@ -41,7 +42,13 @@ const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
const chatInitialized = ref(false)
|
||||
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 mapBoundsTimer: number | undefined
|
||||
let mapReportRequestSeq = 0
|
||||
|
||||
const nodesById = computed<NodeInfoById>(() => {
|
||||
const map = new Map<string, NodeInfo>()
|
||||
@@ -54,21 +61,31 @@ const nodesById = computed<NodeInfoById>(() => {
|
||||
return Object.fromEntries(map)
|
||||
})
|
||||
|
||||
const mapNodes = computed<MapNode[]>(() => {
|
||||
return mapReportSource.value
|
||||
.filter((report) => report.latitude != null && report.longitude != null)
|
||||
.map((report) => {
|
||||
const nodeinfo = nodesById.value[report.node_id] ?? null
|
||||
const mapItems = computed<MapRenderable[]>(() => {
|
||||
return mapViewportItems.value
|
||||
.filter((item) => item.type === 'cluster' || (item.latitude != null && item.longitude != null))
|
||||
.map((item) => {
|
||||
if (item.type === 'cluster') {
|
||||
return {
|
||||
type: 'cluster',
|
||||
cluster_id: item.cluster_id,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
count: item.count,
|
||||
}
|
||||
}
|
||||
const nodeinfo = nodesById.value[item.node_id] ?? null
|
||||
return {
|
||||
node_id: report.node_id,
|
||||
label: report.short_name || report.long_name || nodeinfo?.short_name || nodeinfo?.long_name || report.node_id,
|
||||
latitude: report.latitude as number,
|
||||
longitude: report.longitude as number,
|
||||
altitude: report.altitude,
|
||||
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',
|
||||
updated_at: report.updated_at,
|
||||
updated_at: item.updated_at,
|
||||
nodeinfo,
|
||||
map_report: report,
|
||||
map_report: item,
|
||||
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) {
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
try {
|
||||
const [healthData, nodeInfoData, mapReportData, positionData] = await Promise.all([
|
||||
const [healthData, nodeInfoData, positionData] = await Promise.all([
|
||||
getHealth(),
|
||||
getNodeInfo(500, 0),
|
||||
getMapReports(500, 0),
|
||||
getPositions(500),
|
||||
])
|
||||
health.value = healthData
|
||||
nodeInfoSource.value = nodeInfoData.items
|
||||
mapReportSource.value = mapReportData.items
|
||||
positions.value = positionData.items
|
||||
await Promise.all([
|
||||
currentMapBounds.value ? loadMapReportsForBounds(currentMapBounds.value, currentMapZoom.value, false) : Promise.resolve(),
|
||||
loadNodePage(nodePage.value, showLoading),
|
||||
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
|
||||
])
|
||||
@@ -205,7 +256,7 @@ async function deleteNodeById(nodeId: string) {
|
||||
await deleteNode(nodeId)
|
||||
nodeInfoSource.value = nodeInfoSource.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) {
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
@@ -232,6 +283,9 @@ onBeforeUnmount(() => {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
if (mapBoundsTimer !== undefined) {
|
||||
window.clearTimeout(mapBoundsTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -266,7 +320,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
</template>
|
||||
@@ -316,9 +370,12 @@ onBeforeUnmount(() => {
|
||||
@delete-message="deleteMessage"
|
||||
/>
|
||||
<MeshMap
|
||||
:nodes="mapNodes"
|
||||
:items="mapItems"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:is-admin="!!adminUser"
|
||||
:auto-fit="false"
|
||||
:loading="mapReportsLoading"
|
||||
@bounds-change="handleMapBoundsChange"
|
||||
@select-node="selectedNodeId = $event"
|
||||
@clear-node="selectedNodeId = null"
|
||||
@delete-node="deleteNodeById"
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
IPBlockingRule,
|
||||
IPBlockingRulePayload,
|
||||
ListResponse,
|
||||
MapBoundsQuery,
|
||||
MapReport,
|
||||
MapViewportResponse,
|
||||
NodeBlockingRule,
|
||||
NodeBlockingRulePayload,
|
||||
NodeInfo,
|
||||
@@ -82,14 +84,33 @@ export function getNodeInfoById(nodeId: string): Promise<NodeInfo> {
|
||||
return getJSON<NodeInfo>(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
|
||||
}
|
||||
|
||||
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
|
||||
return getJSON<ListResponse<MapReport>>(listPath('/api/map-reports', limit, offset))
|
||||
export function getMapReports(limit = 500, offset = 0, bounds?: MapBoundsQuery): Promise<ListResponse<MapReport>> {
|
||||
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> {
|
||||
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>> {
|
||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement | null>(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: `<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 {
|
||||
const info = node.nodeinfo
|
||||
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 {
|
||||
let hash = 0
|
||||
for (let index = 0; index < nodeId.length; index += 1) {
|
||||
@@ -193,7 +300,8 @@ function escapeHTML(value: string): string {
|
||||
<template>
|
||||
<section class="map-panel panel">
|
||||
<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
|
||||
v-if="menuNodeId"
|
||||
class="context-menu"
|
||||
|
||||
@@ -335,6 +335,36 @@ h3 {
|
||||
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 {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,40 @@ export interface MapReport {
|
||||
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 {
|
||||
id: number
|
||||
from_id: string
|
||||
@@ -77,6 +111,7 @@ export interface TelemetryRecord {
|
||||
}
|
||||
|
||||
export interface MapNode {
|
||||
type: 'node'
|
||||
node_id: string
|
||||
label: string
|
||||
latitude: number
|
||||
@@ -89,6 +124,16 @@ export interface MapNode {
|
||||
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 interface AdminUser {
|
||||
|
||||
Reference in New Issue
Block a user