|
|
@ -362,28 +362,29 @@ |
|
|
<script setup> |
|
|
<script setup> |
|
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' |
|
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' |
|
|
import { useRoute, useRouter } from 'vue-router' |
|
|
import { useRoute, useRouter } from 'vue-router' |
|
|
import mapboxgl from 'mapbox-gl' |
|
|
|
|
|
import { useDeviceStore } from '@/stores/modules/devicesStore' |
|
|
import { useDeviceStore } from '@/stores/modules/devicesStore' |
|
|
import { useUiStore } from '@/stores/modules/uiStore' |
|
|
import { useUiStore } from '@/stores/modules/uiStore' |
|
|
import { useUserStore } from '@/stores/modules/userStore' |
|
|
import { useUserStore } from '@/stores/modules/userStore' |
|
|
import commonRefs from '@/utils/commonRefs' |
|
|
import commonRefs from '@/utils/commonRefs' |
|
|
import mapHelper from '@/core/mapHelper' |
|
|
import mapHelper from '@/core/mapHelper' |
|
|
import mapConfig, { MAP_STYLES } from '@/config/map' |
|
|
|
|
|
|
|
|
import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map' |
|
|
import LivePlayer from '@/components/LivePlayer.vue' |
|
|
import LivePlayer from '@/components/LivePlayer.vue' |
|
|
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' |
|
|
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' |
|
|
|
|
|
|
|
|
const DEVICE_SOURCE = 'monitor-devices' |
|
|
const DEVICE_SOURCE = 'monitor-devices' |
|
|
const DEVICE_CIRCLE = 'monitor-devices-circle' |
|
|
|
|
|
|
|
|
const DEVICE_SYMBOL = 'monitor-devices-symbol' |
|
|
|
|
|
const DEVICE_HALO = 'monitor-devices-halo' |
|
|
const DEVICE_LABEL = 'monitor-devices-label' |
|
|
const DEVICE_LABEL = 'monitor-devices-label' |
|
|
const BINDING_SOURCE = 'monitor-binding' |
|
|
const BINDING_SOURCE = 'monitor-binding' |
|
|
const BINDING_LINE = 'monitor-binding-line' |
|
|
const BINDING_LINE = 'monitor-binding-line' |
|
|
|
|
|
|
|
|
const STATUS_COLORS = { |
|
|
|
|
|
online: '#21a06a', |
|
|
|
|
|
mission: '#2d82da', |
|
|
|
|
|
alarm: '#cc3d43', |
|
|
|
|
|
offline: '#84909b', |
|
|
|
|
|
pending: '#c48a2a', |
|
|
|
|
|
|
|
|
const PIN_KINDS = ['dock', 'drone'] |
|
|
|
|
|
const PIN_STATUSES = ['online', 'mission', 'alarm', 'offline', 'pending'] |
|
|
|
|
|
|
|
|
|
|
|
function pinImageId(kind, status) { |
|
|
|
|
|
const s = PIN_STATUSES.includes(status) ? status : 'offline' |
|
|
|
|
|
const k = PIN_KINDS.includes(kind) ? kind : 'dock' |
|
|
|
|
|
return `${k}-pin-${s}` |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const devices = useDeviceStore() |
|
|
const devices = useDeviceStore() |
|
|
@ -442,9 +443,6 @@ const mapMarkers = computed(() => |
|
|
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock) |
|
|
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock) |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
function statusColor(statusClass) { |
|
|
|
|
|
return STATUS_COLORS[statusClass] || STATUS_COLORS.offline |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function devicesFeatureCollection() { |
|
|
function devicesFeatureCollection() { |
|
|
return { |
|
|
return { |
|
|
@ -455,6 +453,7 @@ function devicesFeatureCollection() { |
|
|
const lat = Number(asset.latitude) |
|
|
const lat = Number(asset.latitude) |
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null |
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null |
|
|
const selected = selectedId.value === asset.id |
|
|
const selected = selectedId.value === asset.id |
|
|
|
|
|
const status = asset.statusClass || 'offline' |
|
|
return { |
|
|
return { |
|
|
type: 'Feature', |
|
|
type: 'Feature', |
|
|
// asset.id 多为字符串,省略 numeric Feature.id,靠 setData 全量刷新 |
|
|
// asset.id 多为字符串,省略 numeric Feature.id,靠 setData 全量刷新 |
|
|
@ -462,9 +461,9 @@ function devicesFeatureCollection() { |
|
|
assetId: String(asset.id), |
|
|
assetId: String(asset.id), |
|
|
name: asset.name || '', |
|
|
name: asset.name || '', |
|
|
kind: asset.type, |
|
|
kind: asset.type, |
|
|
|
|
|
status, |
|
|
selected: selected ? 1 : 0, |
|
|
selected: selected ? 1 : 0, |
|
|
color: statusColor(asset.statusClass), |
|
|
|
|
|
radius: asset.type === 'drone' ? (selected ? 10 : 8) : (selected ? 12 : 10), |
|
|
|
|
|
|
|
|
icon: pinImageId(asset.type, status), |
|
|
}, |
|
|
}, |
|
|
geometry: { type: 'Point', coordinates: [lon, lat] }, |
|
|
geometry: { type: 'Point', coordinates: [lon, lat] }, |
|
|
} |
|
|
} |
|
|
@ -490,14 +489,40 @@ function bindingFeatureCollection() { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function ensureMonitorLayers() { |
|
|
|
|
|
|
|
|
async function ensurePinImages(map) { |
|
|
|
|
|
if (!map) return |
|
|
|
|
|
const jobs = [] |
|
|
|
|
|
for (const kind of PIN_KINDS) { |
|
|
|
|
|
for (const status of PIN_STATUSES) { |
|
|
|
|
|
const id = pinImageId(kind, status) |
|
|
|
|
|
if (map.hasImage(id)) continue |
|
|
|
|
|
const url = `/assets/icons/${id}.svg` |
|
|
|
|
|
jobs.push( |
|
|
|
|
|
new Promise((resolve) => { |
|
|
|
|
|
map.loadImage(url, (err, image) => { |
|
|
|
|
|
if (!err && image && !map.hasImage(id)) { |
|
|
|
|
|
map.addImage(id, image, { pixelRatio: 2 }) |
|
|
|
|
|
} |
|
|
|
|
|
resolve() |
|
|
|
|
|
}) |
|
|
|
|
|
}), |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
await Promise.all(jobs) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function ensureMonitorLayers() { |
|
|
if (!mapInstance || !mapInstance.isStyleLoaded()) return |
|
|
if (!mapInstance || !mapInstance.isStyleLoaded()) return |
|
|
|
|
|
await ensurePinImages(mapInstance) |
|
|
|
|
|
|
|
|
if (!mapInstance.getSource(DEVICE_SOURCE)) { |
|
|
if (!mapInstance.getSource(DEVICE_SOURCE)) { |
|
|
mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() }) |
|
|
mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() }) |
|
|
} |
|
|
} |
|
|
if (!mapInstance.getSource(BINDING_SOURCE)) { |
|
|
if (!mapInstance.getSource(BINDING_SOURCE)) { |
|
|
mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() }) |
|
|
mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() }) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (!mapInstance.getLayer(BINDING_LINE)) { |
|
|
if (!mapInstance.getLayer(BINDING_LINE)) { |
|
|
mapInstance.addLayer({ |
|
|
mapInstance.addLayer({ |
|
|
id: BINDING_LINE, |
|
|
id: BINDING_LINE, |
|
|
@ -511,33 +536,51 @@ function ensureMonitorLayers() { |
|
|
}, |
|
|
}, |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |
|
|
if (!mapInstance.getLayer(DEVICE_CIRCLE)) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!mapInstance.getLayer(DEVICE_HALO)) { |
|
|
mapInstance.addLayer({ |
|
|
mapInstance.addLayer({ |
|
|
id: DEVICE_CIRCLE, |
|
|
|
|
|
|
|
|
id: DEVICE_HALO, |
|
|
type: 'circle', |
|
|
type: 'circle', |
|
|
source: DEVICE_SOURCE, |
|
|
source: DEVICE_SOURCE, |
|
|
|
|
|
filter: ['==', ['get', 'selected'], 1], |
|
|
paint: { |
|
|
paint: { |
|
|
'circle-radius': ['get', 'radius'], |
|
|
|
|
|
'circle-color': ['get', 'color'], |
|
|
|
|
|
'circle-stroke-color': '#fff', |
|
|
|
|
|
'circle-stroke-width': [ |
|
|
|
|
|
|
|
|
'circle-radius': 22, |
|
|
|
|
|
'circle-color': 'rgba(45,130,218,0.22)', |
|
|
|
|
|
'circle-stroke-width': 0, |
|
|
|
|
|
}, |
|
|
|
|
|
}) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (!mapInstance.getLayer(DEVICE_SYMBOL)) { |
|
|
|
|
|
mapInstance.addLayer({ |
|
|
|
|
|
id: DEVICE_SYMBOL, |
|
|
|
|
|
type: 'symbol', |
|
|
|
|
|
source: DEVICE_SOURCE, |
|
|
|
|
|
layout: { |
|
|
|
|
|
'icon-image': ['get', 'icon'], |
|
|
|
|
|
'icon-size': [ |
|
|
'case', |
|
|
'case', |
|
|
['==', ['get', 'selected'], 1], |
|
|
['==', ['get', 'selected'], 1], |
|
|
3, |
|
|
|
|
|
2, |
|
|
|
|
|
|
|
|
1.25, |
|
|
|
|
|
1, |
|
|
], |
|
|
], |
|
|
|
|
|
'icon-anchor': 'bottom', |
|
|
|
|
|
'icon-allow-overlap': true, |
|
|
|
|
|
'icon-ignore-placement': true, |
|
|
}, |
|
|
}, |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (!mapInstance.getLayer(DEVICE_LABEL)) { |
|
|
if (!mapInstance.getLayer(DEVICE_LABEL)) { |
|
|
mapInstance.addLayer({ |
|
|
mapInstance.addLayer({ |
|
|
id: DEVICE_LABEL, |
|
|
id: DEVICE_LABEL, |
|
|
type: 'symbol', |
|
|
type: 'symbol', |
|
|
source: DEVICE_SOURCE, |
|
|
source: DEVICE_SOURCE, |
|
|
|
|
|
minzoom: 4, |
|
|
layout: { |
|
|
layout: { |
|
|
'text-field': ['get', 'name'], |
|
|
'text-field': ['get', 'name'], |
|
|
'text-size': 11, |
|
|
'text-size': 11, |
|
|
'text-offset': [0, 1.4], |
|
|
|
|
|
|
|
|
'text-offset': [0, 0.6], |
|
|
'text-anchor': 'top', |
|
|
'text-anchor': 'top', |
|
|
'text-allow-overlap': false, |
|
|
'text-allow-overlap': false, |
|
|
}, |
|
|
}, |
|
|
@ -548,12 +591,13 @@ function ensureMonitorLayers() { |
|
|
}, |
|
|
}, |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
layersReady = true |
|
|
layersReady = true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function clearMonitorLayers() { |
|
|
function clearMonitorLayers() { |
|
|
if (!mapInstance) return |
|
|
if (!mapInstance) return |
|
|
for (const id of [DEVICE_LABEL, DEVICE_CIRCLE, BINDING_LINE]) { |
|
|
|
|
|
|
|
|
for (const id of [DEVICE_LABEL, DEVICE_SYMBOL, DEVICE_HALO, BINDING_LINE]) { |
|
|
if (mapInstance.getLayer(id)) mapInstance.removeLayer(id) |
|
|
if (mapInstance.getLayer(id)) mapInstance.removeLayer(id) |
|
|
} |
|
|
} |
|
|
for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) { |
|
|
for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) { |
|
|
@ -623,11 +667,11 @@ function cancelPendingMapHandlers() { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function rebuildAfterStyle() { |
|
|
|
|
|
|
|
|
async function rebuildAfterStyle() { |
|
|
pendingStyleHandler = null |
|
|
pendingStyleHandler = null |
|
|
if (!mapInstance) return |
|
|
if (!mapInstance) return |
|
|
layersReady = false |
|
|
layersReady = false |
|
|
ensureMonitorLayers() |
|
|
|
|
|
|
|
|
await ensureMonitorLayers() |
|
|
syncMarkers() |
|
|
syncMarkers() |
|
|
updateMapScale() |
|
|
updateMapScale() |
|
|
} |
|
|
} |
|
|
@ -652,33 +696,23 @@ function setMapMode(mode) { |
|
|
mapInstance.once('style.load', pendingStyleHandler) |
|
|
mapInstance.once('style.load', pendingStyleHandler) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function resetOverviewCamera() { |
|
|
|
|
|
if (!mapInstance) return |
|
|
|
|
|
mapInstance.easeTo({ |
|
|
|
|
|
center: MAP_OVERVIEW.center, |
|
|
|
|
|
zoom: MAP_OVERVIEW.zoom, |
|
|
|
|
|
pitch: 0, |
|
|
|
|
|
bearing: 0, |
|
|
|
|
|
duration: 450, |
|
|
|
|
|
}) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function fitAll() { |
|
|
function fitAll() { |
|
|
if (selectedId.value) { |
|
|
if (selectedId.value) { |
|
|
clearSelection() |
|
|
clearSelection() |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
fitAllMarkers() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function fitAllMarkers() { |
|
|
|
|
|
if (!mapInstance) return |
|
|
|
|
|
const coords = mapMarkers.value |
|
|
|
|
|
.map((a) => [Number(a.longitude), Number(a.latitude)]) |
|
|
|
|
|
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)) |
|
|
|
|
|
|
|
|
|
|
|
if (!coords.length) { |
|
|
|
|
|
mapInstance.jumpTo({ center: mapConfig.center, zoom: mapConfig.zoom }) |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
if (coords.length === 1) { |
|
|
|
|
|
mapInstance.easeTo({ center: coords[0], zoom: 16, duration: 450 }) |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
const bounds = coords.reduce( |
|
|
|
|
|
(b, c) => b.extend(c), |
|
|
|
|
|
new mapboxgl.LngLatBounds(coords[0], coords[0]), |
|
|
|
|
|
) |
|
|
|
|
|
mapInstance.fitBounds(bounds, { padding: 60, duration: 450 }) |
|
|
|
|
|
|
|
|
resetOverviewCamera() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function flyToSelected() { |
|
|
function flyToSelected() { |
|
|
@ -690,7 +724,7 @@ function flyToSelected() { |
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return |
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return |
|
|
mapInstance.easeTo({ |
|
|
mapInstance.easeTo({ |
|
|
center: [lon, lat], |
|
|
center: [lon, lat], |
|
|
zoom: Math.max(mapInstance.getZoom(), 16), |
|
|
|
|
|
|
|
|
zoom: Math.max(mapInstance.getZoom(), MAP_VIEW.detailZoom), |
|
|
duration: 450, |
|
|
duration: 450, |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |
|
|
@ -698,7 +732,7 @@ function flyToSelected() { |
|
|
function onMapClick(e) { |
|
|
function onMapClick(e) { |
|
|
if (suppressMapClick) return |
|
|
if (suppressMapClick) return |
|
|
if (!layersReady) return |
|
|
if (!layersReady) return |
|
|
const layers = [DEVICE_CIRCLE, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)) |
|
|
|
|
|
|
|
|
const layers = [DEVICE_SYMBOL, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)) |
|
|
if (!layers.length) return |
|
|
if (!layers.length) return |
|
|
const feats = mapInstance.queryRenderedFeatures(e.point, { |
|
|
const feats = mapInstance.queryRenderedFeatures(e.point, { |
|
|
layers, |
|
|
layers, |
|
|
@ -713,6 +747,16 @@ function onMapClick(e) { |
|
|
clearSelection() |
|
|
clearSelection() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function onSymbolEnter() { |
|
|
|
|
|
if (!mapInstance) return |
|
|
|
|
|
mapInstance.getCanvas().style.cursor = 'pointer' |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function onSymbolLeave() { |
|
|
|
|
|
if (!mapInstance) return |
|
|
|
|
|
mapInstance.getCanvas().style.cursor = '' |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function setupMap() { |
|
|
async function setupMap() { |
|
|
const map = await commonRefs.getRef('map') |
|
|
const map = await commonRefs.getRef('map') |
|
|
if (!map) { |
|
|
if (!map) { |
|
|
@ -722,13 +766,13 @@ async function setupMap() { |
|
|
mapInstance = map |
|
|
mapInstance = map |
|
|
mapHelper.toggleMapMode({ is2D: true }) |
|
|
mapHelper.toggleMapMode({ is2D: true }) |
|
|
|
|
|
|
|
|
const onReady = () => { |
|
|
|
|
|
|
|
|
const onReady = async () => { |
|
|
pendingLoadHandler = null |
|
|
pendingLoadHandler = null |
|
|
if (!mapInstance) return |
|
|
if (!mapInstance) return |
|
|
ensureMonitorLayers() |
|
|
|
|
|
|
|
|
await ensureMonitorLayers() |
|
|
syncMarkers() |
|
|
syncMarkers() |
|
|
if (selectedId.value) flyToSelected() |
|
|
if (selectedId.value) flyToSelected() |
|
|
else fitAllMarkers() |
|
|
|
|
|
|
|
|
else resetOverviewCamera() |
|
|
updateMapScale() |
|
|
updateMapScale() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -740,6 +784,8 @@ async function setupMap() { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
map.on('click', onMapClick) |
|
|
map.on('click', onMapClick) |
|
|
|
|
|
map.on('mouseenter', DEVICE_SYMBOL, onSymbolEnter) |
|
|
|
|
|
map.on('mouseleave', DEVICE_SYMBOL, onSymbolLeave) |
|
|
map.on('zoom', updateMapScale) |
|
|
map.on('zoom', updateMapScale) |
|
|
map.on('move', updateMapScale) |
|
|
map.on('move', updateMapScale) |
|
|
} |
|
|
} |
|
|
@ -748,6 +794,8 @@ function teardownMap() { |
|
|
cancelPendingMapHandlers() |
|
|
cancelPendingMapHandlers() |
|
|
if (mapInstance) { |
|
|
if (mapInstance) { |
|
|
mapInstance.off('click', onMapClick) |
|
|
mapInstance.off('click', onMapClick) |
|
|
|
|
|
mapInstance.off('mouseenter', DEVICE_SYMBOL, onSymbolEnter) |
|
|
|
|
|
mapInstance.off('mouseleave', DEVICE_SYMBOL, onSymbolLeave) |
|
|
mapInstance.off('zoom', updateMapScale) |
|
|
mapInstance.off('zoom', updateMapScale) |
|
|
mapInstance.off('move', updateMapScale) |
|
|
mapInstance.off('move', updateMapScale) |
|
|
clearMonitorLayers() |
|
|
clearMonitorLayers() |
|
|
@ -783,7 +831,7 @@ watch(mapMarkers, () => { |
|
|
watch(selectedId, () => { |
|
|
watch(selectedId, () => { |
|
|
syncMarkers() |
|
|
syncMarkers() |
|
|
if (selectedId.value) flyToSelected() |
|
|
if (selectedId.value) flyToSelected() |
|
|
else fitAllMarkers() |
|
|
|
|
|
|
|
|
else resetOverviewCamera() |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null)) |
|
|
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null)) |
|
|
@ -1088,7 +1136,7 @@ async function reload() { |
|
|
}) |
|
|
}) |
|
|
syncMarkers() |
|
|
syncMarkers() |
|
|
if (selectedId.value) flyToSelected() |
|
|
if (selectedId.value) flyToSelected() |
|
|
else fitAllMarkers() |
|
|
|
|
|
|
|
|
else resetOverviewCamera() |
|
|
ui.toast('设备状态已刷新') |
|
|
ui.toast('设备状态已刷新') |
|
|
} catch (e) { |
|
|
} catch (e) { |
|
|
ui.toast(e.message || '刷新设备状态失败') |
|
|
ui.toast(e.message || '刷新设备状态失败') |
|
|
|