|
|
|
@ -8,14 +8,19 @@ |
|
|
|
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')" |
|
|
|
:scaleConfig="scaleConfig" |
|
|
|
@draw-complete="handleMapDrawComplete" |
|
|
|
@drawing-points-update="missionDrawingPointsCount = $event" |
|
|
|
@open-waypoint-dialog="handleOpenWaypointEdit" |
|
|
|
@open-route-dialog="handleOpenRouteEdit" |
|
|
|
@scale-click="handleScaleClick" /> |
|
|
|
@open-route-dialog="handleOpenRouteEdit" /> |
|
|
|
<div class="map-overlay-text"> |
|
|
|
<i class="el-icon-location-outline text-3xl mb-2 block"></i> |
|
|
|
<p>二维GIS地图区域</p> |
|
|
|
<p class="text-sm mt-1">支持标绘/航线/空域/实时态势</p> |
|
|
|
</div> |
|
|
|
<div v-if="missionDrawingActive && missionDrawingPointsCount >= 2" class="mission-drawing-actions" style="position:absolute; bottom:16px; left:50%; transform:translateX(-50%); z-index:10; display:flex; gap:8px; align-items:center;"> |
|
|
|
<span class="text-white text-sm">已 {{ missionDrawingPointsCount }} 个航点,右键结束</span> |
|
|
|
<el-button type="primary" size="small" @click="openAddHoldDuringDrawing">插入盘旋</el-button> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 地图中间的浮动红点(触发左侧菜单) --> |
|
|
|
<div |
|
|
|
@ -155,6 +160,7 @@ |
|
|
|
@hide="hideRightPanel" |
|
|
|
@select-route="selectRoute" |
|
|
|
@create-route="createRoute" |
|
|
|
@create-route-with-hold="openAddHoldThenCreateRoute" |
|
|
|
@delete-route="handleDeleteRoute" |
|
|
|
@select-plan="selectPlan" |
|
|
|
@create-plan="createPlan" |
|
|
|
@ -226,13 +232,56 @@ |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div v-if="deductionWarnings.length > 0" class="deduction-warnings"> |
|
|
|
<div v-if="deductionWarnings.length > 0 || hasEarlyArrivalLegs" class="deduction-warnings"> |
|
|
|
<i class="el-icon-warning-outline"></i> |
|
|
|
<span>{{ deductionWarnings[0] }}</span> |
|
|
|
<span>{{ deductionWarnings[0] || '存在航段将提前到达下一航点。' }}</span> |
|
|
|
<el-tooltip v-if="deductionWarnings.length > 1" :content="deductionWarnings.join(';')" placement="top"> |
|
|
|
<span class="warnings-more">等 {{ deductionWarnings.length }} 条</span> |
|
|
|
</el-tooltip> |
|
|
|
<el-button v-if="hasEarlyArrivalLegs" type="text" size="mini" @click="openAddHoldFromFirstEarly">在此添加盘旋</el-button> |
|
|
|
</div> |
|
|
|
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body> |
|
|
|
<div v-if="addHoldContext" class="add-hold-tip">{{ addHoldDialogTip }}</div> |
|
|
|
<el-form :model="addHoldForm" label-width="100px" size="small"> |
|
|
|
<el-form-item label="盘旋类型"> |
|
|
|
<el-radio-group v-model="addHoldForm.holdType"> |
|
|
|
<el-radio label="hold_circle">圆形</el-radio> |
|
|
|
<el-radio label="hold_ellipse">椭圆</el-radio> |
|
|
|
</el-radio-group> |
|
|
|
</el-form-item> |
|
|
|
<el-form-item v-if="addHoldForm.holdType === 'hold_circle'" label="半径(米)"> |
|
|
|
<el-input-number v-model="addHoldForm.radius" :min="100" :max="50000" style="width:100%" /> |
|
|
|
</el-form-item> |
|
|
|
<template v-if="addHoldForm.holdType === 'hold_ellipse'"> |
|
|
|
<el-form-item label="长半轴(米)"> |
|
|
|
<el-input-number v-model="addHoldForm.semiMajor" :min="100" :max="50000" style="width:100%" /> |
|
|
|
</el-form-item> |
|
|
|
<el-form-item label="短半轴(米)"> |
|
|
|
<el-input-number v-model="addHoldForm.semiMinor" :min="50" :max="50000" style="width:100%" /> |
|
|
|
</el-form-item> |
|
|
|
<el-form-item label="长轴方位(度)"> |
|
|
|
<el-input-number v-model="addHoldForm.headingDeg" :min="-180" :max="180" style="width:100%" /> |
|
|
|
</el-form-item> |
|
|
|
</template> |
|
|
|
<el-form-item label="盘旋方向"> |
|
|
|
<el-radio-group v-model="addHoldForm.clockwise"> |
|
|
|
<el-radio :label="true">顺时针</el-radio> |
|
|
|
<el-radio :label="false">逆时针</el-radio> |
|
|
|
</el-radio-group> |
|
|
|
</el-form-item> |
|
|
|
<el-form-item label="计划离开(K时)"> |
|
|
|
<el-input |
|
|
|
v-model.number="addHoldForm.startTimeMinutes" |
|
|
|
type="number" |
|
|
|
placeholder="正数=K+多少分钟,负数=K-多少分钟,留空用下一航点时间" |
|
|
|
/> |
|
|
|
</el-form-item> |
|
|
|
</el-form> |
|
|
|
<span slot="footer" class="dialog-footer"> |
|
|
|
<el-button @click="showAddHoldDialog = false">取消</el-button> |
|
|
|
<el-button type="primary" @click="saveAddHold">确定添加</el-button> |
|
|
|
</span> |
|
|
|
</el-dialog> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 在线成员弹窗 --> |
|
|
|
@ -333,7 +382,7 @@ import BottomLeftPanel from './BottomLeftPanel' |
|
|
|
import TopHeader from './TopHeader' |
|
|
|
import { listScenario,addScenario,delScenario} from "@/api/system/scenario"; |
|
|
|
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes } from "@/api/system/routes"; |
|
|
|
import { updateWaypoints } from "@/api/system/waypoints"; |
|
|
|
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints"; |
|
|
|
import { listLib,addLib,delLib} from "@/api/system/lib"; |
|
|
|
import { getRooms, updateRooms } from "@/api/system/rooms"; |
|
|
|
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig"; |
|
|
|
@ -518,6 +567,12 @@ export default { |
|
|
|
currentTime: 'K+00:00:00', |
|
|
|
deductionMinutesFromK: 0, |
|
|
|
deductionWarnings: [], |
|
|
|
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs |
|
|
|
showAddHoldDialog: false, |
|
|
|
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName } |
|
|
|
addHoldForm: { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null }, |
|
|
|
missionDrawingActive: false, |
|
|
|
missionDrawingPointsCount: 0, |
|
|
|
isPlaying: false, |
|
|
|
playbackSpeed: 1, |
|
|
|
playbackInterval: null, |
|
|
|
@ -569,6 +624,21 @@ export default { |
|
|
|
kTimeDisplay() { |
|
|
|
if (!this.roomDetail || !this.roomDetail.kAnchorTime) return ''; |
|
|
|
return this.formatKTimeForPicker(this.roomDetail.kAnchorTime) || ''; |
|
|
|
}, |
|
|
|
hasEarlyArrivalLegs() { |
|
|
|
return this.activeRouteIds.some(rid => (this.deductionEarlyArrivalByRoute[rid] || []).length > 0); |
|
|
|
}, |
|
|
|
addHoldDialogTitle() { |
|
|
|
if (!this.addHoldContext) return '添加盘旋'; |
|
|
|
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋'; |
|
|
|
if (this.addHoldContext.mode === 'drawing_first') return '先画盘旋再画航线'; |
|
|
|
return '在此航段添加盘旋'; |
|
|
|
}, |
|
|
|
addHoldDialogTip() { |
|
|
|
if (!this.addHoldContext) return ''; |
|
|
|
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。'; |
|
|
|
if (this.addHoldContext.mode === 'drawing_first') return '设置盘旋参数后,将进入航线绘制:第一个点击为盘旋中心,第二个点击为下一航点。'; |
|
|
|
return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`; |
|
|
|
} |
|
|
|
}, |
|
|
|
mounted() { |
|
|
|
@ -835,6 +905,8 @@ export default { |
|
|
|
if (route && route.waypoints && route.waypoints.length > 0) { |
|
|
|
this.$refs.cesiumMap.removeRouteById(updatedRoute.id); |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, updatedRoute.id, route.platformId, route.platform, routeStyle); |
|
|
|
// 切换平台后按当前推演时间把图标更新到对应位置,避免图标回到起点 |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
@ -856,6 +928,8 @@ export default { |
|
|
|
} |
|
|
|
// 进入 Cesium 绘图模式 |
|
|
|
if (this.$refs.cesiumMap) { |
|
|
|
this.missionDrawingActive = true; |
|
|
|
this.missionDrawingPointsCount = 0; |
|
|
|
this.$refs.cesiumMap.startMissionRouteDrawing(); |
|
|
|
this.$message.success(`${plan.name}开启航线规划`); |
|
|
|
} |
|
|
|
@ -944,6 +1018,7 @@ export default { |
|
|
|
} |
|
|
|
}, |
|
|
|
handleMapDrawComplete(points) { |
|
|
|
this.missionDrawingActive = false; |
|
|
|
if (!points || points.length < 2) { |
|
|
|
this.$message.error('航点太少,无法生成航线'); |
|
|
|
this.drawDom = false; |
|
|
|
@ -952,6 +1027,19 @@ export default { |
|
|
|
this.tempMapPoints = points; // 暂存坐标点 |
|
|
|
this.showNameDialog = true; // 弹出对话框起名 |
|
|
|
}, |
|
|
|
|
|
|
|
openAddHoldDuringDrawing() { |
|
|
|
this.addHoldContext = { mode: 'drawing' }; |
|
|
|
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; |
|
|
|
this.showAddHoldDialog = true; |
|
|
|
}, |
|
|
|
|
|
|
|
openAddHoldThenCreateRoute(plan) { |
|
|
|
if (!plan || !plan.id) return; |
|
|
|
this.addHoldContext = { mode: 'drawing_first', plan }; |
|
|
|
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; |
|
|
|
this.showAddHoldDialog = true; |
|
|
|
}, |
|
|
|
/** 弹窗点击“确定”:正式将数据保存到后端数据库 */ |
|
|
|
async confirmSaveNewRoute() { |
|
|
|
// 1. 严格校验 |
|
|
|
@ -965,15 +1053,17 @@ export default { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 2. 构造数据 |
|
|
|
// 2. 构造数据(含盘旋航点的 pointType、holdParams) |
|
|
|
const finalWaypoints = this.tempMapPoints.map((p, index) => ({ |
|
|
|
name: `WP${index + 1}`, |
|
|
|
name: p.name || `WP${index + 1}`, |
|
|
|
lat: p.lat, |
|
|
|
lng: p.lng, |
|
|
|
alt: 5000.0, |
|
|
|
speed: 800.0, |
|
|
|
startTime: 'K+00:00:00', |
|
|
|
turnAngle: 0.0 |
|
|
|
alt: p.alt != null ? p.alt : 5000.0, |
|
|
|
speed: p.speed != null ? p.speed : 800.0, |
|
|
|
startTime: p.startTime || 'K+00:00:00', |
|
|
|
turnAngle: p.turnAngle != null ? p.turnAngle : 0.0, |
|
|
|
...(p.pointType && { pointType: p.pointType }), |
|
|
|
...(p.holdParams != null && { holdParams: typeof p.holdParams === 'string' ? p.holdParams : JSON.stringify(p.holdParams) }) |
|
|
|
})); |
|
|
|
|
|
|
|
const routeData = { |
|
|
|
@ -1068,26 +1158,33 @@ export default { |
|
|
|
: 'K+00:00:00', |
|
|
|
turnAngle: updatedWaypoint.turnAngle |
|
|
|
}; |
|
|
|
if (updatedWaypoint.pointType != null) payload.pointType = updatedWaypoint.pointType; |
|
|
|
if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams; |
|
|
|
const response = await updateWaypoints(payload); |
|
|
|
if (response.code === 200) { |
|
|
|
const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id); |
|
|
|
if (index !== -1) { |
|
|
|
// 更新本地数据(用已提交的 payload 保证 startTime 等与数据库一致) |
|
|
|
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload }); |
|
|
|
// 通知地图组件同步更新 |
|
|
|
const merged = { ...updatedWaypoint, ...payload }; |
|
|
|
const routeInList = this.routes.find(r => r.id === this.selectedRouteDetails.id); |
|
|
|
if (routeInList && routeInList.waypoints) { |
|
|
|
const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id); |
|
|
|
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); |
|
|
|
} |
|
|
|
if (this.$refs.cesiumMap) { |
|
|
|
// 更新航点图标 |
|
|
|
this.$refs.cesiumMap.updateWaypointGraphicById(updatedWaypoint.id, updatedWaypoint.name); |
|
|
|
// 重新触发航线渲染 |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints( |
|
|
|
this.selectedRouteDetails.waypoints, |
|
|
|
this.selectedRouteDetails.id, |
|
|
|
this.selectedRouteDetails.platformId, |
|
|
|
this.selectedRouteDetails.platform |
|
|
|
this.selectedRouteDetails.platform, |
|
|
|
this.parseRouteStyle(this.selectedRouteDetails.attributes) |
|
|
|
); |
|
|
|
} |
|
|
|
this.showWaypointDialog = false; |
|
|
|
this.$message.success('航点信息已持久化至数据库'); |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
} |
|
|
|
} else { |
|
|
|
// 如果 code 不是 200,手动抛出错误进入catch |
|
|
|
@ -1114,7 +1211,7 @@ export default { |
|
|
|
return this.activeRouteIds && this.activeRouteIds.length > 0; |
|
|
|
}, |
|
|
|
updateCombatTime() { |
|
|
|
// 有推演航线时:作战时间 = 推演时间轴当前时间(由 updateTimeFromProgress 同步) |
|
|
|
// 作战时间始终与推演时间轴同步:有航线时用 deductionMinutesFromK,无航线时用 currentTime(由时间轴 updateTimeFromProgress 计算) |
|
|
|
if (this.hasDeductionRange()) { |
|
|
|
const sign = this.deductionMinutesFromK >= 0 ? '+' : '-'; |
|
|
|
const absMin = Math.abs(Math.floor(this.deductionMinutesFromK)); |
|
|
|
@ -1123,8 +1220,8 @@ export default { |
|
|
|
this.combatTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; |
|
|
|
return; |
|
|
|
} |
|
|
|
// 无推演航线时:保持进入房间时的固定作战时间,不随真实时间变化 |
|
|
|
this.combatTime = 'K+00:00:00'; |
|
|
|
// 无展示航线时:作战时间仍随推演时间轴变化,用 currentTime |
|
|
|
this.combatTime = this.currentTime || 'K+00:00:00'; |
|
|
|
}, |
|
|
|
/** 将作战时间字符串(如 K+01:30:00)解析为相对 K 的分钟数 */ |
|
|
|
combatTimeToMinutes(str) { |
|
|
|
@ -1737,10 +1834,8 @@ export default { |
|
|
|
const hours = Math.floor(absMin / 60); |
|
|
|
const minutes = absMin % 60; |
|
|
|
this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; |
|
|
|
// 右上角作战时间与推演时间轴同步 |
|
|
|
if (this.hasDeductionRange()) { |
|
|
|
// 右上角作战时间随时与推演时间轴同步(无论是否展示航线) |
|
|
|
this.combatTime = this.currentTime; |
|
|
|
} |
|
|
|
this.updateDeductionPositions(); |
|
|
|
}, |
|
|
|
|
|
|
|
@ -1775,6 +1870,62 @@ export default { |
|
|
|
const min = parseInt(m[3], 10); |
|
|
|
return sign * (h * 60 + min); |
|
|
|
}, |
|
|
|
/** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */ |
|
|
|
minutesToStartTime(minutes) { |
|
|
|
const m = Math.floor(Number(minutes)); |
|
|
|
if (!Number.isFinite(m)) return 'K+00:00'; |
|
|
|
const sign = m >= 0 ? '+' : '-'; |
|
|
|
const abs = Math.abs(m); |
|
|
|
const h = Math.floor(abs / 60); |
|
|
|
const min = abs % 60; |
|
|
|
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; |
|
|
|
}, |
|
|
|
|
|
|
|
isHoldWaypoint(wp) { |
|
|
|
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal'; |
|
|
|
return t === 'hold_circle' || t === 'hold_ellipse'; |
|
|
|
}, |
|
|
|
parseHoldParams(wp) { |
|
|
|
const raw = (wp && wp.holdParams) || (wp && wp.hold_params); |
|
|
|
if (!raw) return null; |
|
|
|
try { |
|
|
|
const p = typeof raw === 'string' ? JSON.parse(raw) : raw; |
|
|
|
return { radius: p.radius, semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, clockwise: p.clockwise !== false }; |
|
|
|
} catch (e) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
/** 路径片段总距离(米) */ |
|
|
|
pathSliceDistance(pathSlice) { |
|
|
|
if (!pathSlice || pathSlice.length < 2) return 0; |
|
|
|
let d = 0; |
|
|
|
for (let i = 1; i < pathSlice.length; i++) d += this.segmentDistance(pathSlice[i - 1], pathSlice[i]); |
|
|
|
return d; |
|
|
|
}, |
|
|
|
|
|
|
|
/** 圆周上按角度取点:圆心 lng/lat/alt,半径米。angleRad 为从北顺时针的角度弧度,0=北 */ |
|
|
|
positionOnCircle(centerLng, centerLat, centerAlt, radiusM, angleRad) { |
|
|
|
const R = 6371000; |
|
|
|
const latRad = (centerLat * Math.PI) / 180; |
|
|
|
const dNorth = radiusM * Math.cos(angleRad); |
|
|
|
const dEast = radiusM * Math.sin(angleRad); |
|
|
|
const dLat = (dNorth / R) * (180 / Math.PI); |
|
|
|
const dLng = (dEast / (R * Math.cos(latRad))) * (180 / Math.PI); |
|
|
|
return { |
|
|
|
lng: centerLng + dLng, |
|
|
|
lat: centerLat + dLat, |
|
|
|
alt: centerAlt != null ? centerAlt : 0 |
|
|
|
}; |
|
|
|
}, |
|
|
|
/** 从圆心到某点的角度(弧度,从北顺时针),用于盘旋入口基准角 */ |
|
|
|
angleFromCenterToPoint(centerLng, centerLat, pointLng, pointLat) { |
|
|
|
const R = 6371000; |
|
|
|
const latRad = (centerLat * Math.PI) / 180; |
|
|
|
const dNorth = (pointLat - centerLat) * (R * Math.PI / 180); |
|
|
|
const dEast = (pointLng - centerLng) * (R * Math.cos(latRad) * Math.PI / 180); |
|
|
|
return Math.atan2(dEast, dNorth); |
|
|
|
}, |
|
|
|
|
|
|
|
/** 两航点间近似距离(米),含高度差 */ |
|
|
|
segmentDistance(wp1, wp2) { |
|
|
|
@ -1791,26 +1942,28 @@ export default { |
|
|
|
}, |
|
|
|
|
|
|
|
/** |
|
|
|
* 按速度与计划时间构建航线时间轴:含飞行段与“提前到达则等待”的等待段,并做航段校验。 |
|
|
|
* 若所有航点相对 K 时相同,则在 [globalMin, globalMax] 内按航点顺序均匀分布,避免闪现。 |
|
|
|
* 按速度与计划时间构建航线时间轴:含飞行段、盘旋段与“提前到达则等待”的等待段。 |
|
|
|
* pathData 可选:{ path, segmentEndIndices, holdArcRanges },由 getRoutePathWithSegmentIndices 提供,用于输出 hold 段。 |
|
|
|
*/ |
|
|
|
buildRouteTimeline(waypoints, globalMin, globalMax) { |
|
|
|
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) { |
|
|
|
const warnings = []; |
|
|
|
if (!waypoints || waypoints.length === 0) return { segments: [], warnings }; |
|
|
|
const points = waypoints.map(wp => ({ |
|
|
|
const points = waypoints.map((wp, idx) => ({ |
|
|
|
lng: parseFloat(wp.lng), |
|
|
|
lat: parseFloat(wp.lat), |
|
|
|
alt: Number(wp.alt) || 0, |
|
|
|
minutes: this.waypointStartTimeToMinutes(wp.startTime), |
|
|
|
speed: Number(wp.speed) || 800 |
|
|
|
speed: Number(wp.speed) || 800, |
|
|
|
isHold: this.isHoldWaypoint(wp) |
|
|
|
})); |
|
|
|
const hasHold = points.some(p => p.isHold); |
|
|
|
const allSame = points.every(p => p.minutes === points[0].minutes); |
|
|
|
if (allSame && points.length > 1) { |
|
|
|
if (allSame && points.length > 1 && !hasHold) { |
|
|
|
const span = Math.max(globalMax - globalMin, 1); |
|
|
|
points.forEach((p, i) => { |
|
|
|
p.minutes = globalMin + (span * i) / (points.length - 1); |
|
|
|
}); |
|
|
|
} else { |
|
|
|
} else if (!hasHold) { |
|
|
|
points.sort((a, b) => a.minutes - b.minutes); |
|
|
|
} |
|
|
|
if (points.length === 1) { |
|
|
|
@ -1823,7 +1976,61 @@ export default { |
|
|
|
} |
|
|
|
const effectiveTime = [points[0].minutes]; |
|
|
|
const segments = []; |
|
|
|
const path = pathData && pathData.path; |
|
|
|
const segmentEndIndices = pathData && pathData.segmentEndIndices; |
|
|
|
const holdArcRanges = pathData && pathData.holdArcRanges || {}; |
|
|
|
for (let i = 0; i < points.length - 1; i++) { |
|
|
|
if (this.isHoldWaypoint(waypoints[i + 1]) && path && segmentEndIndices && holdArcRanges[i]) { |
|
|
|
const range = holdArcRanges[i]; |
|
|
|
const startIdx = i === 0 ? 0 : segmentEndIndices[i - 1] + 1; |
|
|
|
const toEntrySlice = path.slice(startIdx, range.start + 1); |
|
|
|
const holdPathSlice = path.slice(range.start, range.end + 1); |
|
|
|
const exitIdx = segmentEndIndices[i]; |
|
|
|
const toNextSlice = path.slice(exitIdx, (segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1) + 1); |
|
|
|
const distToEntry = this.pathSliceDistance(toEntrySlice); |
|
|
|
const speedKmh = points[i].speed || 800; |
|
|
|
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh); |
|
|
|
const arrivalEntry = effectiveTime[i] + travelToEntryMin; |
|
|
|
const holdEndTime = points[i + 1].minutes; |
|
|
|
const distExitToNext = this.pathSliceDistance(toNextSlice); |
|
|
|
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh); |
|
|
|
const arrivalNext = holdEndTime + travelExitMin; |
|
|
|
effectiveTime[i + 1] = holdEndTime; |
|
|
|
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext; |
|
|
|
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }; |
|
|
|
const entryPos = toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : posCur; |
|
|
|
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : entryPos; |
|
|
|
const holdDurationMin = holdEndTime - arrivalEntry; |
|
|
|
const holdWp = waypoints[i + 1]; |
|
|
|
const holdParams = this.parseHoldParams(holdWp); |
|
|
|
const holdCenter = holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null; |
|
|
|
const holdRadius = holdParams && holdParams.radius != null ? holdParams.radius : null; |
|
|
|
const holdClockwise = holdParams && holdParams.clockwise !== false; |
|
|
|
const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null; |
|
|
|
const holdEntryAngle = holdCenter && entryPos && holdRadius != null |
|
|
|
? this.angleFromCenterToPoint(holdCenter.lng, holdCenter.lat, entryPos.lng, entryPos.lat) |
|
|
|
: null; |
|
|
|
segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice }); |
|
|
|
segments.push({ |
|
|
|
startTime: arrivalEntry, |
|
|
|
endTime: holdEndTime, |
|
|
|
startPos: entryPos, |
|
|
|
endPos: exitPos, |
|
|
|
type: 'hold', |
|
|
|
legIndex: i, |
|
|
|
holdPath: holdPathSlice, |
|
|
|
holdDurationMin, |
|
|
|
speedKmh: points[i].speed || 800, |
|
|
|
holdCenter, |
|
|
|
holdRadius, |
|
|
|
holdCircumference, |
|
|
|
holdClockwise, |
|
|
|
holdEntryAngle |
|
|
|
}); |
|
|
|
segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); |
|
|
|
i++; |
|
|
|
continue; |
|
|
|
} |
|
|
|
const dist = this.segmentDistance(points[i], points[i + 1]); |
|
|
|
const speedKmh = points[i].speed || 800; |
|
|
|
const travelMin = (dist / 1000) * (60 / speedKmh); |
|
|
|
@ -1847,7 +2054,19 @@ export default { |
|
|
|
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i }); |
|
|
|
} |
|
|
|
} |
|
|
|
return { segments, warnings }; |
|
|
|
const earlyArrivalLegs = []; |
|
|
|
for (let i = 0; i < points.length - 1; i++) { |
|
|
|
if (this.isHoldWaypoint(waypoints[i + 1])) continue; |
|
|
|
const dist = this.segmentDistance(points[i], points[i + 1]); |
|
|
|
const speedKmh = points[i].speed || 800; |
|
|
|
const travelMin = (dist / 1000) * (60 / speedKmh); |
|
|
|
const actualArrival = effectiveTime[i] + travelMin; |
|
|
|
const scheduled = points[i + 1].minutes; |
|
|
|
if (travelMin > 0 && scheduled - points[i].minutes > 0 && actualArrival < scheduled - 0.5) { |
|
|
|
earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name }); |
|
|
|
} |
|
|
|
} |
|
|
|
return { segments, warnings, earlyArrivalLegs }; |
|
|
|
}, |
|
|
|
|
|
|
|
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */ |
|
|
|
@ -1875,31 +2094,52 @@ export default { |
|
|
|
}; |
|
|
|
}, |
|
|
|
|
|
|
|
/** 从时间轴中取当前推演时间对应的位置;若有 path/segmentEndIndices 则沿带转弯弧的路径插值 */ |
|
|
|
/** 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold,hold 沿 holdPath 弧长插值 */ |
|
|
|
getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) { |
|
|
|
if (!segments || segments.length === 0) return null; |
|
|
|
if (minutesFromK <= segments[0].startTime) return segments[0].startPos; |
|
|
|
const last = segments[segments.length - 1]; |
|
|
|
if (minutesFromK >= last.endTime) { |
|
|
|
// 若最后一段是等待且有弧线路径,终点取弧线终点 |
|
|
|
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) { |
|
|
|
return path[segmentEndIndices[last.legIndex]]; |
|
|
|
} |
|
|
|
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1]; |
|
|
|
return last.endPos; |
|
|
|
} |
|
|
|
for (let i = 0; i < segments.length; i++) { |
|
|
|
const s = segments[i]; |
|
|
|
if (minutesFromK < s.endTime) { |
|
|
|
const t = (minutesFromK - s.startTime) / (s.endTime - s.startTime); |
|
|
|
const t = Math.max(0, Math.min(1, (minutesFromK - s.startTime) / (s.endTime - s.startTime))); |
|
|
|
if (s.type === 'wait') { |
|
|
|
// 有弧线路径时在弧线终点等待,不跳到航点 |
|
|
|
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|
|
|
const endIdx = segmentEndIndices[s.legIndex]; |
|
|
|
if (path[endIdx]) return path[endIdx]; |
|
|
|
} |
|
|
|
return s.startPos; |
|
|
|
} |
|
|
|
// 有带弧路径且当前为飞行段且存在对应航段索引时,沿路径弧线插值(含上一段终点,保证从弧线终点匀速运动到本段终点、不闪现) |
|
|
|
if (s.type === 'hold' && s.holdPath && s.holdPath.length) { |
|
|
|
const durationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); |
|
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : 800; |
|
|
|
const totalHoldDistM = (speedKmh * (durationMin / 60)) * 1000; |
|
|
|
if (s.holdCircumference != null && s.holdCircumference > 0 && s.holdCenter && s.holdRadius != null) { |
|
|
|
const currentDistM = t * totalHoldDistM; |
|
|
|
const distOnLap = currentDistM % s.holdCircumference; |
|
|
|
const angleRad = (distOnLap / s.holdCircumference) * (2 * Math.PI); |
|
|
|
const signedAngle = s.holdClockwise ? -angleRad : angleRad; |
|
|
|
const entryAngle = s.holdEntryAngle != null ? s.holdEntryAngle : 0; |
|
|
|
const angle = entryAngle + signedAngle; |
|
|
|
return this.positionOnCircle(s.holdCenter.lng, s.holdCenter.lat, s.holdCenter.alt, s.holdRadius, angle); |
|
|
|
} |
|
|
|
const holdPathLen = this.pathSliceDistance(s.holdPath); |
|
|
|
if (holdPathLen <= 0) return this.getPositionAlongPathSlice(s.holdPath, t); |
|
|
|
const currentDistM = t * totalHoldDistM; |
|
|
|
const positionOnLap = currentDistM % holdPathLen; |
|
|
|
const tLap = holdPathLen > 0 ? positionOnLap / holdPathLen : 0; |
|
|
|
return this.getPositionAlongPathSlice(s.holdPath, tLap); |
|
|
|
} |
|
|
|
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) { |
|
|
|
return this.getPositionAlongPathSlice(s.pathSlice, t); |
|
|
|
} |
|
|
|
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|
|
|
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1]; |
|
|
|
const endIdx = segmentEndIndices[s.legIndex]; |
|
|
|
@ -1916,24 +2156,24 @@ export default { |
|
|
|
return last.endPos; |
|
|
|
}, |
|
|
|
|
|
|
|
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧路径运动;返回 { position, nextPosition, previousPosition, warnings },用于计算机头朝向 */ |
|
|
|
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings } */ |
|
|
|
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) { |
|
|
|
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [] }; |
|
|
|
const { segments, warnings } = this.buildRouteTimeline(waypoints, globalMin, globalMax); |
|
|
|
let path = null; |
|
|
|
let segmentEndIndices = null; |
|
|
|
let pathData = null; |
|
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) { |
|
|
|
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints); |
|
|
|
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) { |
|
|
|
path = ret.path; |
|
|
|
segmentEndIndices = ret.segmentEndIndices; |
|
|
|
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; |
|
|
|
} |
|
|
|
} |
|
|
|
const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); |
|
|
|
const path = pathData ? pathData.path : null; |
|
|
|
const segmentEndIndices = pathData ? pathData.segmentEndIndices : null; |
|
|
|
const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices); |
|
|
|
const stepMin = 1 / 60; |
|
|
|
const nextPosition = this.getPositionFromTimeline(segments, minutesFromK + stepMin, path, segmentEndIndices); |
|
|
|
const previousPosition = this.getPositionFromTimeline(segments, minutesFromK - stepMin, path, segmentEndIndices); |
|
|
|
return { position, nextPosition, previousPosition, warnings }; |
|
|
|
return { position, nextPosition, previousPosition, warnings, earlyArrivalLegs: earlyArrivalLegs || [] }; |
|
|
|
}, |
|
|
|
|
|
|
|
/** 仅根据当前展示的航线(activeRouteIds)更新平台图标位置,并汇总航段提示 */ |
|
|
|
@ -1945,13 +2185,120 @@ export default { |
|
|
|
this.activeRouteIds.forEach(routeId => { |
|
|
|
const route = this.routes.find(r => r.id === routeId); |
|
|
|
if (!route || !route.waypoints || route.waypoints.length === 0) return; |
|
|
|
const { position, nextPosition, previousPosition, warnings } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); |
|
|
|
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); |
|
|
|
if (warnings && warnings.length) allWarnings.push(...warnings); |
|
|
|
if (position) this.$refs.cesiumMap.updatePlatformPosition(routeId, position, nextPosition || previousPosition); |
|
|
|
this.deductionEarlyArrivalByRoute[routeId] = earlyArrivalLegs || []; |
|
|
|
}); |
|
|
|
this.deductionWarnings = [...new Set(allWarnings)]; |
|
|
|
}, |
|
|
|
|
|
|
|
openAddHoldFromFirstEarly() { |
|
|
|
for (const routeId of this.activeRouteIds) { |
|
|
|
const legs = this.deductionEarlyArrivalByRoute[routeId] || []; |
|
|
|
if (legs.length === 0) continue; |
|
|
|
const route = this.routes.find(r => r.id === routeId); |
|
|
|
const leg = legs[0]; |
|
|
|
const waypoints = route && route.waypoints ? route.waypoints : []; |
|
|
|
const nextWp = waypoints[leg.legIndex + 1]; |
|
|
|
this.addHoldContext = { |
|
|
|
routeId, |
|
|
|
routeName: route ? route.name : '', |
|
|
|
legIndex: leg.legIndex, |
|
|
|
fromName: leg.fromName, |
|
|
|
toName: leg.toName |
|
|
|
}; |
|
|
|
const defaultStart = nextWp && nextWp.startTime ? nextWp.startTime : 'K+01:00'; |
|
|
|
this.addHoldForm.startTime = defaultStart; |
|
|
|
this.addHoldForm.startTimeMinutes = this.waypointStartTimeToMinutes(defaultStart); |
|
|
|
this.showAddHoldDialog = true; |
|
|
|
return; |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
async saveAddHold() { |
|
|
|
if (!this.addHoldContext) return; |
|
|
|
if (this.addHoldContext.mode === 'drawing') { |
|
|
|
const holdParams = this.addHoldForm.holdType === 'hold_circle' |
|
|
|
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } |
|
|
|
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; |
|
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) { |
|
|
|
this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams); |
|
|
|
} |
|
|
|
this.showAddHoldDialog = false; |
|
|
|
this.addHoldContext = null; |
|
|
|
this.$message.success('已插入盘旋,继续点选航点后右键结束保存'); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (this.addHoldContext.mode === 'drawing_first') { |
|
|
|
const holdParams = this.addHoldForm.holdType === 'hold_circle' |
|
|
|
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } |
|
|
|
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; |
|
|
|
this.showAddHoldDialog = false; |
|
|
|
const plan = this.addHoldContext.plan; |
|
|
|
this.addHoldContext = null; |
|
|
|
this.createRoute(plan); |
|
|
|
this.$nextTick(() => { |
|
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.setInitialHoldParams) { |
|
|
|
this.$refs.cesiumMap.setInitialHoldParams(holdParams); |
|
|
|
} |
|
|
|
}); |
|
|
|
this.$message.success('请先点击盘旋中心,再点击下一航点,然后继续绘制或右键结束'); |
|
|
|
return; |
|
|
|
} |
|
|
|
const { routeId, legIndex } = this.addHoldContext; |
|
|
|
const route = this.routes.find(r => r.id === routeId); |
|
|
|
if (!route || !route.waypoints || route.waypoints.length < 2) { |
|
|
|
this.$message.warning('航线数据异常'); |
|
|
|
return; |
|
|
|
} |
|
|
|
const waypoints = route.waypoints; |
|
|
|
const prevWp = waypoints[legIndex]; |
|
|
|
const nextWp = waypoints[legIndex + 1]; |
|
|
|
const newSeq = (prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1) + 1; |
|
|
|
const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1; |
|
|
|
const holdParams = this.addHoldForm.holdType === 'hold_circle' |
|
|
|
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } |
|
|
|
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; |
|
|
|
const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes)) |
|
|
|
? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes)) |
|
|
|
: (nextWp.startTime || 'K+01:00'); |
|
|
|
try { |
|
|
|
await addWaypoints({ |
|
|
|
routeId, |
|
|
|
name: 'HOLD', |
|
|
|
seq: newSeq, |
|
|
|
lat: nextWp.lat, |
|
|
|
lng: nextWp.lng, |
|
|
|
alt: nextWp.alt != null ? nextWp.alt : prevWp.alt, |
|
|
|
speed: prevWp.speed || 800, |
|
|
|
startTime, |
|
|
|
pointType: this.addHoldForm.holdType, |
|
|
|
holdParams: JSON.stringify(holdParams) |
|
|
|
}); |
|
|
|
await delWaypoints(nextWp.id); |
|
|
|
for (let i = legIndex + 2; i < waypoints.length; i++) { |
|
|
|
const w = waypoints[i]; |
|
|
|
if (w.id) { |
|
|
|
await updateWaypoints({ ...w, seq: baseSeq + (i - legIndex) }); |
|
|
|
} |
|
|
|
} |
|
|
|
this.showAddHoldDialog = false; |
|
|
|
this.addHoldContext = null; |
|
|
|
await this.getList(); |
|
|
|
const updated = this.routes.find(r => r.id === routeId); |
|
|
|
if (updated && updated.waypoints && this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) { |
|
|
|
this.$refs.cesiumMap.removeRouteById(routeId); |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes)); |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
} |
|
|
|
this.$message.success('已添加盘旋航点'); |
|
|
|
} catch (e) { |
|
|
|
this.$message.error(e.msg || '添加盘旋失败'); |
|
|
|
console.error(e); |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
// 时间控制(保留用于底部时间轴) |
|
|
|
play() { |
|
|
|
this.$message.success('推演开始'); |
|
|
|
|