【类 型】:feat

【原  因】:1集群 点飞功能完善 发命令 规划引导线等  2单体点飞增加 规划引导线功能  3集群飞机轨迹显示bug
【过  程】:
【影  响】:

# 类型 包含:
# feat:新功能(feature)
# fix:修补bug
# docs:文档(documentation)
# style: 格式(不影响代码运行的变动)
# refactor:重构(即不是新增功能,也不是修改bug的代码变动)
# test:增加测试
# chore:构建过程或辅助工具的变动
This commit is contained in:
air 2025-06-24 16:12:28 +08:00
parent bcffe48c50
commit b42ea4328d
6 changed files with 384 additions and 178 deletions

View File

@ -748,8 +748,7 @@ export default {
* @description: 创建飞机轨迹 ps:原理删除之前的轨迹 重新绘制 用于实时轨迹
* @param {arr} coordinatesArray 飞机经纬高度数组
*/
createPathWithArray (coordinatesArray) {
// GeoJSON LineString
createPathWithArray (coordinatesArray, pathId = 'path') {
const geojson = {
type: 'Feature',
properties: {},
@ -762,21 +761,21 @@ export default {
])
}
}
//
if (this.map.getLayer('path')) {
this.map.getSource('path').setData(geojson)
//
if (this.map.getLayer(pathId)) {
this.map.getSource(pathId).setData(geojson)
} else {
//
//
if (coordinatesArray.length > 0) {
// 3D
// source layer
this.map.addSource(pathId, {
type: 'geojson',
data: geojson
})
this.map.addLayer({
id: 'path',
id: pathId,
type: 'line',
source: {
type: 'geojson',
data: geojson
},
source: pathId,
layout: {
'line-cap': 'round',
'line-join': 'round'
@ -976,8 +975,8 @@ export default {
if (plane != null) {
plane.setLngLat([lonLat.lon, lonLat.lat])
}
//
this.createPathWithArray(pathArr) //
// ID
this.createPathWithArray(pathArr, `path-${index}`)
//
if (this.isflow) {
this.map.flyTo({
@ -1090,65 +1089,112 @@ export default {
}, 200)
}
},
drawTestPoints (positions, center) {
//
if (this.map.getSource('test-points')) this.map.removeSource('test-points')
if (this.map.getLayer('test-points')) this.map.removeLayer('test-points')
if (this.map.getSource('center-point')) this.map.removeSource('center-point')
if (this.map.getLayer('center-point')) this.map.removeLayer('center-point')
/**
* @description: 点飞 - 绘制引导线与中心点 ps: 该方法用于在地图上绘制从中心点到点击点的引导线以及每架飞机到终点的拓扑偏移线
* @param {Object} centerPoint - 中心点经纬度对象 {lng: number, lat: number}
* @param {Object} endPoint - 点击点经纬度对象 {lng: number, lat: number}
* @param {Array} planeCoords - 飞机坐标数组 [[lng, lat], ...]
*/
drawGuidedLines ({ centerPoint, endPoint, planeCoords }) {
const sourceId = 'guided-lines-source'
const lineLayerId = 'guided-lines-layer'
const pointLayerId = 'guided-center-point-layer'
// GeoJSON
const features = positions.map(pos => ({
//
if (this.map.getLayer(lineLayerId)) this.map.removeLayer(lineLayerId)
if (this.map.getLayer(pointLayerId)) this.map.removeLayer(pointLayerId)
if (this.map.getSource(sourceId)) this.map.removeSource(sourceId)
const deltaLng = endPoint.lng - centerPoint.lng
const deltaLat = endPoint.lat - centerPoint.lat
const features = []
// 🔵 线
planeCoords.forEach(([lng, lat]) => {
const targetLng = lng + deltaLng
const targetLat = lat + deltaLat
features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [[lng, lat], [targetLng, targetLat]]
},
properties: {
color: 'blue',
type: 'line'
}
})
})
// 🔴 线
features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [[centerPoint.lng, centerPoint.lat], [endPoint.lng, endPoint.lat]]
},
properties: {
color: 'red',
type: 'line'
}
})
// 🔴
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [pos[0], pos[1]]
}
}))
this.map.addSource('test-points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: features
coordinates: [centerPoint.lng, centerPoint.lat]
},
properties: {
color: 'red',
type: 'center-point'
}
})
// GeoJSON
this.map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features
}
})
// 线
this.map.addLayer({
id: 'test-points',
id: lineLayerId,
type: 'line',
source: sourceId,
filter: ['==', ['get', 'type'], 'line'],
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-dasharray': [2, 2]
}
})
//
this.map.addLayer({
id: pointLayerId,
type: 'circle',
source: 'test-points',
source: sourceId,
filter: ['==', ['get', 'type'], 'center-point'],
paint: {
'circle-radius': 6,
'circle-color': '#3399ff' //
}
})
//
this.map.addSource('center-point', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [center.lon, center.lat]
}
}]
}
})
this.map.addLayer({
id: 'center-point',
type: 'circle',
source: 'center-point',
paint: {
'circle-radius': 8,
'circle-color': '#ff3333' //
'circle-color': ['get', 'color'],
'circle-stroke-color': '#fff',
'circle-stroke-width': 2
}
})
}
},
beforeDestroy () {
if (this.map) {
@ -1184,8 +1230,15 @@ export default {
/*飞机popup 心跳图标样式*/
@keyframes heartbeat {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.3); }
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
}
.heart-icon {

View File

@ -30,6 +30,20 @@ const store = new Vuex.Store({
ADSBList: [] // 存放当前活跃的 ADSB 飞机数据
},
mutations: {
updatePlanePosition (state, { planeId, position }) {
const plane = state.airList.find(p => p.id === planeId)
if (plane) {
if (!plane.planeState) {
Vue.set(plane, 'planeState', {})
}
if (!plane.planeState.position) {
Vue.set(plane.planeState, 'position', [])
}
// 追加新点
plane.planeState.position.push(...position)
}
},
/**
* @description: 设置商铺列表
*/

View File

@ -159,6 +159,97 @@ export function formatPrice (value) {
return formattedPrice
}
/**
* @description: 把对象保存为 JSON 文件并触发下载
* @param {*} data - 要保存的对象数据
* @param {string} filename - 下载的文件名默认为 data_时间
*/
export function saveObjectToFile (data, filename = `data_${Date.now()}.json`) {
if (!data || typeof data !== 'object') {
alert('无效的数据对象')
return
}
// 将对象格式化为 JSON 字符串
const content = JSON.stringify(data, null, 2)
// 创建 Blob 对象
const blob = new Blob([content], { type: 'application/json' })
// 创建一个临时下载链接
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
// 模拟点击以触发下载
link.click()
// 释放资源
URL.revokeObjectURL(link.href)
}
/* ----------------------- 几何 线代 等 相关 ----------------------- */
/**
* @abstract 传入一组坐标组返回包围盒中心点即最小矩形包围框的中心
* @param {Array} positions - 包含经度纬度和高度的数组格式[[经度, 纬度, 高度], ...]
* @return {Object|null} 返回包围盒中心点对象 { lon: 经度, lat: 纬度, alt: 高度 }
* 如果输入无效则返回 null
*/
export function getBoundingCenter (positions) {
if (!Array.isArray(positions) || positions.length === 0) return null
// 初始化边界极值
let minLon = Infinity; let maxLon = -Infinity
let minLat = Infinity; let maxLat = -Infinity
let minAlt = Infinity; let maxAlt = -Infinity
// 遍历所有点,计算边界最大最小值
positions.forEach(pos => {
const [lon, lat, alt = 0] = pos
if (lon < minLon) minLon = lon
if (lon > maxLon) maxLon = lon
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (alt < minAlt) minAlt = alt
if (alt > maxAlt) maxAlt = alt
})
// 计算包围盒中心点坐标,返回对象格式
return {
lon: (minLon + maxLon) / 2,
lat: (minLat + maxLat) / 2,
alt: (minAlt + maxAlt) / 2
}
}
/**
* @abstract 传入一组坐标组返回几何中心点重心
* @param {Array} positions - 包含经度纬度和高度的数组格式[[经度, 纬度, 高度], ...]
* @return {Object|null} 返回几何中心点对象 { lon: 经度, lat: 纬度, alt: 高度 }
* 如果输入无效则返回 null
*/
export function getGeometricCenter (positions) {
if (!Array.isArray(positions) || positions.length === 0) return null
// 累加所有点的经纬高坐标
const sum = positions.reduce((acc, [lon, lat, alt = 0]) => {
acc.lon += lon
acc.lat += lat
acc.alt += alt
return acc
}, { lon: 0, lat: 0, alt: 0 })
const count = positions.length
// 计算平均值作为几何中心,返回对象格式
return {
lon: sum.lon / count,
lat: sum.lat / count,
alt: sum.alt / count
}
}
/**
* @description:等待 Mapbox 地图的画布容器Canvas Container准备就绪因为 Mapbox 初始化是异步的某些操作需要等待画布容器加载完成才能执行
* @param {Object} map - Mapbox GL JS 地图实例对象
@ -190,32 +281,3 @@ export function waitForMapCanvasReady (map, maxRetry = 5) {
check(maxRetry)
})
}
/**
* @description: 把对象保存为 JSON 文件并触发下载
* @param {*} data - 要保存的对象数据
* @param {string} filename - 下载的文件名默认为 data_时间
*/
export function saveObjectToFile (data, filename = `data_${Date.now()}.json`) {
if (!data || typeof data !== 'object') {
alert('无效的数据对象')
return
}
// 将对象格式化为 JSON 字符串
const content = JSON.stringify(data, null, 2)
// 创建 Blob 对象
const blob = new Blob([content], { type: 'application/json' })
// 创建一个临时下载链接
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
// 模拟点击以触发下载
link.click()
// 释放资源
URL.revokeObjectURL(link.href)
}

View File

@ -3,7 +3,7 @@
<map-box ref="mapbox" @map-ready="onMapReady">
<template #content>
<div v-show="mapReady">
<Statistics :planes="airList" />
<Statistics :planes="planeList" />
</div>
</template>
</map-box>
@ -27,12 +27,21 @@ export default {
Statistics
},
computed: {
airList () {
planeList () {
return this.$store.state.airList
},
// s
planeStatus () {
return this.airList.map(plane => plane.planeState)
return this.planeList.map(plane => plane.planeState)
},
/**
* @description: 所有飞机的历史定位数组集合每个元素是一架飞机的 position 数组
*/
planePositions () {
return this.planeList.map(plane => {
const posArr = plane.planeState?.position
return Array.isArray(posArr) ? posArr : []
})
},
/**
* @description: 侧边栏显隐
@ -45,7 +54,7 @@ export default {
//
onMapReady () {
this.mapReady = true //
this.makePlanes(this.airList)
this.makePlanes(this.planeList)
},
/**
* @description: 创建飞机图标
@ -68,43 +77,12 @@ export default {
}
},
mounted () {
setTimeout(() => {
//
const centerLng = 100.0000
const centerLat = 40.0000
// 10
const testPositions = Array.from({ length: 10 }, () => {
const offsetLng = centerLng + (Math.random() - 0.5) * 0.001 // ±0.0005
const offsetLat = centerLat + (Math.random() - 0.5) * 0.001
const alt = 100 + Math.floor(Math.random() * 100) // 100~200
return [offsetLng, offsetLat, alt]
})
//
const center = testPositions.reduce((acc, pos) => {
acc.lon += pos[0]
acc.lat += pos[1]
acc.alt += pos[2]
return acc
}, { lon: 0, lat: 0, alt: 0 })
const count = testPositions.length
const centerPoint = {
lon: center.lon / count,
lat: center.lat / count,
alt: center.alt / count
}
// mapbox
this.$refs.mapbox.drawTestPoints(testPositions, centerPoint)
}, 3000)
},
watch: {
/**
* @description: 飞机列表更新时候 更新地图
*/
airList: {
planeList: {
async handler () {
try {
//
@ -121,12 +99,36 @@ export default {
planeStatus: {
handler (val) {
val.forEach((stateObj, index) => {
stateObj.name = this.airList[index].name //
stateObj.name = this.planeList[index].name //
this.$refs.mapbox.updatePopupContent(stateObj, index)
})
},
deep: true
},
//
planePositions: {
handler (allPositions) {
// allPositions position
allPositions.forEach((positions, idx) => {
const n = positions.length
if (n > 2) {
const [lon, lat] = positions[n - 1]
// (100)
const key = `swarm_plane_${this.planeList[idx].name}`
//
if (!this._storeCount) this._storeCount = {}
const cnt = (this._storeCount[key] = (this._storeCount[key] || 0) + 1)
if (cnt % 100 === 1) {
localStorage.setItem(key, JSON.stringify({ lon, lat }))
}
// MapBox idx
this.$refs.mapbox.setPlaneLonLat({ lon, lat }, idx, positions)
}
})
},
deep: true
},
/**
* @description: 侧边栏显隐
*/

View File

@ -116,6 +116,7 @@ export default {
closeCallback () {
if (this.dialogItem === 'guidedBox' && this.isReserveGuidedMaker === false) { //
this.$refs.mapbox.delGuidedMarker()//
this.$refs.mapbox.clearMapElements(['guided-lines-layer', 'guided-center-point-layer'], ['guided-lines-source'])// 线
}
this.dialogVisible = false
this.dialogItem = ''
@ -131,15 +132,27 @@ export default {
this.dialogItem = 'guidedBox'
this.guidedLonLat = lonLat //
//
//
let center = { lon: 0, lat: 0 }
let height = 0
if (this.plane && this.plane.planeState && Array.isArray(this.plane.planeState.position)) {
const posLen = this.plane.planeState.position.length
if (posLen > 0 && Array.isArray(this.plane.planeState.position[posLen - 1])) {
height = this.plane.planeState.position[posLen - 1][2] || 0
if (this.plane?.planeState?.position?.length > 0) {
const lastPos = this.plane.planeState.position.at(-1)
if (Array.isArray(lastPos)) {
center = { lon: lastPos[0], lat: lastPos[1] }
height = lastPos[2] || 0
}
}
this.guidedAlt = height
//
const planeCoords = [[center.lon, center.lat]]
// 线
this.$refs.mapbox.drawGuidedLines({
centerPoint: { lng: center.lon, lat: center.lat },
endPoint: { lng: lonLat.lon, lat: lonLat.lat },
planeCoords
})
},
// lon, lat, alt
flyToSinglePlane (lon, lat, alt) {

View File

@ -1,11 +1,11 @@
<template>
<div class="h-100">
<!-- 地图组件 -->
<map-box ref="mapbox" v-if="swarmReady" :enableShowNofly="true" :enableGuided="true" :enblueScale="!$store.state.app.isWideScreen"
@longPress="handleLongPress" @map-ready="onMapReady">
<map-box ref="mapbox" v-if="swarmReady" :enableShowNofly="true" :enableGuided="true"
:enblueScale="!$store.state.app.isWideScreen" @longPress="handleLongPress" @map-ready="onMapReady">
<template #content>
<div v-show="mapReady">
<SwarmControllerTabs :planes="planeList"/>
<SwarmControllerTabs :planes="planeList" />
</div>
</template>
</map-box>
@ -38,7 +38,8 @@
<script>
import MapBox from '@/components/MapBox'
import SwarmControllerTabs from '@/components/SwarmControllerTabs.vue'
import { waitForMapCanvasReady } from '@/utils'
import { waitForMapCanvasReady, getBoundingCenter } from '@/utils'
import mqtt from '@/utils/mqtt'
export default {
name: 'Swarm',
@ -69,6 +70,15 @@ export default {
planeStatus () {
return this.planeList.map(plane => plane.planeState)
},
/**
* @description: 所有飞机的历史定位数组集合每个元素是一架飞机的 position 数组
*/
planePositions () {
return this.planeList.map(plane => {
const posArr = plane.planeState?.position
return Array.isArray(posArr) ? posArr : []
})
},
/**
* @description: 侧边栏显隐
*/
@ -83,12 +93,41 @@ export default {
} else {
this.swarmReady = true // <map-box>
}
// //
// setInterval(() => {
// const centerLng = 116.397 //
// const centerLat = 39.908 //
// const maxOffset = 0.0002 // ±200
// this.$store.state.airList.forEach((plane) => {
// if (!plane.planeState) {
// plane.planeState = {}
// }
// //
// const offsetLng = (Math.random() - 0.5) * 2 * maxOffset
// const offsetLat = (Math.random() - 0.5) * 2 * maxOffset
// const randomLng = Number((centerLng + offsetLng).toFixed(7))
// const randomLat = Number((centerLat + offsetLat).toFixed(7))
// // 4060
// const randomAlt = Number((40 + Math.random() * 20).toFixed(2))
// //
// this.$store.commit('updatePlanePosition', {
// planeId: plane.id,
// position: [[randomLng, randomLat, randomAlt]]
// })
// })
// }, 15000)
},
methods: {
/** 弹出框 关闭事件回调 */
closeCallback () {
if (this.dialogItem === 'guidedBox' && this.isReserveGuidedMaker === false) { //
this.$refs.mapbox.delGuidedMarker()//
this.$refs.mapbox.clearMapElements(['guided-lines-layer', 'guided-center-point-layer'], ['guided-lines-source'])// 线
}
this.dialogVisible = false
this.dialogItem = ''
@ -96,26 +135,41 @@ export default {
/** 弹出框 打开事件回调 */
openCallback () {
},
//
/**
* @abstract 地图长按事件处理
* @param {Object} lonLat - 点击地图终点经纬度 { lng: number, lat: number }
*/
handleLongPress (lonLat) {
this.isReserveGuidedMaker = false
this.dialogTitle = '集群指点'
this.dialogVisible = true
this.dialogItem = 'guidedBox'
this.guidedLonLat = lonLat //
this.guidedLonLat = lonLat
//
const validHeights = this.planeList.map(p => {
// [[lng, lat, alt], ...]
const allPlaneCoords = this.planeList.map(p => {
const pos = p?.planeState?.position
const last = Array.isArray(pos) && pos.length > 0 ? pos[pos.length - 1] : null
return Array.isArray(last) ? last[2] || 0 : 0
}).filter(h => typeof h === 'number')
return Array.isArray(last) ? [last[0], last[1], last[2] || 0] : null
}).filter(Boolean)
const avgAlt = validHeights.length
? (validHeights.reduce((sum, h) => sum + h, 0) / validHeights.length).toFixed(1)
: 0
//
if (allPlaneCoords.length > 0) {
const totalAlt = allPlaneCoords.reduce((sum, item) => sum + (item[2] || 0), 0)
this.guidedAlt = Number((totalAlt / allPlaneCoords.length).toFixed(1))
} else {
this.guidedAlt = 0
}
this.guidedAlt = Number(avgAlt)
// 使 getBoundingCenter
const center = getBoundingCenter(allPlaneCoords) // { lng, lat }
// 线
this.$refs.mapbox.drawGuidedLines({
centerPoint: { lng: center.lon, lat: center.lat },
endPoint: { lng: lonLat.lon, lat: lonLat.lat },
planeCoords: allPlaneCoords.map(([lng, lat]) => [lng, lat]) // alt
})
},
//
onSwarmFlyTo () {
@ -125,15 +179,23 @@ export default {
if (
isNaN(targetLon) || isNaN(targetLat) || isNaN(targetAlt) ||
targetLon < -180 || targetLon > 180 ||
targetLat < -90 || targetLat > 90
targetLon < -180 || targetLon > 180 ||
targetLat < -90 || targetLat > 90
) {
this.$message.warning('请输入有效的经纬度(经度-180~180纬度-90~90和高度')
return
}
//
const currentCenter = this.getSwarmCenter()
// getBoundingCenter
const positions = this.planeList.map(p => {
const pos = p?.planeState?.position
// null
if (!Array.isArray(pos) || pos.length === 0) return null
return pos[pos.length - 1]
}).filter(Boolean)
//
const currentCenter = getBoundingCenter(positions)
if (!currentCenter) {
this.$message.error('无法获取飞行器中心位置,检查飞机是否都已定位')
@ -155,43 +217,19 @@ export default {
const newAlt = targetAlt + offsetAlt
return {
id: p.id,
macadd: p.macadd,
cmd: `{guidedMode:{lon:${newLon.toFixed(7)},lat:${newLat.toFixed(7)},alt:${newAlt.toFixed(1)}}}`
}
}).filter(Boolean)
//
commands.forEach(({ id, cmd }) => {
this.publishFun(cmd, id)
commands.forEach(({ macadd, cmd }) => {
mqtt.publishFun(`cmd/${macadd}`, cmd)
})
this.isReserveGuidedMaker = true
this.dialogVisible = false
},
//
getSwarmCenter () {
const positions = this.planeList.map(p => {
const pos = p?.planeState?.position
const last = Array.isArray(pos) && pos.length > 0 ? pos[pos.length - 1] : null
return Array.isArray(last) ? last : null
}).filter(Boolean)
if (!positions.length) return null
const sum = positions.reduce((acc, pos) => {
acc.lon += pos[0]
acc.lat += pos[1]
acc.alt += pos[2] || 0
return acc
}, { lon: 0, lat: 0, alt: 0 })
const count = positions.length
return {
lon: sum.lon / count,
lat: sum.lat / count,
alt: sum.alt / count
}
},
//
onMapReady () {
this.mapReady = true //
@ -244,6 +282,30 @@ export default {
},
deep: true
},
//
planePositions: {
handler (allPositions) {
// allPositions position
allPositions.forEach((positions, idx) => {
const n = positions.length
if (n > 2) {
const [lon, lat] = positions[n - 1]
// (100)
const key = `swarm_plane_${this.planeList[idx].name}`
//
if (!this._storeCount) this._storeCount = {}
const cnt = (this._storeCount[key] = (this._storeCount[key] || 0) + 1)
if (cnt % 100 === 1) {
localStorage.setItem(key, JSON.stringify({ lon, lat }))
}
// MapBox idx
this.$refs.mapbox.setPlaneLonLat({ lon, lat }, idx, positions)
}
})
},
deep: true
},
/**
* @description: 侧边栏显隐
*/