|
|
|
@ -237,7 +237,19 @@ |
|
|
|
<i class="el-icon-time"></i> |
|
|
|
<span class="time-text">{{ currentTime }}</span> |
|
|
|
</div> |
|
|
|
<div class="timeline-slider"> |
|
|
|
<div |
|
|
|
ref="timelineSliderWrap" |
|
|
|
class="timeline-slider" |
|
|
|
@mousemove="onTimelineHover" |
|
|
|
@mouseleave="onTimelineLeave" |
|
|
|
> |
|
|
|
<div |
|
|
|
v-show="timelineHoverVisible" |
|
|
|
class="timeline-hover-time" |
|
|
|
:style="{ left: timelineHoverPercent + '%' }" |
|
|
|
> |
|
|
|
{{ timelineHoverTime }} |
|
|
|
</div> |
|
|
|
<el-slider |
|
|
|
v-model="timeProgress" |
|
|
|
:max="100" |
|
|
|
@ -253,25 +265,12 @@ |
|
|
|
> |
|
|
|
<i :class="isPlaying ? 'el-icon-video-pause' : 'el-icon-video-play'"></i> |
|
|
|
</button> |
|
|
|
<div class="speed-control"> |
|
|
|
<button |
|
|
|
class="control-btn blue-control-btn" |
|
|
|
@click="decreaseSpeed" |
|
|
|
:disabled="playbackSpeed <= 1" |
|
|
|
title="减速" |
|
|
|
> |
|
|
|
<i class="el-icon-arrow-down"></i> |
|
|
|
</button> |
|
|
|
<span class="speed-text">{{ playbackSpeed }}x</span> |
|
|
|
<button |
|
|
|
class="control-btn blue-control-btn" |
|
|
|
@click="increaseSpeed" |
|
|
|
:disabled="playbackSpeed >= 25" |
|
|
|
title="加速" |
|
|
|
> |
|
|
|
<i class="el-icon-arrow-up"></i> |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
<el-dropdown trigger="click" @command="setPlaybackSpeed" class="speed-control"> |
|
|
|
<span class="speed-text clickable">{{ playbackSpeed }}x<i class="el-icon-arrow-down el-icon--right"></i></span> |
|
|
|
<el-dropdown-menu slot="dropdown"> |
|
|
|
<el-dropdown-item v-for="s in speedOptions" :key="s" :command="s">{{ s }}x</el-dropdown-item> |
|
|
|
</el-dropdown-menu> |
|
|
|
</el-dropdown> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body> |
|
|
|
@ -287,14 +286,8 @@ |
|
|
|
<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 label="跑道边长(km)"> |
|
|
|
<el-input-number v-model="addHoldForm.edgeLengthKm" :min="1" :max="200" :step="1" style="width:100%" /> |
|
|
|
</el-form-item> |
|
|
|
</template> |
|
|
|
<el-form-item label="盘旋方向"> |
|
|
|
@ -723,12 +716,18 @@ export default { |
|
|
|
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs |
|
|
|
showAddHoldDialog: false, |
|
|
|
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName } |
|
|
|
addHoldForm: { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null }, |
|
|
|
addHoldForm: { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: null }, |
|
|
|
missionDrawingActive: false, |
|
|
|
missionDrawingPointsCount: 0, |
|
|
|
isPlaying: false, |
|
|
|
playbackSpeed: 1, |
|
|
|
speedOptions: [64, 32, 16, 8, 4, 2, 1], |
|
|
|
playbackInterval: null, |
|
|
|
/** 播放时上一帧时间戳(毫秒),用于按真实经过时间推进,避免 setInterval 不准导致时间轴变慢 */ |
|
|
|
_playbackLastTickTime: null, |
|
|
|
timelineHoverTime: '', |
|
|
|
timelineHoverVisible: false, |
|
|
|
timelineHoverPercent: 0, |
|
|
|
/** 导弹从 Redis 加载过的房间+航线组合 key,避免重复加载 */ |
|
|
|
_missilesLoadKey: null, |
|
|
|
|
|
|
|
@ -1193,14 +1192,15 @@ export default { |
|
|
|
let pointType; |
|
|
|
let holdParams; |
|
|
|
let turnAngle; |
|
|
|
const preserveTurnAngle = () => isFirstOrLast ? 0 : (wp.turnAngle != null && wp.turnAngle !== '' ? Number(wp.turnAngle) : 0); |
|
|
|
if (isHold) { |
|
|
|
pointType = 'normal'; |
|
|
|
holdParams = null; |
|
|
|
turnAngle = isFirstOrLast ? 0 : (Number(wp.turnAngle) || 45); |
|
|
|
turnAngle = preserveTurnAngle(); |
|
|
|
} else { |
|
|
|
pointType = 'hold_circle'; |
|
|
|
holdParams = JSON.stringify({ radius: 15000, clockwise: true }); |
|
|
|
turnAngle = 0; |
|
|
|
turnAngle = preserveTurnAngle(); |
|
|
|
} |
|
|
|
try { |
|
|
|
const payload = { |
|
|
|
@ -1247,6 +1247,10 @@ export default { |
|
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data); |
|
|
|
} catch (_) {} |
|
|
|
} |
|
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); |
|
|
|
if (r.waypoints.some(wp => this.isHoldWaypoint(wp))) { |
|
|
|
this.getPositionAtMinutesFromK(r.waypoints, minMinutes, minMinutes, maxMinutes, routeId); |
|
|
|
} |
|
|
|
this.$refs.cesiumMap.removeRouteById(routeId); |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes)); |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
@ -1337,6 +1341,10 @@ export default { |
|
|
|
if (wp.holdParams != null) payload.holdParams = wp.holdParams; |
|
|
|
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize; |
|
|
|
if (wp.labelColor != null) payload.labelColor = wp.labelColor; |
|
|
|
if (wp.segmentMode != null) payload.segmentMode = wp.segmentMode; |
|
|
|
if (wp.color != null) payload.color = wp.color; |
|
|
|
if (wp.pixelSize != null) payload.pixelSize = wp.pixelSize; |
|
|
|
if (wp.outlineColor != null) payload.outlineColor = wp.outlineColor; |
|
|
|
try { |
|
|
|
const response = await updateWaypoints(payload); |
|
|
|
if (response.code === 200) { |
|
|
|
@ -1347,7 +1355,96 @@ export default { |
|
|
|
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); |
|
|
|
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); |
|
|
|
} |
|
|
|
// 定速/定时:根据拖拽后的新位置重算相对K时或上一航点速度 |
|
|
|
const routeForPlatform = this.routes.find(r => r.id === routeId) || route; |
|
|
|
if (idx > 0) { |
|
|
|
const prev = waypoints[idx - 1]; |
|
|
|
const distM = this.segmentDistance( |
|
|
|
{ lat: prev.lat, lng: prev.lng, alt: prev.alt }, |
|
|
|
{ lat: merged.lat, lng: merged.lng, alt: merged.alt } |
|
|
|
); |
|
|
|
const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime); |
|
|
|
if (prev.segmentMode === 'fixed_speed') { |
|
|
|
const speedKmh = Number(prev.segmentTargetSpeed ?? prev.speed) || 800; |
|
|
|
const newMinutesFromK = prevMinutes + (distM / 1000) / speedKmh * 60; |
|
|
|
const newStartTime = this.minutesToStartTimeWithSeconds(newMinutesFromK); |
|
|
|
const startPayload = { ...merged, startTime: newStartTime }; |
|
|
|
if (merged.segmentMode != null) startPayload.segmentMode = merged.segmentMode; |
|
|
|
try { |
|
|
|
const r2 = await updateWaypoints(startPayload); |
|
|
|
if (r2.code === 200) { |
|
|
|
Object.assign(merged, { startTime: newStartTime }); |
|
|
|
if (idx !== -1) waypoints.splice(idx, 1, merged); |
|
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { |
|
|
|
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); |
|
|
|
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('定速重算相对K时失败', e); |
|
|
|
} |
|
|
|
} else if (merged.segmentMode === 'fixed_time') { |
|
|
|
const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime); |
|
|
|
const deltaMin = currMinutes - prevMinutes; |
|
|
|
if (deltaMin > 0.001) { |
|
|
|
const newSpeedKmh = (distM / 1000) / (deltaMin / 60); |
|
|
|
const speedVal = Math.round(newSpeedKmh * 10) / 10; |
|
|
|
const speedPayload = { ...prev, speed: speedVal }; |
|
|
|
if (prev.segmentMode != null) speedPayload.segmentMode = prev.segmentMode; |
|
|
|
try { |
|
|
|
const r2 = await updateWaypoints(speedPayload); |
|
|
|
if (r2.code === 200) { |
|
|
|
Object.assign(prev, { speed: speedVal }); |
|
|
|
const prevIdx = idx - 1; |
|
|
|
waypoints.splice(prevIdx, 1, prev); |
|
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { |
|
|
|
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === prev.id); |
|
|
|
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, prev); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('定时重算上一航点速度失败', e); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
// 下一航点是定时点时:重算本航点(被拖动的)速度,使平台能在下一航点的 K 时到达 |
|
|
|
if (idx >= 0 && idx < waypoints.length - 1) { |
|
|
|
const next = waypoints[idx + 1]; |
|
|
|
if (next.segmentMode === 'fixed_time') { |
|
|
|
const distToNextM = this.segmentDistance( |
|
|
|
{ lat: merged.lat, lng: merged.lng, alt: merged.alt }, |
|
|
|
{ lat: next.lat, lng: next.lng, alt: next.alt } |
|
|
|
); |
|
|
|
const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime); |
|
|
|
const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime); |
|
|
|
const deltaMin = nextMinutes - currMinutes; |
|
|
|
if (deltaMin > 0.001) { |
|
|
|
const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60); |
|
|
|
const speedVal = Math.round(newSpeedKmh * 10) / 10; |
|
|
|
const currPayload = { ...merged, speed: speedVal }; |
|
|
|
if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode; |
|
|
|
if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize; |
|
|
|
if (merged.labelColor != null) currPayload.labelColor = merged.labelColor; |
|
|
|
if (merged.color != null) currPayload.color = merged.color; |
|
|
|
if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize; |
|
|
|
if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor; |
|
|
|
try { |
|
|
|
const r2 = await updateWaypoints(currPayload); |
|
|
|
if (r2.code === 200) { |
|
|
|
Object.assign(merged, { speed: speedVal }); |
|
|
|
waypoints.splice(idx, 1, merged); |
|
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { |
|
|
|
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); |
|
|
|
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('下一航点为定时点重算本航点速度失败', e); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
if (this.$refs.cesiumMap) { |
|
|
|
const roomId = this.currentRoomId; |
|
|
|
if (roomId && routeForPlatform.platformId) { |
|
|
|
@ -1658,6 +1755,10 @@ export default { |
|
|
|
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap && waypoints.length > 0) { |
|
|
|
const r = this.routes.find(rr => rr.id === routeId); |
|
|
|
if (r) { |
|
|
|
if (waypoints.some(wp => this.isHoldWaypoint(wp))) { |
|
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); |
|
|
|
this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, routeId); |
|
|
|
} |
|
|
|
this.$refs.cesiumMap.removeRouteById(routeId); |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes)); |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
@ -2056,9 +2157,14 @@ export default { |
|
|
|
} |
|
|
|
})); |
|
|
|
this.$nextTick(() => { |
|
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); |
|
|
|
this.activeRouteIds.forEach(id => { |
|
|
|
const route = this.routes.find(r => r.id === id); |
|
|
|
if (route && route.waypoints && route.waypoints.length > 0 && this.$refs.cesiumMap) { |
|
|
|
// 含盘旋航点时先预计算半径/长短轴(根据相对K时与距离),再渲染,使圆/椭圆尺寸正确 |
|
|
|
if (route.waypoints.some(wp => this.isHoldWaypoint(wp))) { |
|
|
|
this.getPositionAtMinutesFromK(route.waypoints, minMinutes, minMinutes, maxMinutes, id); |
|
|
|
} |
|
|
|
this.$refs.cesiumMap.removeRouteById(id); |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); |
|
|
|
} |
|
|
|
@ -2110,7 +2216,7 @@ export default { |
|
|
|
|
|
|
|
openAddHoldDuringDrawing() { |
|
|
|
this.addHoldContext = { mode: 'drawing' }; |
|
|
|
this.addHoldForm = { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; |
|
|
|
this.addHoldForm = { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: 60 }; |
|
|
|
this.showAddHoldDialog = true; |
|
|
|
}, |
|
|
|
|
|
|
|
@ -2281,6 +2387,9 @@ export default { |
|
|
|
if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams; |
|
|
|
if (updatedWaypoint.labelFontSize != null) payload.labelFontSize = updatedWaypoint.labelFontSize; |
|
|
|
if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor; |
|
|
|
if (updatedWaypoint.segmentMode != null) payload.segmentMode = updatedWaypoint.segmentMode; |
|
|
|
if (updatedWaypoint.segmentTargetMinutes !== undefined) payload.segmentTargetMinutes = updatedWaypoint.segmentTargetMinutes; |
|
|
|
if (updatedWaypoint.segmentTargetSpeed !== undefined) payload.segmentTargetSpeed = updatedWaypoint.segmentTargetSpeed; |
|
|
|
if (updatedWaypoint.pixelSize != null) payload.pixelSize = updatedWaypoint.pixelSize; |
|
|
|
if (updatedWaypoint.color != null) payload.color = updatedWaypoint.color; |
|
|
|
if (updatedWaypoint.outlineColor != null) payload.outlineColor = updatedWaypoint.outlineColor; |
|
|
|
@ -2298,6 +2407,78 @@ export default { |
|
|
|
const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id); |
|
|
|
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); |
|
|
|
} |
|
|
|
// 定时点:根据“本航点相对K时”反算上一航点速度,使平台能在 K+本点时间 到达本点并出发(或提前到达后盘旋至该时刻) |
|
|
|
if (merged.segmentMode === 'fixed_time' && index > 0) { |
|
|
|
const prev = sd.waypoints[index - 1]; |
|
|
|
const distM = this.segmentDistance( |
|
|
|
{ lat: prev.lat, lng: prev.lng, alt: prev.alt }, |
|
|
|
{ lat: merged.lat, lng: merged.lng, alt: merged.alt } |
|
|
|
); |
|
|
|
const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime); |
|
|
|
const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime); |
|
|
|
const deltaMin = currMinutes - prevMinutes; |
|
|
|
if (deltaMin > 0.001) { |
|
|
|
const newSpeedKmh = (distM / 1000) / (deltaMin / 60); |
|
|
|
const speedVal = Math.round(newSpeedKmh * 10) / 10; |
|
|
|
const prevPayload = { ...prev, speed: speedVal }; |
|
|
|
if (prev.segmentMode != null) prevPayload.segmentMode = prev.segmentMode; |
|
|
|
if (prev.labelFontSize != null) prevPayload.labelFontSize = prev.labelFontSize; |
|
|
|
if (prev.labelColor != null) prevPayload.labelColor = prev.labelColor; |
|
|
|
if (prev.color != null) prevPayload.color = prev.color; |
|
|
|
if (prev.pixelSize != null) prevPayload.pixelSize = prev.pixelSize; |
|
|
|
if (prev.outlineColor != null) prevPayload.outlineColor = prev.outlineColor; |
|
|
|
try { |
|
|
|
const r2 = await updateWaypoints(prevPayload); |
|
|
|
if (r2.code === 200) { |
|
|
|
Object.assign(prev, { speed: speedVal }); |
|
|
|
sd.waypoints.splice(index - 1, 1, prev); |
|
|
|
if (routeInList && routeInList.waypoints) { |
|
|
|
const prevIdxInList = routeInList.waypoints.findIndex(p => p.id === prev.id); |
|
|
|
if (prevIdxInList !== -1) routeInList.waypoints.splice(prevIdxInList, 1, prev); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('定时点重算上一航点速度失败', e); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
// 下一航点是定时点时:重算本航点速度,使平台能在下一航点的 K 时到达 |
|
|
|
if (index < sd.waypoints.length - 1) { |
|
|
|
const next = sd.waypoints[index + 1]; |
|
|
|
if (next.segmentMode === 'fixed_time') { |
|
|
|
const distToNextM = this.segmentDistance( |
|
|
|
{ lat: merged.lat, lng: merged.lng, alt: merged.alt }, |
|
|
|
{ lat: next.lat, lng: next.lng, alt: next.alt } |
|
|
|
); |
|
|
|
const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime); |
|
|
|
const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime); |
|
|
|
const deltaMin = nextMinutes - currMinutes; |
|
|
|
if (deltaMin > 0.001) { |
|
|
|
const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60); |
|
|
|
const speedVal = Math.round(newSpeedKmh * 10) / 10; |
|
|
|
const currPayload = { ...merged, speed: speedVal }; |
|
|
|
if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode; |
|
|
|
if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize; |
|
|
|
if (merged.labelColor != null) currPayload.labelColor = merged.labelColor; |
|
|
|
if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize; |
|
|
|
if (merged.color != null) currPayload.color = merged.color; |
|
|
|
if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor; |
|
|
|
try { |
|
|
|
const r2 = await updateWaypoints(currPayload); |
|
|
|
if (r2.code === 200) { |
|
|
|
Object.assign(merged, { speed: speedVal }); |
|
|
|
sd.waypoints.splice(index, 1, merged); |
|
|
|
if (routeInList && routeInList.waypoints) { |
|
|
|
const idxInList = routeInList.waypoints.findIndex(p => p.id === merged.id); |
|
|
|
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('下一航点为定时点重算本航点速度失败', e); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
if (this.$refs.cesiumMap) { |
|
|
|
if (roomId && sd.platformId) { |
|
|
|
try { |
|
|
|
@ -2316,6 +2497,11 @@ export default { |
|
|
|
} |
|
|
|
this.showWaypointDialog = false; |
|
|
|
this.$message.success('航点信息已持久化至数据库'); |
|
|
|
if (this.$refs.cesiumMap && (this.$refs.cesiumMap.setRouteHoldRadii || this.$refs.cesiumMap.setRouteHoldEllipseParams)) { |
|
|
|
const rid = sd.id; |
|
|
|
if (this.$refs.cesiumMap.setRouteHoldRadii) this.$refs.cesiumMap.setRouteHoldRadii(rid, {}); |
|
|
|
if (this.$refs.cesiumMap.setRouteHoldEllipseParams) this.$refs.cesiumMap.setRouteHoldEllipseParams(rid, {}); |
|
|
|
} |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
this.wsConnection?.sendSyncWaypoints?.(this.selectedRouteDetails.id); |
|
|
|
// 航点编辑后,根据新位置重算导弹发射位置并更新 Redis |
|
|
|
@ -3517,8 +3703,17 @@ export default { |
|
|
|
if (this.playbackInterval) { |
|
|
|
clearInterval(this.playbackInterval); |
|
|
|
} |
|
|
|
this._playbackLastTickTime = Date.now(); |
|
|
|
// 按真实经过时间推进:1 实机秒 = playbackSpeed 推演秒,避免 setInterval 不准导致“现实两秒对应时间轴一秒” |
|
|
|
this.playbackInterval = setInterval(() => { |
|
|
|
this.timeProgress += this.playbackSpeed * 0.1; |
|
|
|
const now = Date.now(); |
|
|
|
const elapsedSec = (now - (this._playbackLastTickTime || now)) / 1000; |
|
|
|
this._playbackLastTickTime = now; |
|
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); |
|
|
|
const span = Math.max(0, maxMinutes - minMinutes) || 120; |
|
|
|
const spanSeconds = span * 60; |
|
|
|
// 经过 elapsedSec 实机秒 → 推进 playbackSpeed * elapsedSec 推演秒 → 进度增量 (elapsedSec * playbackSpeed / spanSeconds) * 100 |
|
|
|
this.timeProgress += (elapsedSec * this.playbackSpeed / spanSeconds) * 100; |
|
|
|
if (this.timeProgress >= 100) { |
|
|
|
this.timeProgress = 0; |
|
|
|
} |
|
|
|
@ -3533,18 +3728,9 @@ export default { |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
increaseSpeed() { |
|
|
|
if (this.playbackSpeed < 25) { |
|
|
|
this.playbackSpeed++; |
|
|
|
if (this.isPlaying) { |
|
|
|
this.startPlayback(); |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
decreaseSpeed() { |
|
|
|
if (this.playbackSpeed > 1) { |
|
|
|
this.playbackSpeed--; |
|
|
|
setPlaybackSpeed(speed) { |
|
|
|
if (this.speedOptions.includes(speed)) { |
|
|
|
this.playbackSpeed = speed; |
|
|
|
if (this.isPlaying) { |
|
|
|
this.startPlayback(); |
|
|
|
} |
|
|
|
@ -3558,10 +3744,11 @@ export default { |
|
|
|
this.deductionMinutesFromK = currentMinutesFromK; |
|
|
|
|
|
|
|
const sign = currentMinutesFromK >= 0 ? '+' : '-'; |
|
|
|
const absMin = Math.abs(Math.floor(currentMinutesFromK)); |
|
|
|
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`; |
|
|
|
const totalSeconds = Math.floor(Math.abs(currentMinutesFromK) * 60); |
|
|
|
const hours = Math.floor(totalSeconds / 3600); |
|
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60); |
|
|
|
const seconds = totalSeconds % 60; |
|
|
|
this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; |
|
|
|
// 右上角作战时间随时与推演时间轴同步(无论是否展示航线) |
|
|
|
this.combatTime = this.currentTime; |
|
|
|
this.updateDeductionPositions(); |
|
|
|
@ -3676,6 +3863,17 @@ export default { |
|
|
|
const min = parseInt(m[3], 10); |
|
|
|
return sign * (h * 60 + min); |
|
|
|
}, |
|
|
|
/** 将 startTime(如 K+00:19:30)转为相对 K 的分钟数(含秒,保留小数) */ |
|
|
|
waypointStartTimeToMinutesDecimal(s) { |
|
|
|
if (!s || typeof s !== 'string') return 0; |
|
|
|
const m = s.match(/K([+-])(\d{2}):(\d{2})(?::(\d{2}))?/); |
|
|
|
if (!m) return 0; |
|
|
|
const sign = m[1] === '+' ? 1 : -1; |
|
|
|
const h = parseInt(m[2], 10); |
|
|
|
const min = parseInt(m[3], 10); |
|
|
|
const sec = m[4] != null ? parseInt(m[4], 10) : 0; |
|
|
|
return sign * (h * 60 + min + sec / 60); |
|
|
|
}, |
|
|
|
/** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */ |
|
|
|
minutesToStartTime(minutes) { |
|
|
|
const m = Math.floor(Number(minutes)); |
|
|
|
@ -3686,6 +3884,18 @@ export default { |
|
|
|
const min = abs % 60; |
|
|
|
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; |
|
|
|
}, |
|
|
|
/** 将相对 K 的分钟数(可含小数)转为 startTime,精确到秒(如 K+00:19:30) */ |
|
|
|
minutesToStartTimeWithSeconds(minutes) { |
|
|
|
const num = Number(minutes); |
|
|
|
if (!Number.isFinite(num)) return 'K+00:00:00'; |
|
|
|
const sign = num >= 0 ? '+' : '-'; |
|
|
|
const abs = Math.abs(num); |
|
|
|
const totalSec = Math.round(abs * 60); |
|
|
|
const h = Math.floor(totalSec / 3600); |
|
|
|
const min = Math.floor((totalSec % 3600) / 60); |
|
|
|
const sec = totalSec % 60; |
|
|
|
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; |
|
|
|
}, |
|
|
|
|
|
|
|
isHoldWaypoint(wp) { |
|
|
|
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal'; |
|
|
|
@ -3696,7 +3906,14 @@ export default { |
|
|
|
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 }; |
|
|
|
return { |
|
|
|
radius: p.radius, |
|
|
|
semiMajor: p.semiMajor ?? p.semiMajorAxis, |
|
|
|
semiMinor: p.semiMinor ?? p.semiMinorAxis, |
|
|
|
headingDeg: p.headingDeg ?? 0, |
|
|
|
clockwise: p.clockwise !== false, |
|
|
|
edgeLength: p.edgeLength != null ? Number(p.edgeLength) : 20000 |
|
|
|
}; |
|
|
|
} catch (e) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
@ -3750,9 +3967,9 @@ export default { |
|
|
|
/** |
|
|
|
* 按速度与计划时间构建航线时间轴:含飞行段、盘旋段与“提前到达则等待”的等待段。 |
|
|
|
* pathData 可选:{ path, segmentEndIndices, holdArcRanges },由 getRoutePathWithSegmentIndices 提供,用于输出 hold 段。 |
|
|
|
* holdRadiusByLegIndex 可选:{ [legIndex]: number },为盘旋段指定半径(用于推演时落点精准在切点)。 |
|
|
|
* 圆形盘旋半径由速度+坡度公式固定计算,盘旋时间靠多转圈数解决,不反算半径。 |
|
|
|
*/ |
|
|
|
buildRouteTimeline(waypoints, globalMin, globalMax, pathData, holdRadiusByLegIndex) { |
|
|
|
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) { |
|
|
|
const warnings = []; |
|
|
|
if (!waypoints || waypoints.length === 0) return { segments: [], warnings }; |
|
|
|
const points = waypoints.map((wp, idx) => ({ |
|
|
|
@ -3790,7 +4007,12 @@ export default { |
|
|
|
const path = pathData && pathData.path; |
|
|
|
const segmentEndIndices = pathData && pathData.segmentEndIndices; |
|
|
|
const holdArcRanges = pathData && pathData.holdArcRanges || {}; |
|
|
|
let skipNextLeg = false; |
|
|
|
for (let i = 0; i < points.length - 1; i++) { |
|
|
|
if (skipNextLeg) { |
|
|
|
skipNextLeg = false; |
|
|
|
continue; |
|
|
|
} |
|
|
|
if (this.isHoldWaypoint(waypoints[i + 1]) && path && segmentEndIndices && holdArcRanges[i]) { |
|
|
|
const range = holdArcRanges[i]; |
|
|
|
const startIdx = i === 0 ? 0 : segmentEndIndices[i - 1] + 1; |
|
|
|
@ -3799,27 +4021,56 @@ export default { |
|
|
|
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 holdWpForSegment = waypoints[i + 1]; |
|
|
|
const segTarget = holdWpForSegment && (holdWpForSegment.segmentTargetMinutes ?? holdWpForSegment.displayStyle?.segmentTargetMinutes); |
|
|
|
const hasFixedTime = holdWpForSegment && holdWpForSegment.segmentMode === 'fixed_time' && (segTarget != null && segTarget !== ''); |
|
|
|
let arrivalEntry; |
|
|
|
let speedKmhForLeg = points[i].speed || 800; |
|
|
|
if (hasFixedTime) { |
|
|
|
const targetMin = Number(segTarget); |
|
|
|
const deltaMin = targetMin - effectiveTime[i]; |
|
|
|
if (deltaMin > 0.001 && distToEntry > 0) { |
|
|
|
arrivalEntry = targetMin; |
|
|
|
speedKmhForLeg = (distToEntry / 1000) / (deltaMin / 60); |
|
|
|
speedKmhForLeg = Math.round(speedKmhForLeg * 10) / 10; |
|
|
|
} else { |
|
|
|
arrivalEntry = effectiveTime[i] + (distToEntry / 1000) * (60 / speedKmhForLeg); |
|
|
|
} |
|
|
|
} else { |
|
|
|
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmhForLeg); |
|
|
|
arrivalEntry = effectiveTime[i] + travelToEntryMin; |
|
|
|
} |
|
|
|
const holdEndTime = points[i + 1].minutes; // 用户设定的切出时间(如 K+10) |
|
|
|
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : (toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }); |
|
|
|
let loopEndIdx = 1; |
|
|
|
for (let k = 1; k < Math.min(holdPathSlice.length, 120); k++) { |
|
|
|
if (this.segmentDistance(holdPathSlice[0], holdPathSlice[k]) < 80) { loopEndIdx = k; break; } |
|
|
|
let loopEndIdx; |
|
|
|
if (range.loopEndIndex != null) { |
|
|
|
loopEndIdx = range.loopEndIndex - range.start; |
|
|
|
} else { |
|
|
|
const minSearchIdx = Math.max(2, Math.floor(holdPathSlice.length * 0.33)); |
|
|
|
loopEndIdx = holdPathSlice.length - 1; |
|
|
|
for (let k = minSearchIdx; k < holdPathSlice.length; k++) { |
|
|
|
if (this.segmentDistance(holdPathSlice[0], holdPathSlice[k]) < 80) { loopEndIdx = k; break; } |
|
|
|
} |
|
|
|
} |
|
|
|
const holdClosedLoopPath = holdPathSlice.slice(0, loopEndIdx + 1); |
|
|
|
if (loopEndIdx < 1) loopEndIdx = 1; |
|
|
|
if (loopEndIdx >= holdPathSlice.length) loopEndIdx = holdPathSlice.length - 1; |
|
|
|
const holdClosedLoopRaw = holdPathSlice.slice(0, loopEndIdx + 1); |
|
|
|
const holdClosedLoopPath = holdClosedLoopRaw.length >= 2 |
|
|
|
? [...holdClosedLoopRaw.slice(0, -1), { ...holdClosedLoopRaw[0] }] |
|
|
|
: holdClosedLoopRaw; |
|
|
|
const holdLoopLength = this.pathSliceDistance(holdClosedLoopPath) || 1; |
|
|
|
let exitIdxOnLoop = 0; |
|
|
|
let minD = 1e9; |
|
|
|
for (let k = 0; k <= loopEndIdx; k++) { |
|
|
|
const d = this.segmentDistance(holdPathSlice[k], exitPos); |
|
|
|
if (d < minD) { minD = d; exitIdxOnLoop = k; } |
|
|
|
} |
|
|
|
const holdExitDistanceOnLoop = this.pathSliceDistance(holdPathSlice.slice(0, exitIdxOnLoop + 1)); |
|
|
|
const speedMpMin = (speedKmh * 1000) / 60; |
|
|
|
// 出口必须在整条盘旋路径上找,不能只在“整圈”段内找,否则会误把圈上某点当出口导致飞半圈就停或折回 |
|
|
|
const holdEntryToExitRaw = holdPathSlice.slice(loopEndIdx); |
|
|
|
const holdEntryToExitSlice = holdEntryToExitRaw.length >= 2 |
|
|
|
? [{ ...holdClosedLoopPath[0] }, ...holdEntryToExitRaw.slice(1)] |
|
|
|
: holdEntryToExitRaw; |
|
|
|
const holdExitDistanceOnLoop = this.pathSliceDistance(holdEntryToExitSlice); |
|
|
|
const holdSpeedKmh = points[i + 1].speed || 800; |
|
|
|
const HOLD_SPEED_KMH = 800; |
|
|
|
const speedMpMin = (HOLD_SPEED_KMH * 1000) / 60; |
|
|
|
const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin; |
|
|
|
let n = Math.ceil((requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength); |
|
|
|
const rawLoops = (requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength; |
|
|
|
let n = Math.ceil(rawLoops - 1e-9); |
|
|
|
if (n < 0 || !Number.isFinite(n)) n = 0; |
|
|
|
const segmentEndTime = arrivalEntry + (holdExitDistanceOnLoop + n * holdLoopLength) / speedMpMin; |
|
|
|
if (segmentEndTime > holdEndTime) { |
|
|
|
@ -3838,23 +4089,24 @@ export default { |
|
|
|
}); |
|
|
|
} |
|
|
|
const distExitToNext = this.pathSliceDistance(toNextSlice); |
|
|
|
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh); |
|
|
|
const travelExitMin = (distExitToNext / 1000) * (60 / holdSpeedKmh); |
|
|
|
const arrivalNext = segmentEndTime + travelExitMin; |
|
|
|
effectiveTime[i + 1] = holdEndTime; |
|
|
|
effectiveTime[i + 1] = segmentEndTime; |
|
|
|
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 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 overrideR = holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null ? holdRadiusByLegIndex[i] : null; |
|
|
|
const holdRadius = (overrideR != null && Number.isFinite(overrideR)) ? overrideR : (holdParams && holdParams.radius != null ? holdParams.radius : null); |
|
|
|
const computedR = this.$refs.cesiumMap ? this.$refs.cesiumMap.getWaypointRadius(holdWp) : null; |
|
|
|
const holdRadius = (computedR != null && computedR > 0) ? computedR : 500; |
|
|
|
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: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice, speedKmh: speedKmhForLeg }); |
|
|
|
const holdEntryToExitPath = holdEntryToExitSlice; |
|
|
|
segments.push({ |
|
|
|
startTime: arrivalEntry, |
|
|
|
endTime: segmentEndTime, |
|
|
|
@ -3866,15 +4118,27 @@ export default { |
|
|
|
holdClosedLoopPath, |
|
|
|
holdLoopLength, |
|
|
|
holdExitDistanceOnLoop, |
|
|
|
speedKmh: points[i].speed || 800, |
|
|
|
holdEntryToExitPath, |
|
|
|
holdN: n, |
|
|
|
speedKmh: HOLD_SPEED_KMH, |
|
|
|
holdEndTime, |
|
|
|
holdCenter, |
|
|
|
holdRadius, |
|
|
|
holdCircumference, |
|
|
|
holdClockwise, |
|
|
|
holdEntryAngle |
|
|
|
}); |
|
|
|
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); |
|
|
|
i++; |
|
|
|
// 出口→下一航点的 fly 段 |
|
|
|
const exitEndPos = toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos; |
|
|
|
// 如果下一个航点(WP_{i+2})也是盘旋,不创建 fly 段(让下一次循环处理), |
|
|
|
// 只更新 effectiveTime 使下一次循环的起始时间正确 |
|
|
|
if (i + 2 < points.length && this.isHoldWaypoint(waypoints[i + 2])) { |
|
|
|
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: exitEndPos, type: 'fly', legIndex: i + 1, pathSlice: toNextSlice, speedKmh: holdSpeedKmh }); |
|
|
|
} else { |
|
|
|
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: exitEndPos, type: 'fly', legIndex: i + 1, pathSlice: toNextSlice, speedKmh: holdSpeedKmh }); |
|
|
|
// 下一航点不是盘旋,fly 段已覆盖 leg i+1,跳过 |
|
|
|
skipNextLeg = true; |
|
|
|
} |
|
|
|
continue; |
|
|
|
} |
|
|
|
const dist = this.segmentDistance(points[i], points[i + 1]); |
|
|
|
@ -3902,7 +4166,15 @@ export default { |
|
|
|
effectiveTime[i + 1] = Math.max(actualArrival, scheduled); |
|
|
|
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }; |
|
|
|
const posNext = { lng: points[i + 1].lng, lat: points[i + 1].lat, alt: points[i + 1].alt }; |
|
|
|
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i }); |
|
|
|
let flyPathSlice = null; |
|
|
|
if (path && segmentEndIndices) { |
|
|
|
const startIdx = i === 0 ? 0 : (segmentEndIndices[i - 1] != null ? segmentEndIndices[i - 1] : 0); |
|
|
|
const endIdx = segmentEndIndices[i]; |
|
|
|
if (endIdx != null && endIdx >= startIdx) { |
|
|
|
flyPathSlice = path.slice(startIdx, endIdx + 1); |
|
|
|
} |
|
|
|
} |
|
|
|
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i, speedKmh: speedKmh, pathSlice: flyPathSlice }); |
|
|
|
if (actualArrival < effectiveTime[i + 1]) { |
|
|
|
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i }); |
|
|
|
} |
|
|
|
@ -3971,12 +4243,24 @@ export default { |
|
|
|
return s.startPos; |
|
|
|
} |
|
|
|
if (s.type === 'hold' && s.holdPath && s.holdPath.length) { |
|
|
|
// 飞机一直绕闭合环盘旋,不静止;到设定K时若在切出点则切出,否则继续飞到切出点再切出并报冲突 |
|
|
|
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) { |
|
|
|
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60); |
|
|
|
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength; |
|
|
|
const tPath = distOnLoop / s.holdLoopLength; |
|
|
|
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath); |
|
|
|
const n = Math.max(0, s.holdN != null ? s.holdN : 0); |
|
|
|
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength; |
|
|
|
if (totalFly <= 0) return s.startPos; |
|
|
|
if (distM >= totalFly) return s.endPos; |
|
|
|
if (distM < n * s.holdLoopLength) { |
|
|
|
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength; |
|
|
|
const tPath = distOnLoop / s.holdLoopLength; |
|
|
|
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath); |
|
|
|
} |
|
|
|
const distToExit = distM - n * s.holdLoopLength; |
|
|
|
const exitDist = s.holdExitDistanceOnLoop || 1; |
|
|
|
const tPath = Math.min(1, distToExit / exitDist); |
|
|
|
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) { |
|
|
|
return this.getPositionAlongPathSlice(s.holdEntryToExitPath, tPath); |
|
|
|
} |
|
|
|
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength); |
|
|
|
} |
|
|
|
return this.getPositionAlongPathSlice(s.holdPath, t); |
|
|
|
} |
|
|
|
@ -4005,117 +4289,65 @@ export default { |
|
|
|
const cesiumMap = this.$refs.cesiumMap; |
|
|
|
let pathData = null; |
|
|
|
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) { |
|
|
|
const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints); |
|
|
|
const cachedEllipse = (routeId != null && cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId]) ? cesiumMap._routeHoldEllipseParamsByRoute[routeId] : {}; |
|
|
|
const opts = Object.keys(cachedEllipse).length > 0 ? { holdEllipseParamsByLegIndex: cachedEllipse } : {}; |
|
|
|
const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints, opts); |
|
|
|
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) { |
|
|
|
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; |
|
|
|
} |
|
|
|
} |
|
|
|
let { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); |
|
|
|
const holdRadiusByLegIndex = {}; |
|
|
|
// 圆形盘旋:半径固定由速度+坡度公式计算,盘旋时间靠多转圈数解决,不反算半径。 |
|
|
|
// 椭圆/跑道形盘旋:通过反算椭圆参数(semiMajor/semiMinor)来匹配盘旋时间。 |
|
|
|
const holdEllipseParamsByLegIndex = {}; |
|
|
|
if (cesiumMap && segments && pathData) { |
|
|
|
for (let idx = 0; idx < segments.length; idx++) { |
|
|
|
const s = segments[idx]; |
|
|
|
if (s.type !== 'hold' || s.holdCenter == null) continue; |
|
|
|
const i = s.legIndex; |
|
|
|
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); |
|
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : 800; |
|
|
|
const holdWp = waypoints[i + 1]; |
|
|
|
if (!holdWp) continue; |
|
|
|
const isHoldEllipse = (holdWp.pointType || holdWp.point_type) === 'hold_ellipse'; |
|
|
|
if (!isHoldEllipse || !cesiumMap.computeEllipseParamsForDuration) continue; |
|
|
|
const holdEndTime = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(holdWp?.startTime); |
|
|
|
const segTarget = holdWp && (holdWp.segmentTargetMinutes ?? holdWp.displayStyle?.segmentTargetMinutes); |
|
|
|
const arrivalAtHold = (holdWp && holdWp.segmentMode === 'fixed_time' && segTarget != null && segTarget !== '') |
|
|
|
? Number(segTarget) : s.startTime; |
|
|
|
const holdDurationMin = Math.max(0, holdEndTime - arrivalAtHold); |
|
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWp?.speed) || 800); |
|
|
|
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; |
|
|
|
const prevWp = waypoints[i]; |
|
|
|
const holdWp = waypoints[i + 1]; |
|
|
|
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; |
|
|
|
if (!prevWp || !holdWp) continue; |
|
|
|
if (!prevWp) continue; |
|
|
|
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); |
|
|
|
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); |
|
|
|
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; |
|
|
|
const clockwise = s.holdClockwise !== false; |
|
|
|
const isEllipse = (waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse') || s.holdRadius == null; |
|
|
|
if (isEllipse && cesiumMap.computeEllipseParamsForDuration) { |
|
|
|
const holdParams = this.parseHoldParams(holdWp); |
|
|
|
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0; |
|
|
|
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500; |
|
|
|
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300; |
|
|
|
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM, headingDeg, a0, b0); |
|
|
|
if (out && out.semiMajor != null && out.semiMinor != null) holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg }; |
|
|
|
} else if (!isEllipse && cesiumMap.computeHoldRadiusForDuration) { |
|
|
|
const R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM); |
|
|
|
if (R != null && Number.isFinite(R)) holdRadiusByLegIndex[i] = R; |
|
|
|
const holdParams = this.parseHoldParams(holdWp); |
|
|
|
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0; |
|
|
|
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500; |
|
|
|
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300; |
|
|
|
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM, headingDeg, a0, b0); |
|
|
|
if (out && out.semiMajor != null && out.semiMinor != null) { |
|
|
|
holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg }; |
|
|
|
} |
|
|
|
} |
|
|
|
const hasCircle = Object.keys(holdRadiusByLegIndex).length > 0; |
|
|
|
const hasEllipse = Object.keys(holdEllipseParamsByLegIndex).length > 0; |
|
|
|
if (hasCircle || hasEllipse) { |
|
|
|
let pathData2 = null; |
|
|
|
let segments2 = null; |
|
|
|
for (let iter = 0; iter < 2; iter++) { |
|
|
|
const ret2 = cesiumMap.getRoutePathWithSegmentIndices(waypoints, { holdRadiusByLegIndex, holdEllipseParamsByLegIndex }); |
|
|
|
if (!ret2.path || ret2.path.length === 0 || !ret2.segmentEndIndices || ret2.segmentEndIndices.length === 0) break; |
|
|
|
pathData2 = { path: ret2.path, segmentEndIndices: ret2.segmentEndIndices, holdArcRanges: ret2.holdArcRanges || {} }; |
|
|
|
const out = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData2, holdRadiusByLegIndex); |
|
|
|
segments2 = out.segments; |
|
|
|
let changed = false; |
|
|
|
if (hasCircle) { |
|
|
|
const nextRadii = {}; |
|
|
|
for (let idx = 0; idx < segments2.length; idx++) { |
|
|
|
const s = segments2[idx]; |
|
|
|
if (s.type !== 'hold' || s.holdRadius == null || s.holdCenter == null) continue; |
|
|
|
const i = s.legIndex; |
|
|
|
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); |
|
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : 800; |
|
|
|
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; |
|
|
|
const prevWp = waypoints[i]; |
|
|
|
const holdWp = waypoints[i + 1]; |
|
|
|
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; |
|
|
|
if (!prevWp || !holdWp) continue; |
|
|
|
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); |
|
|
|
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); |
|
|
|
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; |
|
|
|
const Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM); |
|
|
|
if (Rnew != null && Number.isFinite(Rnew)) { |
|
|
|
if (holdRadiusByLegIndex[i] == null || Math.abs(Rnew - holdRadiusByLegIndex[i]) > 1) changed = true; |
|
|
|
nextRadii[i] = Rnew; |
|
|
|
} |
|
|
|
} |
|
|
|
Object.assign(holdRadiusByLegIndex, nextRadii); |
|
|
|
} |
|
|
|
if (hasEllipse) { |
|
|
|
for (let idx = 0; idx < segments2.length; idx++) { |
|
|
|
const s = segments2[idx]; |
|
|
|
if (s.type !== 'hold' || s.holdRadius != null || s.holdCenter == null) continue; |
|
|
|
const i = s.legIndex; |
|
|
|
const holdWp = waypoints[i + 1]; |
|
|
|
const holdParams = this.parseHoldParams(holdWp); |
|
|
|
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); |
|
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : 800; |
|
|
|
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; |
|
|
|
const prevWp = waypoints[i]; |
|
|
|
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; |
|
|
|
if (!prevWp || !holdWp || !cesiumMap.computeEllipseParamsForDuration) continue; |
|
|
|
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); |
|
|
|
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); |
|
|
|
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; |
|
|
|
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0; |
|
|
|
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500; |
|
|
|
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300; |
|
|
|
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM, headingDeg, a0, b0); |
|
|
|
if (out && out.semiMajor != null) { |
|
|
|
const old = holdEllipseParamsByLegIndex[i]; |
|
|
|
if (!old || Math.abs(out.semiMajor - old.semiMajor) > 1) changed = true; |
|
|
|
holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg }; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
if (!changed || iter === 1) break; |
|
|
|
if (hasEllipse) { |
|
|
|
const ret2 = cesiumMap.getRoutePathWithSegmentIndices(waypoints, { holdEllipseParamsByLegIndex }); |
|
|
|
if (ret2.path && ret2.path.length > 0 && ret2.segmentEndIndices && ret2.segmentEndIndices.length > 0) { |
|
|
|
pathData = { path: ret2.path, segmentEndIndices: ret2.segmentEndIndices, holdArcRanges: ret2.holdArcRanges || {} }; |
|
|
|
const out2 = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); |
|
|
|
segments = out2.segments; |
|
|
|
} |
|
|
|
if (pathData2) pathData = pathData2; |
|
|
|
if (segments2) segments = segments2; |
|
|
|
if (routeId != null) { |
|
|
|
if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, holdRadiusByLegIndex); |
|
|
|
if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, holdEllipseParamsByLegIndex); |
|
|
|
if (routeId != null && cesiumMap.setRouteHoldEllipseParams) { |
|
|
|
cesiumMap.setRouteHoldEllipseParams(routeId, holdEllipseParamsByLegIndex); |
|
|
|
} |
|
|
|
} else if (routeId != null) { |
|
|
|
if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, {}); |
|
|
|
if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, {}); |
|
|
|
} else if (routeId != null && cesiumMap.setRouteHoldEllipseParams) { |
|
|
|
cesiumMap.setRouteHoldEllipseParams(routeId, {}); |
|
|
|
} |
|
|
|
// 圆形盘旋不再使用反算半径,清空旧缓存 |
|
|
|
if (routeId != null && cesiumMap.setRouteHoldRadii) { |
|
|
|
cesiumMap.setRouteHoldRadii(routeId, {}); |
|
|
|
} |
|
|
|
} |
|
|
|
const path = pathData ? pathData.path : null; |
|
|
|
@ -4211,7 +4443,7 @@ export default { |
|
|
|
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 }; |
|
|
|
: { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, clockwise: this.addHoldForm.clockwise }; |
|
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) { |
|
|
|
this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams); |
|
|
|
} |
|
|
|
@ -4233,7 +4465,7 @@ export default { |
|
|
|
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 }; |
|
|
|
: { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, 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'); |
|
|
|
@ -4304,10 +4536,24 @@ export default { |
|
|
|
const span = Math.max(0, maxMinutes - minMinutes) || 120; |
|
|
|
const minutesFromK = minMinutes + (val / 100) * span; |
|
|
|
const sign = minutesFromK >= 0 ? '+' : '-'; |
|
|
|
const absMin = Math.abs(Math.floor(minutesFromK)); |
|
|
|
const hours = Math.floor(absMin / 60); |
|
|
|
const minutes = absMin % 60; |
|
|
|
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; |
|
|
|
const totalSeconds = Math.floor(Math.abs(minutesFromK) * 60); |
|
|
|
const hours = Math.floor(totalSeconds / 3600); |
|
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60); |
|
|
|
const seconds = totalSeconds % 60; |
|
|
|
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; |
|
|
|
}, |
|
|
|
|
|
|
|
onTimelineHover(e) { |
|
|
|
const wrap = this.$refs.timelineSliderWrap; |
|
|
|
if (!wrap) return; |
|
|
|
const rect = wrap.getBoundingClientRect(); |
|
|
|
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); |
|
|
|
this.timelineHoverPercent = percent; |
|
|
|
this.timelineHoverTime = this.formatTimeTooltip(percent); |
|
|
|
this.timelineHoverVisible = true; |
|
|
|
}, |
|
|
|
onTimelineLeave() { |
|
|
|
this.timelineHoverVisible = false; |
|
|
|
}, |
|
|
|
async selectPlan(plan) { |
|
|
|
if (plan && plan.id) { |
|
|
|
@ -4457,6 +4703,10 @@ export default { |
|
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data); |
|
|
|
} catch (_) {} |
|
|
|
} |
|
|
|
if (waypoints.some(wp => this.isHoldWaypoint(wp))) { |
|
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); |
|
|
|
this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, route.id); |
|
|
|
} |
|
|
|
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); |
|
|
|
this.$nextTick(() => this.updateDeductionPositions()); |
|
|
|
} |
|
|
|
@ -5002,6 +5252,33 @@ export default { |
|
|
|
.timeline-slider { |
|
|
|
flex: 1; |
|
|
|
margin: 0 20px; |
|
|
|
position: relative; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-hover-time { |
|
|
|
position: absolute; |
|
|
|
bottom: calc(100% + 8px); |
|
|
|
left: 0; |
|
|
|
transform: translateX(-50%); |
|
|
|
padding: 4px 10px; |
|
|
|
background: rgba(0, 0, 0, 0.85); |
|
|
|
color: #fff; |
|
|
|
font-size: 12px; |
|
|
|
font-weight: 500; |
|
|
|
border-radius: 4px; |
|
|
|
white-space: nowrap; |
|
|
|
pointer-events: none; |
|
|
|
z-index: 10; |
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); |
|
|
|
} |
|
|
|
.timeline-hover-time::after { |
|
|
|
content: ''; |
|
|
|
position: absolute; |
|
|
|
top: 100%; |
|
|
|
left: 50%; |
|
|
|
transform: translateX(-50%); |
|
|
|
border: 5px solid transparent; |
|
|
|
border-top-color: rgba(0, 0, 0, 0.85); |
|
|
|
} |
|
|
|
|
|
|
|
.compact-slider { |
|
|
|
@ -5068,6 +5345,16 @@ export default { |
|
|
|
min-width: 24px; |
|
|
|
text-align: center; |
|
|
|
} |
|
|
|
.speed-text.clickable { |
|
|
|
cursor: pointer; |
|
|
|
padding: 2px 4px; |
|
|
|
display: inline-flex; |
|
|
|
align-items: center; |
|
|
|
} |
|
|
|
.speed-text .el-icon--right { |
|
|
|
margin-left: 4px; |
|
|
|
font-size: 10px; |
|
|
|
} |
|
|
|
|
|
|
|
.system-status { |
|
|
|
display: flex; |
|
|
|
|