Browse Source

feat: Monitor 地图改为 Mapbox GeoJSON 图层

设备点、绑定虚线、样式切换与 fit/fly 全量替换 maptalks。
main
xiaosi 3 weeks ago
parent
commit
f36d1a882d
  1. 307
      src/views/MonitorView/MonitorView.vue

307
src/views/MonitorView/MonitorView.vue

@ -362,18 +362,21 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as maptalks from 'maptalks'
import mapboxgl from 'mapbox-gl'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import { useUiStore } from '@/stores/modules/uiStore'
import { useUserStore } from '@/stores/modules/userStore'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
import mapConfig, { createTiandituBaseLayer } from '@/config/map'
import mapConfig, { MAP_STYLES } from '@/config/map'
import LivePlayer from '@/components/LivePlayer.vue'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
const LAYER_ID = 'monitor-devices'
const BINDING_LAYER_ID = 'monitor-binding'
const DEVICE_SOURCE = 'monitor-devices'
const DEVICE_CIRCLE = 'monitor-devices-circle'
const DEVICE_LABEL = 'monitor-devices-label'
const BINDING_SOURCE = 'monitor-binding'
const BINDING_LINE = 'monitor-binding-line'
const STATUS_COLORS = {
online: '#21a06a',
@ -401,13 +404,10 @@ const dockStatusOpen = ref(false)
const selectedDockIds = reactive(new Set())
let mapInstance = null
let deviceLayer = null
let bindingLayer = null
let bindingLine = null
let layersReady = false
const mapMode = ref('satellite')
const mapScaleText = ref('2 km')
let suppressMapClick = false
const markerById = new Map()
const dockExtraCommands = [
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' },
@ -440,75 +440,132 @@ const mapMarkers = computed(() =>
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock)
)
function markerSymbol(asset, selected = false) {
const fill = STATUS_COLORS[asset.statusClass] || STATUS_COLORS.offline
const radius = asset.type === 'drone' ? 8 : 10
return [
{
markerType: 'ellipse',
markerFill: fill,
markerLineColor: '#fff',
markerLineWidth: selected ? 3 : 2,
markerWidth: selected ? radius * 2.4 : radius * 2,
markerHeight: selected ? radius * 2.4 : radius * 2,
markerDx: 0,
markerDy: 0,
markerOpacity: 1,
},
{
textName: asset.name || '',
textFill: '#fff',
textHaloFill: 'rgba(24,35,45,.86)',
textHaloRadius: 3,
textSize: 11,
textDy: selected ? 18 : 16,
textWeight: 500,
},
]
function statusColor(statusClass) {
return STATUS_COLORS[statusClass] || STATUS_COLORS.offline
}
function syncMarkers() {
if (!deviceLayer) return
const nextIds = new Set()
for (const asset of mapMarkers.value) {
function devicesFeatureCollection() {
return {
type: 'FeatureCollection',
features: mapMarkers.value
.map((asset) => {
const lon = Number(asset.longitude)
const lat = Number(asset.latitude)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue
nextIds.add(asset.id)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
const selected = selectedId.value === asset.id
let marker = markerById.get(asset.id)
if (!marker) {
marker = new maptalks.Marker([lon, lat], {
id: asset.id,
cursor: 'pointer',
properties: { assetId: asset.id },
symbol: markerSymbol(asset, selected),
return {
type: 'Feature',
// asset.id numeric Feature.id setData
properties: {
assetId: String(asset.id),
name: asset.name || '',
kind: asset.type,
selected,
color: statusColor(asset.statusClass),
radius: asset.type === 'drone' ? (selected ? 10 : 8) : (selected ? 12 : 10),
},
geometry: { type: 'Point', coordinates: [lon, lat] },
}
})
marker.on('click', (e) => {
e?.domEvent?.stopPropagation?.()
suppressMapClick = true
select(asset.id)
setTimeout(() => { suppressMapClick = false }, 0)
.filter(Boolean),
}
}
function bindingFeatureCollection() {
const ends = bindingEndpoints()
if (!ends) {
return { type: 'FeatureCollection', features: [] }
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { label: '绑定设备' },
geometry: { type: 'LineString', coordinates: ends },
},
],
}
}
function ensureMonitorLayers() {
if (!mapInstance || !mapInstance.isStyleLoaded()) return
if (!mapInstance.getSource(DEVICE_SOURCE)) {
mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() })
}
if (!mapInstance.getSource(BINDING_SOURCE)) {
mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() })
}
if (!mapInstance.getLayer(BINDING_LINE)) {
mapInstance.addLayer({
id: BINDING_LINE,
type: 'line',
source: BINDING_SOURCE,
paint: {
'line-color': 'rgba(117,191,246,0.95)',
'line-width': 2,
'line-dasharray': [2, 1.5],
'line-opacity': 0.95,
},
})
marker.addTo(deviceLayer)
markerById.set(asset.id, marker)
} else {
marker.setCoordinates([lon, lat])
marker.setSymbol(markerSymbol(asset, selected))
}
if (!mapInstance.getLayer(DEVICE_CIRCLE)) {
mapInstance.addLayer({
id: DEVICE_CIRCLE,
type: 'circle',
source: DEVICE_SOURCE,
paint: {
'circle-radius': ['get', 'radius'],
'circle-color': ['get', 'color'],
'circle-stroke-color': '#fff',
'circle-stroke-width': [
'case',
['to-boolean', ['get', 'selected']],
3,
2,
],
},
})
}
if (!mapInstance.getLayer(DEVICE_LABEL)) {
mapInstance.addLayer({
id: DEVICE_LABEL,
type: 'symbol',
source: DEVICE_SOURCE,
layout: {
'text-field': ['get', 'name'],
'text-size': 11,
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': false,
},
paint: {
'text-color': '#fff',
'text-halo-color': 'rgba(24,35,45,.86)',
'text-halo-width': 1.2,
},
})
}
layersReady = true
}
for (const [id, marker] of markerById) {
if (!nextIds.has(id)) {
marker.remove()
markerById.delete(id)
function clearMonitorLayers() {
if (!mapInstance) return
for (const id of [DEVICE_LABEL, DEVICE_CIRCLE, BINDING_LINE]) {
if (mapInstance.getLayer(id)) mapInstance.removeLayer(id)
}
for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) {
if (mapInstance.getSource(id)) mapInstance.removeSource(id)
}
syncBindingLine()
layersReady = false
}
function syncMarkers() {
if (!mapInstance || !layersReady) return
const src = mapInstance.getSource(DEVICE_SOURCE)
if (src) src.setData(devicesFeatureCollection())
syncBindingLine()
}
function updateMapScale() {
if (!mapInstance) return
@ -543,29 +600,16 @@ function bindingEndpoints() {
}
function syncBindingLine() {
if (!bindingLayer) return
const ends = bindingEndpoints()
if (!ends) {
if (bindingLine) {
bindingLine.remove()
bindingLine = null
}
return
}
if (!bindingLine) {
bindingLine = new maptalks.LineString(ends, {
symbol: {
lineColor: 'rgba(117,191,246,0.95)',
lineWidth: 2,
lineDasharray: [8, 6],
lineOpacity: 0.95,
},
properties: { label: '绑定设备' },
})
bindingLine.addTo(bindingLayer)
} else {
bindingLine.setCoordinates(ends)
}
if (!mapInstance || !layersReady) return
const src = mapInstance.getSource(BINDING_SOURCE)
if (src) src.setData(bindingFeatureCollection())
}
function rebuildAfterStyle() {
layersReady = false
ensureMonitorLayers()
syncMarkers()
updateMapScale()
}
function setMapMode(mode) {
@ -573,13 +617,13 @@ function setMapMode(mode) {
mapMode.value = mode
return
}
const tk = mapConfig.token
if (!tk) {
ui.toast('缺少天地图 token,无法切换底图')
if (!mapConfig.token) {
ui.toast('缺少 Mapbox token,无法切换底图')
return
}
mapMode.value = mode
mapInstance.setBaseLayer(createTiandituBaseLayer(maptalks, tk, mode))
mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite)
mapInstance.once('style.load', rebuildAfterStyle)
}
function fitAll() {
@ -597,23 +641,18 @@ function fitAllMarkers() {
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
if (!coords.length) {
mapInstance.setCenter(mapConfig.center)
mapInstance.setZoom(mapConfig.zoom)
mapInstance.jumpTo({ center: mapConfig.center, zoom: mapConfig.zoom })
return
}
if (coords.length === 1) {
mapInstance.animateTo({ center: coords[0], zoom: 16 }, { duration: 450 })
mapInstance.easeTo({ center: coords[0], zoom: 16, duration: 450 })
return
}
const extent = new maptalks.Extent(
Math.min(...coords.map((c) => c[0])),
Math.min(...coords.map((c) => c[1])),
Math.max(...coords.map((c) => c[0])),
Math.max(...coords.map((c) => c[1])),
const bounds = coords.reduce(
(b, c) => b.extend(c),
new mapboxgl.LngLatBounds(coords[0], coords[0]),
)
mapInstance.fitExtent(extent, 0, { animation: true, duration: 450, padding: [60, 60, 60, 60] })
mapInstance.fitBounds(bounds, { padding: 60, duration: 450 })
}
function flyToSelected() {
@ -623,57 +662,60 @@ function flyToSelected() {
const lon = Number(asset.longitude)
const lat = Number(asset.latitude)
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return
mapInstance.animateTo({ center: [lon, lat], zoom: Math.max(mapInstance.getZoom(), 16) }, { duration: 450 })
mapInstance.easeTo({
center: [lon, lat],
zoom: Math.max(mapInstance.getZoom(), 16),
duration: 450,
})
}
function onMapBlankClick() {
function onMapClick(e) {
if (suppressMapClick) return
const feats = mapInstance.queryRenderedFeatures(e.point, {
layers: [DEVICE_CIRCLE, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)),
})
const id = feats[0]?.properties?.assetId
if (id) {
suppressMapClick = true
select(String(id))
setTimeout(() => { suppressMapClick = false }, 0)
return
}
clearSelection()
}
async function setupMap() {
const map = await commonRefs.getRef('map')
if (!map) {
ui.toast('地图未初始化(缺少天地图 token)')
ui.toast('地图未初始化(缺少 Mapbox token)')
return
}
mapInstance = map
mapHelper.toggleMapMode({ is2D: true })
const existing = map.getLayer(LAYER_ID)
if (existing) existing.remove()
const existingBinding = map.getLayer(BINDING_LAYER_ID)
if (existingBinding) existingBinding.remove()
deviceLayer = new maptalks.VectorLayer(LAYER_ID, [], { zIndex: 120, forceRenderOnMoving: true })
deviceLayer.addTo(map)
bindingLayer = new maptalks.VectorLayer(BINDING_LAYER_ID, [], { zIndex: 110, forceRenderOnMoving: true })
bindingLayer.addTo(map)
map.on('click', onMapBlankClick)
map.on('zoomend', updateMapScale)
map.on('moveend', updateMapScale)
updateMapScale()
const onReady = () => {
ensureMonitorLayers()
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
updateMapScale()
}
if (map.isStyleLoaded()) onReady()
else map.once('load', onReady)
map.on('click', onMapClick)
map.on('zoom', updateMapScale)
map.on('move', updateMapScale)
}
function teardownMap() {
if (mapInstance) {
mapInstance.off('click', onMapBlankClick)
mapInstance.off('zoomend', updateMapScale)
mapInstance.off('moveend', updateMapScale)
const layer = mapInstance.getLayer(LAYER_ID)
if (layer) layer.remove()
const binding = mapInstance.getLayer(BINDING_LAYER_ID)
if (binding) binding.remove()
mapInstance.off('click', onMapClick)
mapInstance.off('zoom', updateMapScale)
mapInstance.off('move', updateMapScale)
clearMonitorLayers()
}
markerById.clear()
bindingLine = null
bindingLayer = null
deviceLayer = null
mapInstance = null
commonRefs.clearPendingList('map')
}
@ -1104,12 +1146,11 @@ function goDetail() {
function zoomIn() {
if (!mapInstance) return
mapInstance.setZoom(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1))
mapInstance.zoomTo(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1), { duration: 200 })
}
function zoomOut() {
if (!mapInstance) return
mapInstance.setZoom(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1))
mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 })
}
async function runCommand(title, desc) {

Loading…
Cancel
Save