Browse Source

冲突检测圆柱体、甘特图拖拽改时间、盘旋修复

mh
menghao 2 weeks ago
parent
commit
c1c6712bb9
  1. 1
      ruoyi-ui/src/lang/en.js
  2. 1
      ruoyi-ui/src/lang/zh.js
  3. 338
      ruoyi-ui/src/utils/conflictDetection.js
  4. 51
      ruoyi-ui/src/utils/timelinePosition.js
  5. 60
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  6. 106
      ruoyi-ui/src/views/cesiumMap/index.vue
  7. 3
      ruoyi-ui/src/views/childRoom/ConflictDrawer.vue
  8. 595
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  9. 560
      ruoyi-ui/src/views/childRoom/index.vue
  10. 69
      ruoyi-ui/src/workers/conflictCheck.worker.js

1
ruoyi-ui/src/lang/en.js

@ -127,7 +127,6 @@ export default {
conflictTypeAll: 'All',
conflictTypeTime: 'Time',
conflictTypeSpace: 'Space',
conflictTypeSpectrum: 'Spectrum',
air: 'Air',
sea: 'Sea',
ground: 'Ground'

1
ruoyi-ui/src/lang/zh.js

@ -127,7 +127,6 @@ export default {
conflictTypeAll: '全部',
conflictTypeTime: '时间',
conflictTypeSpace: '空间',
conflictTypeSpectrum: '频谱',
air: '空中',
sea: '海上',
ground: '地面'

338
ruoyi-ui/src/utils/conflictDetection.js

@ -1,15 +1,13 @@
/**
* 冲突检测工具时间冲突空间冲突频谱冲突
* 冲突检测工具时间冲突空间冲突
* - 时间航线时间窗重叠无法按时到达提前到达盘旋时间不足资源占用缓冲
* - 空间航迹最小间隔摆放平台距离过小禁限区入侵考虑高度带
* - 频谱同频/邻频 + 时间 + 带宽 + 地理范围叠加判定支持频谱资源台账
* - 空间航迹最小间隔摆放平台距离过小禁限区入侵考虑高度带Safety Column 间距
*/
/** 冲突类型 */
export const CONFLICT_TYPE = {
TIME: 'time',
SPACE: 'space',
SPECTRUM: 'spectrum'
SPACE: 'space'
}
/** 默认冲突配置(可由界面配置覆盖) */
@ -27,9 +25,18 @@ export const defaultConflictConfig = {
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
// 频谱
adjacentFreqGuardMHz: 5, // 邻频保护间隔(MHz),两频段中心差小于此视为邻频冲突
spectrumConflictGeoOverlap: true // 频谱冲突是否要求地理范围叠加
/** 隐藏圆柱体(Safety Column):水平半径 R、垂直高度差阈值 H、同点先后时间阈值 ΔT(秒) */
safetyColumnRadiusMeters: 10000, // R:单柱水平半径(米)
safetyColumnHeightMeters: 300, // H:两机高度差小于此值且水平重叠时视为垂直方向不安全(米)
safetyColumnTimeThresholdSeconds: 60, // ΔT:先后经过同一位置的时间间隔小于此值报时间类冲突(秒)
/** 空间重叠采样步长(分钟),默认 0.5 即 30 秒 */
safetyColumnSpatialStepMinutes: 0.5,
/** 同点先后判定:时间维采样步长(分钟),默认 5 秒 */
safetyColumnTemporalStepMinutes: 5 / 60,
/** 判定「同一物理位置」的网格:经纬度量化(度),约百米量级 */
safetyColumnTemporalGridLngDeg: 0.001,
safetyColumnTemporalGridLatDeg: 0.001,
safetyColumnTemporalGridAltMeters: 100
}
/**
@ -87,7 +94,7 @@ export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, conf
title: '航线时间窗重叠',
routeIds: [a.routeId, b.routeId],
routeNames: [a.routeName, b.routeName],
time: `K+${formatMinutes(a.min)}K+${formatMinutes(a.max)}K+${formatMinutes(b.min)}K+${formatMinutes(b.max)} 重叠`,
time: `${formatKLabel(a.min)}${formatKLabel(a.max)}${formatKLabel(b.min)}${formatKLabel(b.max)} 重叠`,
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。'
})
}
@ -96,13 +103,230 @@ export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, conf
return list
}
function formatMinutes(m) {
export function formatMinutes(m) {
const h = Math.floor(Math.abs(m) / 60)
const min = Math.floor(Math.abs(m) % 60)
const sign = m >= 0 ? '+' : '-'
return `${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`
}
/** 展示用完整 K 时标签:K+00:10(formatMinutes 已含 ±,勿再拼接「K+」前缀以免出现 K++) */
export function formatKLabel(m) {
return `K${formatMinutes(m)}`
}
/**
* 地表水平距离不含高度
*/
export function horizontalDistanceMeters(lng1, lat1, lng2, lat2) {
const R = 6371000
const toRad = x => (x * Math.PI) / 180
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δφ = toRad(lat2 - lat1)
const Δλ = toRad(lng2 - lng1)
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* Safety Column 空间冲突同一时刻 t水平距离 < 2R |Δ高度| < H
* getSpeedKmhAtMinutes可选(routeId, minutesFromK) => number用于给出可执行的航速建议
*/
export function detectSafetyColumnSpatial(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {},
canPairFn = null,
getSpeedKmhAtMinutes = null
) {
const R = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters
const H = config.safetyColumnHeightMeters != null ? Number(config.safetyColumnHeightMeters) : defaultConflictConfig.safetyColumnHeightMeters
const step = config.safetyColumnSpatialStepMinutes != null ? Number(config.safetyColumnSpatialStepMinutes) : defaultConflictConfig.safetyColumnSpatialStepMinutes
const list = []
const twoR = 2 * R
const sampleTimes = []
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t)
for (let i = 0; i < routeIds.length; i++) {
for (let j = i + 1; j < routeIds.length; j++) {
const ridA = routeIds[i]
const ridB = routeIds[j]
if (canPairFn && !canPairFn(ridA, ridB)) continue
for (const t of sampleTimes) {
const posA = getPositionAtMinutesFromK(ridA, t)
const posB = getPositionAtMinutesFromK(ridB, t)
if (!posA || !posB || posA.lng == null || posB.lng == null) continue
const horiz = horizontalDistanceMeters(posA.lng, posA.lat, posB.lng, posB.lat)
const altA = posA.alt != null ? Number(posA.alt) : 0
const altB = posB.alt != null ? Number(posB.alt) : 0
const dAlt = Math.abs(altA - altB)
if (horiz < twoR && dAlt < H) {
const needVertSep = Math.max(0, H - dAlt)
const nameA = routeNamesById[ridA] || `航线${ridA}`
const nameB = routeNamesById[ridB] || `航线${ridB}`
const vA = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridA, t) : null
const vB = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridB, t) : null
const vNumA = vA != null && Number.isFinite(vA) && vA > 0 ? vA : null
const vNumB = vB != null && Number.isFinite(vB) && vB > 0 ? vB : null
let vRef = 800
if (vNumA != null && vNumB != null) vRef = (vNumA + vNumB) / 2
else if (vNumA != null) vRef = vNumA
else if (vNumB != null) vRef = vNumB
const horizDeficitM = Math.max(0, twoR - horiz)
const reductionKmh = Math.min(150, Math.max(35, Math.round((horizDeficitM / 1000) * 10)))
const vSuggest = Math.max(120, Math.round(vRef - reductionKmh))
const kShiftMin = 1
const kLater = formatKLabel(t + kShiftMin)
const kEarlier = formatKLabel(t - kShiftMin)
const speedLine =
vNumA != null && vNumB != null
? `② 按当前推演航段速度(「${nameA}」约 ${Math.round(vNumA)} km/h、「${nameB}」约 ${Math.round(vNumB)} km/h),建议将其中一机在本段调至约 ${vSuggest} km/h(约比两机均值低 ${Math.round(vRef - vSuggest)} km/h),使通过该位置的时刻与另一机至少错开约 1 分钟。`
: `② 按参考航段速度约 ${Math.round(vRef)} km/h 计,建议将其中一机在本段调至约 ${vSuggest} km/h(约降低 ${Math.round(vRef - vSuggest)} km/h),使通过该冲突区域的先后间隔至少约 1 分钟。`
const kTimeLine =
`③ 将其中一条航线在冲突时刻附近航点的相对 K 时整体推迟或提前至少 ${kShiftMin} 分钟:例如由 ${formatKLabel(t)} 调至 ${kLater} 或更晚,或调至 ${kEarlier} 或更早。`
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'safety_column_spatial',
title: '飞机间距不足',
routeIds: [ridA, ridB],
routeNames: [nameA, nameB],
time: `${formatKLabel(t)}`,
position: `经度 ${((posA.lng + posB.lng) / 2).toFixed(5)}°, 纬度 ${((posA.lat + posB.lat) / 2).toFixed(5)}°, 高度约 ${((altA + altB) / 2).toFixed(0)} m`,
suggestion:
`两机在相同时刻占位过近:水平间距约 ${(horiz / 1000).toFixed(2)} km,高度差约 ${dAlt.toFixed(0)} m。` +
`① 将其中一条航线在冲突航段附近的高度上调或下调,使两机垂直间隔至少再拉开约 ${Math.ceil(needVertSep)} m(择一执行即可)。` +
speedLine +
kTimeLine,
severity: 'high',
positionLng: (posA.lng + posB.lng) / 2,
positionLat: (posA.lat + posB.lat) / 2,
positionAlt: (altA + altB) / 2,
minutesFromK: t,
safetyColumnHorizM: horiz,
safetyColumnDAltM: dAlt
})
break
}
}
}
}
return list
}
/**
* 隐藏圆柱体 时间冲突不同航线先后经过同一物理位置时间间隔 < ΔT
* 使用网格量化同一位置采样步长应小于 ΔT 以避免漏检
*/
export function detectSafetyColumnTemporal(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {}
) {
const deltaSec = config.safetyColumnTimeThresholdSeconds != null
? Number(config.safetyColumnTimeThresholdSeconds)
: defaultConflictConfig.safetyColumnTimeThresholdSeconds
const step = config.safetyColumnTemporalStepMinutes != null
? Number(config.safetyColumnTemporalStepMinutes)
: defaultConflictConfig.safetyColumnTemporalStepMinutes
const gLng = config.safetyColumnTemporalGridLngDeg != null
? Number(config.safetyColumnTemporalGridLngDeg)
: defaultConflictConfig.safetyColumnTemporalGridLngDeg
const gLat = config.safetyColumnTemporalGridLatDeg != null
? Number(config.safetyColumnTemporalGridLatDeg)
: defaultConflictConfig.safetyColumnTemporalGridLatDeg
const gAlt = config.safetyColumnTemporalGridAltMeters != null
? Number(config.safetyColumnTemporalGridAltMeters)
: defaultConflictConfig.safetyColumnTemporalGridAltMeters
const cellMap = new Map()
for (const rid of routeIds) {
for (let t = minMinutes; t <= maxMinutes; t += step) {
const pos = getPositionAtMinutesFromK(rid, t)
if (!pos || pos.lng == null || pos.lat == null) continue
const lng = Number(pos.lng)
const lat = Number(pos.lat)
const altN = Number(pos.alt) || 0
const ix = gLng > 0 ? Math.floor(lng / gLng) : 0
const iy = gLat > 0 ? Math.floor(lat / gLat) : 0
const iz = gAlt > 0 ? Math.floor(altN / gAlt) : 0
const key = `${ix}_${iy}_${iz}`
let arr = cellMap.get(key)
if (!arr) {
arr = []
cellMap.set(key, arr)
}
if (arr.length < 200) {
arr.push({ routeId: rid, tMinutes: t, lng: pos.lng, lat: pos.lat, alt: Number(pos.alt) || 0 })
}
}
}
const reported = new Set()
const list = []
for (const [, samples] of cellMap) {
if (samples.length < 2) continue
samples.sort((a, b) => a.tMinutes - b.tMinutes)
for (let a = 0; a < samples.length; a++) {
for (let b = a + 1; b < samples.length; b++) {
const sa = samples[a]
const sb = samples[b]
if (sa.routeId === sb.routeId) continue
const dtSec = Math.abs(sb.tMinutes - sa.tMinutes) * 60
if (dtSec <= 1e-6 || dtSec >= deltaSec) continue
const idA = String(sa.routeId)
const idB = String(sb.routeId)
const rLo = idA < idB ? idA : idB
const rHi = idA < idB ? idB : idA
const t1 = Math.min(sa.tMinutes, sb.tMinutes)
const t2 = Math.max(sa.tMinutes, sb.tMinutes)
const pairKey = `${rLo}_${rHi}_${t1.toFixed(5)}_${t2.toFixed(5)}`
if (reported.has(pairKey)) continue
reported.add(pairKey)
const nameA = routeNamesById[sa.routeId] || `航线${sa.routeId}`
const nameB = routeNamesById[sb.routeId] || `航线${sb.routeId}`
const tEarly = sa.tMinutes <= sb.tMinutes ? sa : sb
const tLate = sa.tMinutes <= sb.tMinutes ? sb : sa
const needDelaySec = Math.ceil(deltaSec - dtSec)
const midLng = (tEarly.lng + tLate.lng) / 2
const midLat = (tEarly.lat + tLate.lat) / 2
const midAlt = (tEarly.alt + tLate.alt) / 2
list.push({
type: CONFLICT_TYPE.TIME,
subType: 'safety_column_temporal',
title: '先后经过同点时间过近',
routeIds: [sa.routeId, sb.routeId],
routeNames: [nameA, nameB],
time: `先后约 ${Math.round(dtSec)} s(约 ${formatKLabel(tEarly.tMinutes)}${formatKLabel(tLate.tMinutes)}`,
position: `经度 ${midLng.toFixed(5)}°, 纬度 ${midLat.toFixed(5)}°, 高度约 ${midAlt.toFixed(0)} m`,
suggestion:
`两机先后经过同一空域位置,时间间隔仅约 ${Math.round(dtSec)} s,低于安全先后间隔要求。` +
`① 将其中一条航线相关航点的相对 K 时整体前移或后移,使先后到达间隔至少再拉开约 ${needDelaySec} s 以上。` +
`② 微调其中一机在邻近航段的飞行速度,使到达该位置的时刻错开。` +
`③ 若仍受航路结构限制,可为其中一机分配不同高度层,通过垂直间隔避免同点先后过近。`,
severity: 'high',
positionLng: midLng,
positionLat: midLat,
positionAlt: midAlt,
minutesFromK: tEarly.tMinutes,
safetyColumnTemporalDtSec: dtSec
})
}
}
}
return list
}
/**
* 空间冲突推演航迹最小间隔
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null
@ -130,7 +354,7 @@ export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPosit
subType: 'track_separation',
title: '航迹间隔过小',
routeIds: [ridA, ridB],
time: `K+${formatMinutes(t)}`,
time: `${formatKLabel(t)}`,
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
severity: 'high',
@ -239,7 +463,7 @@ export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes,
title: '禁限区入侵',
routeIds: [rid],
zoneName: zone.name || '禁限区',
time: `K+${formatMinutes(t)}`,
time: `${formatKLabel(t)}`,
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`,
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`,
severity: 'high',
@ -313,96 +537,6 @@ export function parseRestrictedZonesFromDrawings(entities, keywords = defaultCon
return zones
}
// --------------- 频谱冲突与台账 ---------------
/**
* 频谱资源台账项同频/邻频 + 时间 + 带宽 + 地理范围
*/
export function createSpectrumLedgerEntry({ id, name, freqMHz, bandwidthMHz, startTimeMinutes, endTimeMinutes, geo }) {
return {
id: id || `spec_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: name || '',
freqMHz: Number(freqMHz) || 0,
bandwidthMHz: Number(bandwidthMHz) || 0,
startTimeMinutes: Number(startTimeMinutes) || 0,
endTimeMinutes: Number(endTimeMinutes) || 0,
geo: geo || null // { type: 'bbox', minLng, minLat, maxLng, maxLat } 或 { type: 'polygon', points: [{lng,lat}] }
}
}
/**
* 两频段是否同频或邻频中心频率差在带宽一半或 guard
*/
export function isSameOrAdjacentFreq(freq1, bw1, freq2, bw2, guardMHz = defaultConflictConfig.adjacentFreqGuardMHz) {
const c1 = freq1
const c2 = freq2
const half = (bw1 + bw2) / 2
const gap = Math.abs(c1 - c2)
if (gap <= 1e-6) return true // 同频
if (gap <= half || gap <= guardMHz) return true // 重叠或邻频
return false
}
/**
* 两地理范围是否重叠bbox polygon 简化bbox bbox
*/
export function geoOverlap(geo1, geo2) {
if (!geo1 || !geo2) return true // 未设范围视为全局重叠
const toBbox = (g) => {
if (g.type === 'bbox') return g
if (g.type === 'polygon' && g.points && g.points.length) {
const lngs = g.points.map(p => p.lng != null ? p.lng : p.x)
const lats = g.points.map(p => p.lat != null ? p.lat : p.y)
return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs), minLat: Math.min(...lats), maxLat: Math.max(...lats) }
}
return null
}
const b1 = toBbox(geo1)
const b2 = toBbox(geo2)
if (!b1 || !b2) return true
const minLng1 = b1.minLng ?? b1.west
const maxLng1 = b1.maxLng ?? b1.east
const minLat1 = b1.minLat ?? b1.south
const maxLat1 = b1.maxLat ?? b1.north
const minLng2 = b2.minLng ?? b2.west
const maxLng2 = b2.maxLng ?? b2.east
const minLat2 = b2.minLat ?? b2.south
const maxLat2 = b2.maxLat ?? b2.north
return !(maxLng1 < minLng2 || maxLng2 < minLng1 || maxLat1 < minLat2 || maxLat2 < minLat1)
}
/**
* 频谱冲突检测台账内两两判定 同频/邻频 + 时间重叠 + 地理重叠
* spectrumLedger: createSpectrumLedgerEntry 数组
*/
export function detectSpectrumConflicts(spectrumLedger, config = {}) {
const guard = config.adjacentFreqGuardMHz != null ? config.adjacentFreqGuardMHz : defaultConflictConfig.adjacentFreqGuardMHz
const checkGeo = config.spectrumConflictGeoOverlap !== false
const list = []
for (let i = 0; i < spectrumLedger.length; i++) {
for (let j = i + 1; j < spectrumLedger.length; j++) {
const a = spectrumLedger[i]
const b = spectrumLedger[j]
if (!isSameOrAdjacentFreq(a.freqMHz, a.bandwidthMHz, b.freqMHz, b.bandwidthMHz, guard)) continue
if (!timeRangesOverlap(a.startTimeMinutes, a.endTimeMinutes, b.startTimeMinutes, b.endTimeMinutes, 0)) continue
if (checkGeo && !geoOverlap(a.geo, b.geo)) continue
list.push({
type: CONFLICT_TYPE.SPECTRUM,
subType: 'spectrum_overlap',
title: '频谱冲突',
routeIds: [],
routeNames: [a.name || `频谱${a.id}`, b.name || `频谱${b.id}`],
time: `时间重叠:K+${formatMinutes(a.startTimeMinutes)}~K+${formatMinutes(a.endTimeMinutes)} 与 K+${formatMinutes(b.startTimeMinutes)}~K+${formatMinutes(b.endTimeMinutes)}`,
position: checkGeo && a.geo ? `地理范围重叠` : '时间与频段重叠',
suggestion: `同频/邻频且时间与地理范围重叠。建议错开使用时间、调整频点或带宽、或缩小地理范围。`,
severity: 'high',
spectrumIds: [a.id, b.id]
})
}
}
return list
}
/**
* 合并并规范化冲突列表统一 idseveritycategory 等供 UI 展示
*/

51
ruoyi-ui/src/utils/timelinePosition.js

@ -77,28 +77,35 @@
return s.startPos
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60)
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 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 getPositionAlongPathSlice(s.holdEntryToExitPath, tPath)
}
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength)
}
return getPositionAlongPathSlice(s.holdPath, t)
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60)
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 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 getPositionAlongPathSlice(s.holdEntryToExitPath, tPath)
}
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength)
}
if (s.holdGeometricDist != null && s.holdGeometricDist > 0 && s.speedKmh != null) {
const speedMpMin = (s.speedKmh * 1000) / 60
const distM = (minutesFromK - s.startTime) * speedMpMin
if (distM >= s.holdGeometricDist) return s.endPos
const tPath = Math.max(0, Math.min(1, distM / s.holdGeometricDist))
return getPositionAlongPathSlice(s.holdPath, tPath)
}
return getPositionAlongPathSlice(s.holdPath, t)
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
return getPositionAlongPathSlice(s.pathSlice, t)

60
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue

@ -21,6 +21,10 @@
<span class="menu-icon">📝</span>
<span>编辑航点</span>
</div>
<div class="menu-item" v-if="isHoldWaypoint" @click="openEditHoldSpeedDialog">
<span class="menu-icon"></span>
<span>编辑盘旋速度</span>
</div>
<div class="menu-item" @click.stop="toggleAddWaypointExpand('before')">
<span class="menu-icon"></span>
<span>向前增加航点</span>
@ -90,6 +94,32 @@
</span>
</el-dialog>
<el-dialog
title="编辑盘旋速度"
:visible.sync="showHoldSpeedDialog"
width="360px"
append-to-body
:close-on-click-modal="false"
>
<el-form :model="holdSpeedForm" label-width="120px" size="small">
<el-form-item label="盘旋速度(km/h)">
<el-input-number
v-model.number="holdSpeedForm.speed"
:min="100"
:max="2000"
:precision="1"
:step="10"
style="width:100%"
/>
<div class="form-tip-small">该值用于盘旋段推演速度默认 800 km/h</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showHoldSpeedDialog = false">取消</el-button>
<el-button type="primary" @click="confirmEditHoldSpeed">确定</el-button>
</span>
</el-dialog>
<!-- 航线上锁/解锁复制单条航线推演航点右键时也显示 routeId -->
<div class="menu-section" v-if="entityData && (entityData.type === 'route' || entityData.type === 'routeWaypoint')">
<div class="menu-title">航线编辑</div>
@ -574,9 +604,11 @@ export default {
return {
expandedAddWaypoint: null,
showAddWaypointDialog: false,
showHoldSpeedDialog: false,
addWaypointDialogMode: null,
addWaypointDialogSegmentMode: null,
addWaypointForm: { segmentTargetMinutes: 10, segmentTargetSpeed: 800 },
holdSpeedForm: { speed: 800 },
showColorPickerFor: null,
showWidthPicker: false,
showSizePicker: false,
@ -620,6 +652,13 @@ export default {
if (!this.entityData || this.entityData.routeId == null) return false
return !!this.routeLocked[this.entityData.routeId]
},
isHoldWaypoint() {
const ed = this.entityData || {}
if (!ed || ed.type !== 'routeWaypoint') return false
if (ed.fromHold) return true
const pt = ed.pointType || ed.point_type || ''
return pt === 'hold_circle' || pt === 'hold_ellipse'
},
addWaypointDialogTitle() {
if (!this.addWaypointDialogMode) return '设置参数'
const dir = this.addWaypointDialogMode === 'before' ? '向前' : '向后'
@ -697,6 +736,27 @@ export default {
handleEditWaypoint() {
this.$emit('open-waypoint-dialog', this.entityData.dbId, this.entityData.routeId, this.entityData.waypointIndex)
},
openEditHoldSpeedDialog() {
const current = this.entityData && this.entityData.holdSpeed != null
? Number(this.entityData.holdSpeed)
: (this.entityData && this.entityData.speed != null ? Number(this.entityData.speed) : 800)
this.holdSpeedForm = { speed: Number.isFinite(current) && current > 0 ? current : 800 }
this.showHoldSpeedDialog = true
},
confirmEditHoldSpeed() {
const v = Number(this.holdSpeedForm.speed)
if (!Number.isFinite(v) || v <= 0) {
this.$message && this.$message.warning('请填写大于0的盘旋速度')
return
}
this.showHoldSpeedDialog = false
this.$emit('edit-hold-speed', {
routeId: this.entityData.routeId,
dbId: this.entityData.dbId,
waypointIndex: this.entityData.waypointIndex,
speed: v
})
},
toggleAddWaypointExpand(which) {
this.expandedAddWaypoint = this.expandedAddWaypoint === which ? null : which

106
ruoyi-ui/src/views/cesiumMap/index.vue

@ -59,6 +59,7 @@
@open-waypoint-dialog="handleContextMenuOpenWaypointDialog"
@add-waypoint-at="handleAddWaypointAt"
@toggle-waypoint-hold="handleToggleWaypointHold"
@edit-hold-speed="handleEditHoldSpeed"
@launch-missile="openLaunchMissileDialog"
@adjust-airspace-position="startAirspacePositionEdit"
/>
@ -4737,8 +4738,10 @@ export default {
const endLat = Cesium.Math.toDegrees(lat2)
const endLng = Cesium.Math.toDegrees(lon2)
const durationSec = d / 1000
const durationMinutes = durationSec / 60
// GanttDrawer.loadMissiles d/1000 /
const distanceKm = Number(distance) || 1000
const missileSpeedKmh = 1400
const durationMinutes = (distanceKm / missileSpeedKmh) * 60
const endK = launchK + durationMinutes
const startPoint = Cesium.Cartesian3.fromDegrees(Number(entityData.lng), Number(entityData.lat), 1000)
@ -4829,8 +4832,9 @@ export default {
const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d / R) * Math.cos(lat1), Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2))
const endLng = Cesium.Math.toDegrees(lon2)
const endLat = Cesium.Math.toDegrees(lat2)
const durationSec = d / 1000
const durationMinutes = durationSec / 60
const distanceKm = Number(distance) || 1000
const missileSpeedKmh = 1400
const durationMinutes = (distanceKm / missileSpeedKmh) * 60
const endK = launchK + durationMinutes
const startPoint = Cesium.Cartesian3.fromDegrees(startLng, startLat, 1000)
const endPoint = Cesium.Cartesian3.fromDegrees(endLng, endLat, 0)
@ -5325,14 +5329,14 @@ export default {
let platformId = 0
let platformName = '平台'
if (pickedEntity.properties) {
const now = Cesium.JulianDate.now();
//
if (pickedEntity.properties.platformId) {
platformId = pickedEntity.properties.platformId.getValue ? pickedEntity.properties.platformId.getValue(now) : pickedEntity.properties.platformId
}
if (pickedEntity.properties.platformName) {
platformName = pickedEntity.properties.platformName.getValue ? pickedEntity.properties.platformName.getValue(now) : pickedEntity.properties.platformName
}
const now = Cesium.JulianDate.now();
//
if (pickedEntity.properties.platformId) {
platformId = pickedEntity.properties.platformId.getValue ? pickedEntity.properties.platformId.getValue(now) : pickedEntity.properties.platformId
}
if (pickedEntity.properties.platformName) {
platformName = pickedEntity.properties.platformName.getValue ? pickedEntity.properties.platformName.getValue(now) : pickedEntity.properties.platformName
}
}
entityData = {
@ -5487,11 +5491,23 @@ export default {
if (dbId && dbId.getValue) dbId = dbId.getValue();
const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[rId];
const waypointIndex = ids && dbId != null ? ids.indexOf(dbId) : -1;
if (rId != null) entityData = { type: 'routeWaypoint', routeId: rId, dbId, waypointIndex };
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[rId];
const wp = routeWps && waypointIndex >= 0 ? routeWps[waypointIndex] : null;
const hp = wp ? this.parseHoldParams(wp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
if (rId != null) entityData = {
type: 'routeWaypoint',
routeId: rId,
dbId,
waypointIndex,
speed: wp && wp.speed != null ? Number(wp.speed) : null,
holdSpeed,
pointType: wp && (wp.pointType || wp.point_type)
};
} else if (isLine) {
let rId = props.routeId;
if (rId && rId.getValue) rId = rId.getValue();
if (rId) entityData = { type: 'route', routeId: rId };
if (rId) entityData = {type: 'route', routeId: rId};
}
}
}
@ -5501,24 +5517,39 @@ export default {
if (parts.length >= 4) {
const routeId = parts[2];
const segIdx = parseInt(parts[3], 10);
if (!isNaN(segIdx)) entityData = { type: 'routeWaypoint', routeId, waypointIndex: segIdx, fromHold: true };
if (!isNaN(segIdx)) {
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId];
const holdWp = routeWps && routeWps[segIdx] ? routeWps[segIdx] : null;
const hp = holdWp ? this.parseHoldParams(holdWp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
entityData = {
type: 'routeWaypoint',
routeId,
waypointIndex: segIdx,
dbId: holdWp && holdWp.id != null ? holdWp.id : null,
speed: holdWp && holdWp.speed != null ? Number(holdWp.speed) : 800,
holdSpeed,
pointType: holdWp && (holdWp.pointType || holdWp.point_type),
fromHold: true
};
}
}
}
}
// 线 allEntities routeId id
if (entityData && entityData.type === 'route' && entityData.id && !entityData.routeId) {
entityData = { ...entityData, routeId: entityData.id.replace('route-line-', '') };
entityData = {...entityData, routeId: entityData.id.replace('route-line-', '')};
}
for (const pick of drillPicks) {
const pickedEntity = pick.id || pick.object;
if (!pickedEntity || seenEntities.has(pickedEntity)) continue;
const entityData = this.resolveEntityDataFromPick(pickedEntity);
if (entityData) {
seenEntities.add(pickedEntity);
pickList.push({ entityData, pickedEntity });
if (entityData) {
seenEntities.add(pickedEntity);
pickList.push({entityData, pickedEntity});
}
}
}
}
if (pickList.length > 0) {
const { entityData, pickedEntity } = pickList[0];
const anchorCartesian = this.getContextMenuAnchorCartesian(entityData, pickedEntity, click.position);
@ -8341,6 +8372,10 @@ export default {
this.contextMenu.visible = false;
this.$emit('toggle-waypoint-hold', payload);
},
handleEditHoldSpeed(payload) {
this.contextMenu.visible = false;
this.$emit('edit-hold-speed', payload);
},
/** 开始“在航点前/后增加航点”模式:显示预览折线,左键放置、右键取消。segmentMode 可选:null(默认)、fixed_time(定时点)、fixed_speed(定速点)。segmentTargetMinutes/segmentTargetSpeed 为用户填写的固定值。 */
startAddWaypointAt(routeId, waypointIndex, mode, waypoints, segmentMode, segmentTargetMinutes, segmentTargetSpeed) {
if (!waypoints || waypoints.length === 0) return;
@ -8798,7 +8833,19 @@ export default {
let dbId = props.dbId; if (dbId && dbId.getValue) dbId = dbId.getValue();
const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[rId];
const waypointIndex = ids && dbId != null ? ids.indexOf(dbId) : -1;
if (rId != null) entityData = { type: 'routeWaypoint', routeId: rId, dbId, waypointIndex };
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[rId];
const wp = routeWps && waypointIndex >= 0 ? routeWps[waypointIndex] : null;
const hp = wp ? this.parseHoldParams(wp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
if (rId != null) entityData = {
type: 'routeWaypoint',
routeId: rId,
dbId,
waypointIndex,
speed: wp && wp.speed != null ? Number(wp.speed) : null,
holdSpeed,
pointType: wp && (wp.pointType || wp.point_type)
};
} else if (isLine) {
let rId = props.routeId; if (rId && rId.getValue) rId = rId.getValue();
if (rId) entityData = { type: 'route', routeId: rId };
@ -8810,7 +8857,22 @@ export default {
if (parts.length >= 4) {
const routeId = parts[2];
const segIdx = parseInt(parts[3], 10);
if (!isNaN(segIdx)) entityData = { type: 'routeWaypoint', routeId, waypointIndex: segIdx, fromHold: true };
if (!isNaN(segIdx)) {
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId];
const holdWp = routeWps && routeWps[segIdx] ? routeWps[segIdx] : null;
const hp = holdWp ? this.parseHoldParams(holdWp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
entityData = {
type: 'routeWaypoint',
routeId,
waypointIndex: segIdx,
dbId: holdWp && holdWp.id != null ? holdWp.id : null,
speed: holdWp && holdWp.speed != null ? Number(holdWp.speed) : 800,
holdSpeed,
pointType: holdWp && (holdWp.pointType || holdWp.point_type),
fromHold: true
};
}
}
}
}

3
ruoyi-ui/src/views/childRoom/ConflictDrawer.vue

@ -16,7 +16,6 @@
<el-option :label="$t('rightPanel.conflictTypeAll')" value="all" />
<el-option :label="$t('rightPanel.conflictTypeTime')" value="time" />
<el-option :label="$t('rightPanel.conflictTypeSpace')" value="space" />
<el-option :label="$t('rightPanel.conflictTypeSpectrum')" value="spectrum" />
</el-select>
</div>
<div class="toolbar-row">
@ -177,13 +176,11 @@ export default {
const t = type || ''
if (t === 'time') return this.$t('rightPanel.conflictTypeTime')
if (t === 'space') return this.$t('rightPanel.conflictTypeSpace')
if (t === 'spectrum') return this.$t('rightPanel.conflictTypeSpectrum')
return this.$t('rightPanel.conflictTypeAll')
},
conflictTypeTagType(type) {
if (type === 'time') return 'warning'
if (type === 'space') return 'danger'
if (type === 'spectrum') return 'primary'
return 'info'
},
onDragStart(e) {

595
ruoyi-ui/src/views/childRoom/GanttDrawer.vue

@ -1,21 +1,14 @@
<template>
<div class="gantt-shell">
<!-- 主甘特图弹窗 -->
<el-dialog
v-if="!isMinimized"
:visible.sync="dialogVisible"
width="1200px"
top="5vh"
:modal="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
custom-class="gantt-dialog gantt-dialog-fixed"
@close="handleClose"
<!-- 主甘特图面板单层可拖动/可缩放 -->
<div
v-if="dialogVisible && !isMinimized"
class="gantt-panel"
:style="panelStyle"
>
<div slot="title" class="gantt-dialog-title">
<div class="gantt-dialog-title" @mousedown.prevent="onPanelDragStart">
<span class="gantt-dialog-title-text">甘特图</span>
<span class="gantt-dialog-title-actions">
<span class="gantt-dialog-title-actions" @mousedown.stop>
<i class="gantt-dialog-header-icon el-icon-minus" title="最小化" @click="minimize"></i>
<i class="gantt-dialog-header-icon el-icon-close" title="关闭" @click="onClose"></i>
</span>
@ -27,13 +20,12 @@
<span class="k-time-range">K 时范围{{ kTimeRangeText }}</span>
</div>
<div class="toolbar-right">
<el-button size="small" @click="restoreInnerSize" title="恢复内框默认大小">恢复大小</el-button>
<el-button size="small" @click="refreshData">刷新</el-button>
<el-dropdown trigger="click" @command="handleExport">
<el-button size="small" type="primary">
导出<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-menu slot="dropdown" class="gantt-export-dropdown-menu">
<el-dropdown-item command="png">导出 PNG</el-dropdown-item>
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
</el-dropdown-menu>
@ -41,13 +33,7 @@
</div>
</div>
<!-- 内框可缩放overflow:auto 产生局部滚动条HOUR_WIDTH 不变通过滚动查看超出部分 -->
<div
class="gantt-scroll-container"
ref="ganttScrollContainer"
:style="{ width: innerWidth + 'px', height: innerHeight + 'px' }"
>
<div class="gantt-inner-content" ref="ganttInnerContent">
<div class="gantt-scroll-container" ref="ganttScrollContainer">
<div class="gantt-main">
<div class="gantt-sidebar">
<div class="sidebar-header">任务名称</div>
@ -91,16 +77,45 @@
<el-tooltip
effect="dark"
placement="top"
:append-to-body="true"
popper-class="gantt-bar-tooltip"
:open-delay="150"
:content="buildTooltipText(item)"
>
<!-- 外层占满色块区域避免 tooltip absolute 子元素时触发区域为 0 -->
<div
class="timeline-bar"
class="timeline-bar-host"
:style="{
position: 'absolute',
left: item.startPx + 'px',
top: '6px',
width: item.widthPx + 'px',
height: '32px',
zIndex: 1
}"
:title="ganttBarTitleOneLine(item)"
>
<div
class="timeline-bar"
:class="{ 'timeline-bar-editable': isBarEditable(item) }"
:style="{
left: '0',
width: '100%',
backgroundColor: item.color
}"
>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-left"
title="拖动调整开始时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'start')"
></span>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-right"
title="拖动调整结束时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'end')"
></span>
<div class="bar-inner">
<span class="bar-text bar-text-name">
{{ item.name }}
@ -110,6 +125,7 @@
</span>
</div>
</div>
</div>
</el-tooltip>
</div>
</div>
@ -118,18 +134,6 @@
<!-- 横向滚动由 .timeline-scroll 自带原生滚动条完成不再使用自定义灰条 -->
</div>
</div>
</div>
<!-- 内框左上角右下角缩放手柄仅改变本容器宽高 -->
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-tl"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeTopLeft"
></div>
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-br"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeBottomRight"
></div>
</div>
<!-- 固定图例不随内框缩放 -->
@ -146,7 +150,12 @@
</span>
</div>
</div>
</el-dialog>
<div
class="gantt-panel-resize-handle"
title="拖拽调整窗口大小"
@mousedown.prevent.stop="onPanelResizeStart"
></div>
</div>
<!-- 底部最小化状态条 -->
<transition name="fade">
@ -179,11 +188,27 @@ function minutesToKTime(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 = Math.round(abs % 60);
// 使 min=60 K+03:60
const totalMin = Math.round(abs);
const h = Math.floor(totalMin / 60);
const min = totalMin % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
function formatDurationText(startMinutes, endMinutes) {
const s = Number(startMinutes);
const e = Number(endMinutes);
if (!Number.isFinite(s) || !Number.isFinite(e)) return '--';
const deltaMin = Math.max(0, e - s);
const totalSec = Math.round(deltaMin * 60);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const sec = totalSec % 60;
if (h > 0) return `${h}小时${m}${sec}`;
if (m > 0) return `${m}${sec}`;
return `${sec}`;
}
export default {
name: 'GanttDrawer',
props: {
@ -202,11 +227,21 @@ export default {
isMinimized: false,
displayBars: [],
hourWidth: HOUR_WIDTH,
// 1200*800
innerWidth: 1168,
innerHeight: 560,
innerMinWidth: 400,
innerMinHeight: 280
panelLeft: null,
panelTop: 60,
panelWidth: 1200,
panelHeight: 800,
panelMinWidth: 860,
panelMinHeight: 460,
isPanelDragging: false,
panelDragOffsetX: 0,
panelDragOffsetY: 0,
isPanelResizing: false,
panelResizeStartX: 0,
panelResizeStartY: 0,
panelResizeStartW: 0,
panelResizeStartH: 0,
barResizeState: null
};
},
computed: {
@ -214,6 +249,17 @@ export default {
get() { return this.value; },
set(v) { this.$emit('input', v); }
},
panelStyle() {
const defaultLeft = Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20);
const left = this.panelLeft == null ? defaultLeft : this.panelLeft;
const top = this.panelTop == null ? 60 : this.panelTop;
return {
left: `${Math.max(0, left)}px`,
top: `${Math.max(0, top)}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
},
minMinutes() {
return this.timeRange && Number.isFinite(this.timeRange.minMinutes) ? this.timeRange.minMinutes : 0;
},
@ -255,6 +301,7 @@ export default {
value(v) {
if (v) {
this.isMinimized = false;
this.ensurePanelInViewport();
this.loadMissiles();
this.rebuildGanttData();
}
@ -298,10 +345,6 @@ export default {
}
},
methods: {
handleClose() {
//
this.onClose();
},
minimize() {
this.isMinimized = true;
this.dialogVisible = false;
@ -313,6 +356,62 @@ export default {
onClose() {
this.$emit('input', false);
},
ensurePanelInViewport() {
if (this.panelLeft == null) return;
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
this.panelLeft = Math.max(0, Math.min(maxLeft, this.panelLeft));
this.panelTop = Math.max(0, Math.min(maxTop, this.panelTop));
},
onPanelDragStart(e) {
if (this.isPanelResizing) return;
this.isPanelDragging = true;
const currentLeft = this.panelLeft == null ? Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20) : this.panelLeft;
const currentTop = this.panelTop == null ? 60 : this.panelTop;
this.panelDragOffsetX = e.clientX - currentLeft;
this.panelDragOffsetY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onPanelDragMove);
document.addEventListener('mouseup', this.onPanelDragEnd);
},
onPanelDragMove(e) {
if (!this.isPanelDragging) return;
e.preventDefault();
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
const left = e.clientX - this.panelDragOffsetX;
const top = e.clientY - this.panelDragOffsetY;
this.panelLeft = Math.max(0, Math.min(maxLeft, left));
this.panelTop = Math.max(0, Math.min(maxTop, top));
},
onPanelDragEnd() {
this.isPanelDragging = false;
document.removeEventListener('mousemove', this.onPanelDragMove);
document.removeEventListener('mouseup', this.onPanelDragEnd);
},
onPanelResizeStart(e) {
if (this.isPanelDragging) return;
this.isPanelResizing = true;
this.panelResizeStartX = e.clientX;
this.panelResizeStartY = e.clientY;
this.panelResizeStartW = this.panelWidth;
this.panelResizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onPanelResizeMove);
document.addEventListener('mouseup', this.onPanelResizeEnd);
},
onPanelResizeMove(e) {
if (!this.isPanelResizing) return;
e.preventDefault();
const dx = e.clientX - this.panelResizeStartX;
const dy = e.clientY - this.panelResizeStartY;
this.panelWidth = Math.max(this.panelMinWidth, Math.min(window.innerWidth - 20, this.panelResizeStartW + dx));
this.panelHeight = Math.max(this.panelMinHeight, Math.min(window.innerHeight - 20, this.panelResizeStartH + dy));
this.ensurePanelInViewport();
},
onPanelResizeEnd() {
this.isPanelResizing = false;
document.removeEventListener('mousemove', this.onPanelResizeMove);
document.removeEventListener('mouseup', this.onPanelResizeEnd);
},
rebuildGanttData() {
const list = [];
const span = this.spanMinutes || 1;
@ -330,6 +429,8 @@ export default {
color: r.color || '#409EFF',
startMinutes: r.startMinutes,
endMinutes: r.endMinutes,
actualStartKTime: minutesToKTime(r.startMinutes),
actualEndKTime: minutesToKTime(r.endMinutes),
startKTime: minutesToKTime(r.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -349,6 +450,8 @@ export default {
color: h.color || '#E6A23C',
startMinutes: h.startMinutes,
endMinutes: h.endMinutes,
actualStartKTime: minutesToKTime(h.startMinutes),
actualEndKTime: minutesToKTime(h.endMinutes),
startKTime: minutesToKTime(h.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -368,6 +471,8 @@ export default {
color: m.color || '#F56C6C',
startMinutes: m.startMinutes,
endMinutes: m.endMinutes,
actualStartKTime: minutesToKTime(m.startMinutes),
actualEndKTime: minutesToKTime(m.endMinutes),
startKTime: minutesToKTime(m.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -377,56 +482,80 @@ export default {
this.displayBars = list.sort((a, b) => a.startMinutes - b.startMinutes);
},
/** 内框右下角拖拽:仅改变 innerWidth / innerHeight,拖拽中实时重算布局 */
onInnerResizeBottomRight(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW + dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH + dy));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
isBarEditable(item) {
return item && item.type === 'route';
},
/** 内框左上角拖拽:仅改变 innerWidth / innerHeight */
onInnerResizeTopLeft(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW - dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH - dy));
onBarResizeStart(e, item, edge) {
if (!this.isBarEditable(item)) return;
this.barResizeState = {
id: item.id,
edge,
startX: e.clientX,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('mousemove', this.onBarResizeMove);
document.addEventListener('mouseup', this.onBarResizeEnd);
},
onBarResizeMove(e) {
if (!this.barResizeState) return;
e.preventDefault();
const state = this.barResizeState;
const deltaMinutes = ((e.clientX - state.startX) / this.hourWidth) * 60;
const roundedDelta = Math.round(deltaMinutes);
const minDuration = 1;
const idx = this.displayBars.findIndex(b => b.id === state.id);
if (idx < 0) return;
const target = this.displayBars[idx];
let nextStart = state.startMinutes;
let nextEnd = state.endMinutes;
if (state.edge === 'start') {
nextStart = Math.min(state.endMinutes - minDuration, state.startMinutes + roundedDelta);
} else {
nextEnd = Math.max(state.startMinutes + minDuration, state.endMinutes + roundedDelta);
}
this.applyBarMinutes(target, nextStart, nextEnd);
},
onBarResizeEnd() {
if (!this.barResizeState) return;
const state = this.barResizeState;
const target = this.displayBars.find(b => b.id === state.id);
if (target) {
this.$emit('bar-time-change', {
id: target.id,
type: target.type,
name: target.name,
startMinutes: target.startMinutes,
endMinutes: target.endMinutes
});
}
this.barResizeState = null;
document.removeEventListener('mousemove', this.onBarResizeMove);
document.removeEventListener('mouseup', this.onBarResizeEnd);
},
/** 恢复内框默认大小 */
restoreInnerSize() {
this.innerWidth = 1168;
this.innerHeight = 560;
this.$message.success('已恢复内框大小');
applyBarMinutes(bar, startMinutes, endMinutes) {
const durationMin = Math.max(0, endMinutes - startMinutes);
bar.startMinutes = startMinutes;
bar.endMinutes = endMinutes;
bar.startKTime = minutesToKTime(startMinutes);
bar.endKTime = minutesToKTime(Math.min(endMinutes, this.maxMinutes));
bar.actualStartKTime = minutesToKTime(startMinutes);
bar.actualEndKTime = minutesToKTime(endMinutes);
bar.startPx = ((startMinutes - this.minMinutes) / 60) * this.hourWidth;
bar.widthPx = Math.max(40, (durationMin / 60) * this.hourWidth);
},
buildTooltipText(item) {
return `${item.name}\n${item.startKTime}${item.endKTime}`;
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name}\n时间段:${start}${end}\n时长:${duration}`;
},
/** 浏览器原生 title(单行),作后备;悬浮块区域小时也能看到 */
ganttBarTitleOneLine(item) {
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name} | ${start}${end} | ${duration}`;
},
refreshData() {
this.loadMissiles();
@ -480,8 +609,8 @@ export default {
/** 导出用:捕获内框完整内容(含超出可视区域部分),非仅视口 */
getExportTarget() {
const container = this.$refs.ganttScrollContainer;
const content = this.$refs.ganttInnerContent;
if (container && content) return { container, content };
const content = container;
if (container) return { container, content };
return null;
},
exportPng() {
@ -593,6 +722,11 @@ export default {
this.exporting = false;
});
}
},
beforeDestroy() {
this.onPanelDragEnd();
this.onPanelResizeEnd();
this.onBarResizeEnd();
}
};
</script>
@ -601,12 +735,24 @@ export default {
.gantt-shell {
position: relative;
}
.gantt-panel {
position: fixed;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
border: 1px solid #e4e7ed;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
z-index: 2500;
overflow: hidden;
}
.gantt-dialog-body {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
background: #f7f8fa;
background: #f5f7fb;
overflow: hidden;
}
.gantt-toolbar {
@ -614,9 +760,9 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
padding: 10px 0;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 12px;
margin-bottom: 10px;
}
.toolbar-left { font-size: 13px; color: #606266; }
.toolbar-right { display: flex; gap: 8px; align-items: center; }
@ -624,16 +770,13 @@ export default {
/* 内框:可缩放,overflow:auto 产生局部滚动条 */
.gantt-scroll-container {
position: relative;
flex-shrink: 0;
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 6px;
box-shadow: 0 0 0 1px #ebeef5;
background: #fff;
}
.gantt-inner-content {
min-width: min-content;
min-height: min-content;
}
.gantt-main {
display: flex;
background: #fff;
@ -695,7 +838,7 @@ export default {
display: flex;
flex-direction: column;
}
/* 横向仍可滚动,但隐藏本层滚动条,只用下边 gantt-scroll-container 的滚动条 */
/* 横向仍可滚动;同时显示在同一层,避免“内外两层感” */
.timeline-scroll {
flex: 1;
min-height: 0;
@ -711,11 +854,6 @@ export default {
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-scroll::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.timeline-header {
flex-shrink: 0;
height: 40px;
@ -739,23 +877,14 @@ export default {
font-weight: 500;
text-align: left;
}
/* 右侧禁止任何垂直滚动条 */
.timeline-content {
flex: 1;
min-height: 0;
position: relative;
overflow-x: hidden;
overflow-y: hidden !important;
overflow-y: hidden;
background: #fff;
border-bottom: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-content::-webkit-scrollbar,
.timeline-content::-webkit-scrollbar-vertical {
display: none !important;
width: 0;
height: 0;
}
.timeline-row {
height: 44px;
@ -789,6 +918,17 @@ export default {
.timeline-scroll-clip {
border-bottom: none !important;
}
/* 色块外包一层,保证 tooltip 触发区域与色块一致 */
.timeline-bar-host {
pointer-events: auto;
}
.timeline-bar-host .timeline-bar {
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 0;
}
.timeline-bar {
position: absolute;
top: 6px;
@ -801,6 +941,38 @@ export default {
min-width: 80px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.05);
}
.timeline-bar.timeline-bar-editable {
cursor: ew-resize;
}
.bar-resize-handle {
position: absolute;
top: 0;
width: 10px;
height: 100%;
z-index: 2;
}
.bar-resize-handle-left {
left: 0;
cursor: w-resize;
}
.bar-resize-handle-right {
right: 0;
cursor: e-resize;
}
.bar-resize-handle::after {
content: '';
position: absolute;
top: 7px;
bottom: 7px;
width: 2px;
background: rgba(255, 255, 255, 0.85);
}
.bar-resize-handle-left::after {
left: 2px;
}
.bar-resize-handle-right::after {
right: 2px;
}
.bar-inner {
display: flex;
flex-direction: column;
@ -826,69 +998,22 @@ export default {
opacity: 0.9;
margin-top: 2px;
}
/* 图例:固定不随内框缩放 */
.gantt-legend {
flex-shrink: 0;
margin-top: 12px;
padding-top: 0;
margin-top: 10px;
padding: 6px 0 2px;
border-top: none;
font-size: 13px;
font-size: 12px;
line-height: 1.7;
color: #606266;
white-space: normal;
word-break: keep-all;
}
/* 内框缩放手柄 */
.gantt-inner-resize-handle {
position: absolute;
width: 20px;
height: 20px;
z-index: 10;
background: rgba(0, 0, 0, 0.05);
pointer-events: auto;
}
.gantt-inner-resize-handle-tl {
left: 0;
top: 0;
cursor: nw-resize;
border-radius: 4px 0 0 0;
}
.gantt-inner-resize-handle-tl::after {
content: '';
position: absolute;
left: 4px;
top: 4px;
width: 8px;
height: 8px;
border-left: 2px solid #909399;
border-top: 2px solid #909399;
border-radius: 2px 0 0 0;
}
.gantt-inner-resize-handle-br {
right: 0;
bottom: 0;
cursor: se-resize;
border-radius: 0 0 4px 0;
}
.gantt-inner-resize-handle-br::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 8px;
height: 8px;
border-right: 2px solid #909399;
border-bottom: 2px solid #909399;
border-radius: 0 0 2px 0;
}
.gantt-inner-resize-handle:hover {
background: rgba(0, 0, 0, 0.1);
}
.gantt-inner-resize-handle:hover::after {
border-color: #606266;
}
.legend-title { margin-right: 16px; }
.legend-item { margin-right: 16px; display: inline-flex; align-items: center; gap: 6px; }
.legend-title { margin-right: 10px; color: #909399; }
.legend-item { margin-right: 14px; display: inline-flex; align-items: center; gap: 6px; }
.legend-color {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
@ -915,6 +1040,20 @@ export default {
z-index: 3000;
border: 1px solid #e4e7ed;
}
.gantt-panel-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 20;
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.28) 50%);
}
.gantt-panel-resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.42) 50%);
}
.minibar-left {
display: flex;
align-items: center;
@ -934,107 +1073,46 @@ export default {
</style>
<style>
.gantt-dialog .el-dialog__body {
padding: 0 16px 16px;
overflow: hidden;
}
/* 时间轴区域:禁止垂直滚动条;.timeline-scroll 也隐藏横向条,只用下边容器的滚动条 */
.gantt-dialog .gantt-timeline-wrap,
.gantt-dialog .timeline-scroll-clip,
.gantt-dialog .timeline-scroll,
.gantt-dialog .timeline-content {
overflow-y: hidden !important;
scrollbar-width: none;
-ms-overflow-style: none;
}
.gantt-dialog .gantt-timeline-wrap::-webkit-scrollbar,
.gantt-dialog .timeline-scroll-clip::-webkit-scrollbar,
.gantt-dialog .timeline-scroll::-webkit-scrollbar,
.gantt-dialog .timeline-content::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.gantt-dialog .el-dialog__header {
padding: 12px 20px;
border-bottom: 1px solid #e4e7ed;
}
.gantt-dialog-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 0;
padding: 8px 12px 8px 16px;
border-bottom: 1px solid #e9edf3;
background: linear-gradient(180deg, #fafbfd 0%, #f5f8fc 100%);
cursor: move;
}
.gantt-dialog-title-text {
font-size: 18px;
color: #303133;
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #5c6778;
letter-spacing: 0.2px;
}
.gantt-dialog-title-actions {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 2px;
}
.gantt-dialog-header-icon {
width: 40px;
height: 40px;
margin: -12px 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-size: 14px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
border-radius: 4px;
border: none;
background: none;
outline: none;
}
.gantt-dialog-header-icon:hover {
color: #303133;
}
.gantt-dialog .el-dialog__headerbtn {
display: none;
}
/* 外框固定:不可缩放,固定 1200*800 */
.el-dialog.gantt-dialog.gantt-dialog-fixed {
width: 1200px !important;
height: 800px !important;
max-width: none !important;
resize: none !important;
overflow: hidden !important;
position: relative;
}
.gantt-dialog .gantt-dialog-body {
position: relative;
}
/* el-table:先清掉所有 border,再只给格子加线(甘特图内若使用 el-table 时生效) */
.gantt-dialog .el-table,
.gantt-dialog .el-table th,
.gantt-dialog .el-table td {
border: none !important;
}
.gantt-dialog .el-table::before {
display: none !important;
}
.gantt-dialog .el-table__fixed::before,
.gantt-dialog .el-table__fixed-right::before {
display: none !important;
}
.gantt-dialog .el-table__body-wrapper,
.gantt-dialog .el-table__header-wrapper {
border: none !important;
}
/* 手动给格子加线:只保留单元格之间的分隔线 */
.gantt-dialog .el-table td {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table th {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table td + td,
.gantt-dialog .el-table th + th {
border-left: 1px solid #ebeef5 !important;
background: #eef2f8;
}
.fade-enter-active,
.fade-leave-active {
@ -1044,4 +1122,15 @@ export default {
.fade-leave-to {
opacity: 0;
}
/* 挂到 body 的 tooltip,需高于甘特面板 z-index:2500 */
.gantt-bar-tooltip {
z-index: 10050 !important;
max-width: 360px;
white-space: pre-line;
line-height: 1.45;
}
/* 下拉挂到 body 时默认 z-index≈2001,低于 .gantt-panel(2500),菜单会被面板挡住 */
.gantt-export-dropdown-menu.el-dropdown-menu {
z-index: 10060 !important;
}
</style>

560
ruoyi-ui/src/views/childRoom/index.vue

@ -35,6 +35,7 @@
@add-waypoint-at="handleAddWaypointAt"
@add-waypoint-placed="handleAddWaypointPlaced"
@toggle-waypoint-hold="handleToggleWaypointHold"
@edit-hold-speed="handleEditHoldSpeed"
@waypoint-position-changed="handleWaypointPositionChanged"
@missile-deleted="handleMissileDeleted"
@scale-click="handleScaleClick"
@ -407,6 +408,7 @@
:current-room-id="currentRoomId"
:routes="routes"
:active-route-ids="activeRouteIds"
@bar-time-change="handleGanttBarTimeChange"
/>
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击4T图标时打开 -->
@ -513,7 +515,7 @@ import ConflictDrawer from './ConflictDrawer'
import WhiteboardPanel from './WhiteboardPanel'
import { createRoomWebSocket } from '@/utils/websocket';
import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle, getMissileParams, updateMissilePositions } from "@/api/system/routes";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle, getMissileParams, updateMissilePositions, saveMissileParams, deleteMissileParams } from "@/api/system/routes";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms, listRooms } from "@/api/system/rooms";
@ -527,7 +529,6 @@ import GanttDrawer from './GanttDrawer.vue';
import {
CONFLICT_TYPE,
defaultConflictConfig,
createSpectrumLedgerEntry,
normalizeConflictList
} from '@/utils/conflictDetection';
import ConflictCheckWorker from 'worker-loader!@/workers/conflictCheck.worker.js'
@ -745,10 +746,8 @@ export default {
// runConflictCheck 线
conflictCount: 0,
conflicts: [],
/** 冲突检测配置(时间缓冲、航迹最小间隔、平台摆放最小距离、禁限区关键词、频谱邻频保护等) */
/** 冲突检测配置(时间缓冲、航迹最小间隔、平台摆放最小距离、禁限区关键词等) */
conflictConfig: { ...defaultConflictConfig },
/** 频谱资源台账(用于频谱冲突检测),可后续从接口或界面维护 */
spectrumLedger: [],
/** 冲突检测 worker(非响应式对象,仅占位,实际实例挂在 this._conflictWorker) */
_conflictWorkerInited: false,
@ -924,7 +923,8 @@ export default {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || !route.waypoints.length) return;
try {
const { segments } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, null);
const pathData = this.buildPathDataForRouteTimeline(route.waypoints, routeId);
const { segments } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
if (!segments || segments.length === 0) return;
const startMinutes = segments[0].startTime;
const endMinutes = segments[segments.length - 1].endTime;
@ -955,21 +955,11 @@ export default {
ganttHoldBars() {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const bars = [];
const cesiumMap = this.$refs.cesiumMap;
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || !route.waypoints.length) return;
try {
let pathData = null;
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) {
const cachedRadii = (cesiumMap._routeHoldRadiiByRoute && cesiumMap._routeHoldRadiiByRoute[routeId]) ? cesiumMap._routeHoldRadiiByRoute[routeId] : {};
const cachedEllipse = (cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId]) ? cesiumMap._routeHoldEllipseParamsByRoute[routeId] : {};
const opts = (Object.keys(cachedRadii).length > 0 || Object.keys(cachedEllipse).length > 0) ? { holdRadiusByLegIndex: cachedRadii, holdEllipseParamsByLegIndex: cachedEllipse } : {};
const ret = cesiumMap.getRoutePathWithSegmentIndices(route.waypoints, opts);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0 && ret.holdArcRanges) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges };
}
}
const pathData = this.buildPathDataForRouteTimeline(route.waypoints, routeId);
const { segments } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
if (!segments) return;
const routeName = route.name || route.callSign || `航线${route.id}`;
@ -1513,6 +1503,113 @@ export default {
console.error(e);
}
},
/** 右键盘旋轨迹“编辑盘旋速度”:更新该盘旋航点 speed,推演按该值计算盘旋段速度(默认800)。 */
async handleEditHoldSpeed({ routeId, dbId, waypointIndex, speed }) {
if (this.isRouteLockedByOther(routeId)) {
this.$message.warning('该航线正被其他成员编辑,无法修改');
return;
}
if (this.routeLocked[routeId]) {
this.$message.info('该航线已上锁,请先解锁');
return;
}
const targetSpeed = Number(speed);
if (!Number.isFinite(targetSpeed) || targetSpeed <= 0) {
this.$message.warning('盘旋速度必须大于0');
return;
}
let route = this.routes.find(r => r.id === routeId);
let waypoints = route && route.waypoints;
if (!waypoints || waypoints.length === 0) {
try {
const res = await getRoutes(routeId);
if (res.code === 200 && res.data && res.data.waypoints) {
waypoints = res.data.waypoints;
route = { ...route, waypoints };
}
} catch (e) {
this.$message.error('获取航线失败');
return;
}
}
if (!waypoints || waypoints.length === 0) {
this.$message.warning('航线无航点');
return;
}
const wp = dbId != null ? waypoints.find(w => w.id === dbId) : waypoints[waypointIndex];
if (!wp) {
this.$message.warning('未找到该盘旋航点');
return;
}
if (!this.isHoldWaypoint(wp)) {
this.$message.warning('仅盘旋航点支持编辑盘旋速度');
return;
}
try {
const holdParamsObj = this.parseHoldParams(wp) || {
type: (wp.pointType || wp.point_type) === 'hold_ellipse' ? 'hold_ellipse' : 'hold_circle',
clockwise: true
};
const nextHoldParamsObj = { ...holdParamsObj, holdSpeed: targetSpeed };
const payload = {
id: wp.id,
routeId,
name: wp.name,
seq: wp.seq,
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
speed: wp.speed != null ? wp.speed : 800,
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
turnAngle: wp.turnAngle != null && wp.turnAngle !== '' ? Number(wp.turnAngle) : 0,
pointType: (wp.pointType || wp.point_type || 'hold_circle')
};
payload.holdParams = JSON.stringify(nextHoldParamsObj);
if (wp.segmentMode != null) payload.segmentMode = wp.segmentMode;
if (wp.segmentTargetMinutes != null) payload.segmentTargetMinutes = wp.segmentTargetMinutes;
if (wp.segmentTargetSpeed != null) payload.segmentTargetSpeed = wp.segmentTargetSpeed;
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
const roomIdParam = this.currentRoomId != null ? { roomId: this.currentRoomId } : {};
const response = await updateWaypoints(payload, roomIdParam);
if (response.code !== 200) throw new Error(response.msg || '更新失败');
const merged = { ...wp, ...payload };
const routeInList = this.routes.find(r => r.id === routeId);
if (routeInList && routeInList.waypoints) {
const idx = routeInList.waypoints.findIndex(p => p.id === wp.id);
if (idx !== -1) routeInList.waypoints.splice(idx, 1, merged);
}
if (this.selectedRouteId === routeId && this.selectedRouteDetails && this.selectedRouteDetails.waypoints) {
const idxS = this.selectedRouteDetails.waypoints.findIndex(p => p.id === wp.id);
if (idxS !== -1) this.selectedRouteDetails.waypoints.splice(idxS, 1, merged);
}
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
const r = this.routes.find(rr => rr.id === routeId);
if (r && r.waypoints) {
const roomId = this.currentRoomId;
if (roomId && r.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: r.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
if (r.waypoints.some(wp2 => this.isHoldWaypoint(wp2))) {
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());
}
}
this.$message.success(`盘旋速度已更新为 ${Math.round(targetSpeed * 10) / 10} km/h`);
this.wsConnection?.sendSyncWaypoints?.(routeId);
} catch (e) {
this.$message.error(e.msg || e.message || '更新盘旋速度失败');
console.error(e);
}
},
/** 右键「复制航线」:拉取航点后进入复制预览,左键放置后弹窗保存 */
async handleCopyRoute(routeId) {
@ -4195,6 +4292,200 @@ export default {
generateGanttChart() {
this.showGanttDrawer = true
},
parseRouteIdFromGanttBarId(barId) {
if (!barId) return null;
const s = String(barId);
if (!s.startsWith('route-')) return null;
const id = Number(s.slice(6));
return Number.isFinite(id) ? id : null;
},
transformTimelineMinutes(value, oldStart, oldEnd, newStart, newEnd) {
const v = Number(value);
const os = Number(oldStart);
const oe = Number(oldEnd);
const ns = Number(newStart);
const ne = Number(newEnd);
if (![v, os, oe, ns, ne].every(Number.isFinite)) return v;
const oldSpan = oe - os;
const newSpan = ne - ns;
if (oldSpan <= 0) return v + (ns - os);
const ratio = (v - os) / oldSpan;
return ns + ratio * newSpan;
},
getRouteOperationRoomId(route) {
const roomId = this.currentRoomId;
const isParentRoom = this.roomDetail && this.roomDetail.parentId == null;
const plan = route ? this.plans.find(p => p.id === route.scenarioId) : null;
return (isParentRoom && plan && plan.roomId) ? plan.roomId : roomId;
},
buildWaypointTimeUpdatePayload(wp, routeId, nextStartTime, nextSegmentTargetMinutes) {
const payload = {
id: wp.id,
routeId: wp.routeId != null ? wp.routeId : routeId,
name: wp.name,
seq: wp.seq,
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
speed: wp.speed,
startTime: nextStartTime != null ? nextStartTime : ((wp.startTime != null && wp.startTime !== '') ? wp.startTime : 'K+00:00:00'),
turnAngle: wp.turnAngle
};
if (wp.pointType != null) payload.pointType = wp.pointType;
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;
if (wp.segmentTargetSpeed != null) payload.segmentTargetSpeed = wp.segmentTargetSpeed;
if (nextSegmentTargetMinutes != null && Number.isFinite(nextSegmentTargetMinutes)) {
payload.segmentTargetMinutes = Number(nextSegmentTargetMinutes.toFixed(6));
} else if (wp.segmentTargetMinutes != null && wp.segmentTargetMinutes !== '') {
payload.segmentTargetMinutes = wp.segmentTargetMinutes;
}
if (wp.displayStyle != null) {
const ds = { ...wp.displayStyle };
if (nextSegmentTargetMinutes != null && Number.isFinite(nextSegmentTargetMinutes)) {
ds.segmentTargetMinutes = Number(nextSegmentTargetMinutes.toFixed(6));
}
payload.displayStyle = ds;
}
return payload;
},
async shiftRouteMissilesByGantt(roomId, routeId, platformId, oldStart, oldEnd, newStart, newEnd, waypointsAfter) {
if (roomId == null || routeId == null) return;
let missilesRes;
try {
missilesRes = await getMissileParams({ roomId, routeId, platformId });
} catch (_) {
return;
}
let missiles = missilesRes && missilesRes.data;
if (!missiles) return;
if (!Array.isArray(missiles)) missiles = [missiles];
if (missiles.length === 0) return;
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const shifted = missiles.map(m => {
const oldLaunch = m.launchTimeMinutesFromK != null ? Number(m.launchTimeMinutesFromK) : null;
if (!Number.isFinite(oldLaunch)) return null;
const nextLaunch = this.transformTimelineMinutes(oldLaunch, oldStart, oldEnd, newStart, newEnd);
const { position, nextPosition } = this.getPositionAtMinutesFromK(waypointsAfter, nextLaunch, minMinutes, maxMinutes);
const startLng = position && position.lng != null ? position.lng : m.startLng;
const startLat = position && position.lat != null ? position.lat : m.startLat;
let platformHeadingDeg = Number(m.platformHeadingDeg) || 0;
if (position && nextPosition && nextPosition.lng != null && nextPosition.lat != null) {
platformHeadingDeg = this.headingDegFromPositions(position, nextPosition);
}
return {
roomId,
routeId,
platformId,
angle: Number(m.angle) || 0,
distance: Number(m.distance) || 1000,
launchTimeMinutesFromK: Number(nextLaunch.toFixed(6)),
startLng,
startLat,
platformHeadingDeg
};
}).filter(Boolean);
if (shifted.length === 0) return;
for (let i = missiles.length - 1; i >= 0; i--) {
try {
await deleteMissileParams({ roomId, routeId, platformId, index: i });
} catch (_) {}
}
for (const item of shifted) {
try {
await saveMissileParams(item);
} catch (_) {}
}
this.handleMissileDeleted();
},
async handleGanttBarTimeChange(change) {
if (!change || change.type !== 'route') return;
const routeId = this.parseRouteIdFromGanttBarId(change.id);
if (routeId == null) return;
if (this.isRouteLockedByOther(routeId)) {
this.$message.warning('该航线正被其他成员编辑,无法通过甘特图调整时间');
return;
}
const route = this.routes.find(r => r.id === routeId);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return;
const oldBar = (this.ganttRouteBars || []).find(b => b.id === change.id);
if (!oldBar) return;
const oldStart = Number(oldBar.startMinutes);
const oldEnd = Number(oldBar.endMinutes);
const newStart = Number(change.startMinutes);
const newEnd = Number(change.endMinutes);
if (![oldStart, oldEnd, newStart, newEnd].every(Number.isFinite) || newEnd <= newStart) return;
const roomId = this.getRouteOperationRoomId(route);
const roomIdParam = roomId != null ? { roomId } : {};
const oldWpSorted = (route.waypoints || []).slice().sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
const oldToNewById = {};
let prevNewMinutes = null;
oldWpSorted.forEach(wp => {
const oldMinutes = this.waypointStartTimeToMinutesDecimal(wp.startTime);
let newMinutes = this.transformTimelineMinutes(oldMinutes, oldStart, oldEnd, newStart, newEnd);
if (prevNewMinutes != null && newMinutes <= prevNewMinutes) {
newMinutes = prevNewMinutes + 1 / 60; // 1
}
prevNewMinutes = newMinutes;
let nextSegTarget = null;
let oldSegTarget = null;
if (wp.segmentTargetMinutes != null && wp.segmentTargetMinutes !== '') oldSegTarget = Number(wp.segmentTargetMinutes);
else if (wp.displayStyle && wp.displayStyle.segmentTargetMinutes != null && wp.displayStyle.segmentTargetMinutes !== '') {
oldSegTarget = Number(wp.displayStyle.segmentTargetMinutes);
}
if (Number.isFinite(oldSegTarget)) {
nextSegTarget = this.transformTimelineMinutes(oldSegTarget, oldStart, oldEnd, newStart, newEnd);
if (nextSegTarget < newMinutes) nextSegTarget = newMinutes;
}
oldToNewById[wp.id] = {
nextStartTime: this.minutesToStartTimeWithSeconds(newMinutes),
nextStartMinutes: newMinutes,
nextSegTarget
};
});
try {
for (const wp of oldWpSorted) {
const next = oldToNewById[wp.id];
const payload = this.buildWaypointTimeUpdatePayload(wp, routeId, next.nextStartTime, next.nextSegTarget);
const res = await updateWaypoints(payload, roomIdParam);
if (!res || res.code !== 200) throw new Error('update waypoint failed');
}
const nextWaypoints = (route.waypoints || []).map(wp => {
const next = oldToNewById[wp.id];
if (!next) return wp;
const merged = { ...wp, startTime: next.nextStartTime };
if (next.nextSegTarget != null && Number.isFinite(next.nextSegTarget)) {
merged.segmentTargetMinutes = Number(next.nextSegTarget.toFixed(6));
if (merged.displayStyle) merged.displayStyle = { ...merged.displayStyle, segmentTargetMinutes: merged.segmentTargetMinutes };
}
return merged;
});
this.$set(route, 'waypoints', nextWaypoints);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
this.$set(this.selectedRouteDetails, 'waypoints', nextWaypoints.slice());
}
await this.shiftRouteMissilesByGantt(
roomId,
routeId,
route.platformId != null ? route.platformId : 0,
oldStart,
oldEnd,
newStart,
newEnd,
nextWaypoints
);
this.updateTimeFromProgress();
this.$message.success('甘特图时间已联动到航线、盘旋与导弹发射时刻');
} catch (e) {
console.warn('甘特图联动时间失败', e);
this.$message.error('联动更新时间失败,请重试');
}
},
systemDescription() {
this.$message.success('系统说明');
@ -4639,7 +4930,23 @@ export default {
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);
//
// segmentEndIndices[i+1]
// arrivalNext / effectiveTime
//
const nextWpIsHold = i + 2 < points.length && this.isHoldWaypoint(waypoints[i + 2]);
let toNextSliceEndIdx;
if (nextWpIsHold) {
const nextHoldRange = holdArcRanges[i + 1];
if (nextHoldRange && Number.isFinite(nextHoldRange.start)) {
toNextSliceEndIdx = nextHoldRange.start;
} else {
toNextSliceEndIdx = segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1;
}
} else {
toNextSliceEndIdx = segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1;
}
const toNextSlice = path.slice(exitIdx, toNextSliceEndIdx + 1);
const distToEntry = this.pathSliceDistance(toEntrySlice);
const holdWpForSegment = waypoints[i + 1];
const segTarget = holdWpForSegment && (holdWpForSegment.segmentTargetMinutes ?? holdWpForSegment.displayStyle?.segmentTargetMinutes);
@ -4660,6 +4967,8 @@ export default {
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmhForLeg);
arrivalEntry = effectiveTime[i] + travelToEntryMin;
}
// target hold
arrivalEntry = Math.max(arrivalEntry, effectiveTime[i]);
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;
@ -4685,18 +4994,29 @@ export default {
? [{ ...holdClosedLoopPath[0] }, ...holdEntryToExitRaw.slice(1)]
: holdEntryToExitRaw;
const holdExitDistanceOnLoop = this.pathSliceDistance(holdEntryToExitSlice);
const holdSpeedKmh = points[i + 1].speed || 800;
const HOLD_SPEED_KMH = 800;
const holdParamsForSpeed = this.parseHoldParams(holdWpForSegment);
const holdSpeedFromParams = Number(holdParamsForSpeed && holdParamsForSpeed.holdSpeed);
const HOLD_SPEED_KMH = (Number.isFinite(holdSpeedFromParams) && holdSpeedFromParams > 0)
? holdSpeedFromParams
: (points[i + 1].speed || 800);
const holdSpeedKmh = HOLD_SPEED_KMH;
const speedMpMin = (HOLD_SPEED_KMH * 1000) / 60;
const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin;
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) {
const geometricDist = this.pathSliceDistance(holdPathSlice) || 0;
const baseExitDistance = Math.max(0.1, holdExitDistanceOnLoop || geometricDist || 0);
const loopLen = Math.max(0.1, holdLoopLength || geometricDist || 0);
const targetHoldMinutes = Math.max(0, holdEndTime - arrivalEntry);
const targetHoldDist = targetHoldMinutes * speedMpMin;
// K
let holdN = 0;
if (targetHoldDist > baseExitDistance + 1e-6) {
holdN = Math.ceil((targetHoldDist - baseExitDistance) / loopLen);
}
const holdTotalDist = baseExitDistance + holdN * loopLen;
const segmentEndTime = arrivalEntry + holdTotalDist / speedMpMin;
if (segmentEndTime > holdEndTime + timeTolMin) {
const delaySec = Math.round((segmentEndTime - holdEndTime) * 60);
const holdWp = waypoints[i + 1];
warnings.push(`盘旋「${holdWp.name || 'WP' + (i + 2)}」:到设定时间时未在切出点,继续盘旋至切出点,实际切出将延迟 ${delaySec} 秒。`);
warnings.push(`盘旋「${holdWp.name || 'WP' + (i + 2)}」:按几何轨迹飞完需晚于该航点相对K时,实际切出将延迟约 ${delaySec} 秒。`);
holdDelayConflicts.push({
legIndex: i,
holdCenter: holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null,
@ -4705,14 +5025,55 @@ export default {
delayMinutes: segmentEndTime - holdEndTime,
delaySeconds: delaySec,
fromName: waypoints[i].name,
toName: (waypoints[i + 1] && waypoints[i + 1].name) ? waypoints[i + 1].name : `盘旋${i + 2}`
toName: (waypoints[i + 1] && waypoints[i + 1].name) ? waypoints[i + 1].name : `盘旋${i + 2}`,
arrivalEntryTime: arrivalEntry,
holdSpeedKmh: HOLD_SPEED_KMH,
holdTotalDistM: holdTotalDist,
holdBaseExitDistM: baseExitDistance,
holdLoopLengthM: loopLen,
holdN,
prevLegStartTime: effectiveTime[i],
prevLegDistanceM: distToEntry,
prevLegSpeedKmh: speedKmhForLeg,
holdSegmentMode: holdWpForSegment && holdWpForSegment.segmentMode ? holdWpForSegment.segmentMode : null,
turnAngleDeg: holdWpForSegment && holdWpForSegment.turnAngle != null ? Number(holdWpForSegment.turnAngle) : null
});
}
const distExitToNext = this.pathSliceDistance(toNextSlice);
const travelExitMin = (distExitToNext / 1000) * (60 / holdSpeedKmh);
const arrivalNext = segmentEndTime + travelExitMin;
if (i + 2 < points.length) {
const scheduledNext = points[i + 2].minutes;
if (arrivalNext > scheduledNext + timeTolMin) {
const availableMin = scheduledNext - segmentEndTime;
let requiredSpeedKmh = null;
if (availableMin > 0.001 && distExitToNext > 0) {
requiredSpeedKmh = (distExitToNext / 1000) / (availableMin / 60);
}
lateArrivalLegs.push({
legIndex: i + 1,
fromName: (waypoints[i + 1] && waypoints[i + 1].name) ? waypoints[i + 1].name : `WP${i + 2}`,
toName: (waypoints[i + 2] && waypoints[i + 2].name) ? waypoints[i + 2].name : `WP${i + 3}`,
requiredSpeedKmh: (requiredSpeedKmh != null && Number.isFinite(requiredSpeedKmh)) ? Math.ceil(requiredSpeedKmh) : null,
speedKmh: holdSpeedKmh,
actualArrival: arrivalNext,
scheduled: scheduledNext
});
} else if (arrivalNext < scheduledNext - timeTolMin) {
//
const nextPos = toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos;
segments.push({
startTime: arrivalNext,
endTime: scheduledNext,
startPos: nextPos,
endPos: nextPos,
type: 'wait',
legIndex: i + 1
});
}
}
effectiveTime[i + 1] = segmentEndTime;
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext;
if (i + 2 < points.length) effectiveTime[i + 2] = Math.max(arrivalNext, points[i + 2].minutes);
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];
@ -4740,7 +5101,8 @@ export default {
holdLoopLength,
holdExitDistanceOnLoop,
holdEntryToExitPath,
holdN: n,
holdN,
holdGeometricDist: holdTotalDist,
speedKmh: HOLD_SPEED_KMH,
holdEndTime,
holdCenter,
@ -4749,10 +5111,8 @@ export default {
holdClockwise,
holdEntryAngle
});
// fly
// 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 {
@ -4900,6 +5260,14 @@ export default {
}
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength);
}
// holdPath
if (s.holdGeometricDist != null && s.holdGeometricDist > 0 && s.speedKmh != null) {
const speedMpMin = (s.speedKmh * 1000) / 60;
const distM = (minutesFromK - s.startTime) * speedMpMin;
if (distM >= s.holdGeometricDist) return s.endPos;
const tPath = Math.max(0, Math.min(1, distM / s.holdGeometricDist));
return this.getPositionAlongPathSlice(s.holdPath, tPath);
}
return this.getPositionAlongPathSlice(s.holdPath, t);
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
@ -4921,19 +5289,30 @@ export default {
return last.endPos;
},
/**
* getPositionAtMinutesFromK 使用相同规则构建 pathData保证甘特图/冲突检测与地图推演几何一致
* 必须与推演首次取位一致不传 holdRadiusByLegIndex避免缓存半径与默认公式半径混用导致盘旋弧长甘特条与飞机位置错位
* 仅当地图上已有椭圆拟合缓存时传入 holdEllipseParamsByLegIndex与推演相同
*/
buildPathDataForRouteTimeline(waypoints, routeId) {
const cesiumMap = this.$refs.cesiumMap;
if (!waypoints || waypoints.length === 0 || !cesiumMap || !cesiumMap.getRoutePathWithSegmentIndices) return null;
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) {
return { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
return null;
},
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;盘旋半径由系统根据 k+10 落点反算,使平滑落在切点。routeId 可选,传入时会把计算半径同步给地图以实时渲染盘旋轨迹与切点进入。返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } */
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax, routeId) {
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [], earlyArrivalLegs: [], currentSegment: null };
const cesiumMap = this.$refs.cesiumMap;
let pathData = null;
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) {
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 pathData = this.buildPathDataForRouteTimeline(waypoints, routeId);
let { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
// +
// /semiMajor/semiMinor
@ -5539,7 +5918,62 @@ export default {
}
},
// 线
buildHoldDelaySuggestion(conf) {
const delaySec = Number(conf && conf.delaySeconds);
const delayMin = Number(conf && conf.delayMinutes);
const setExit = Number(conf && conf.setExitTime);
const actualExit = Number(conf && conf.actualExitTime);
const entryTime = Number(conf && conf.arrivalEntryTime);
const holdBaseDist = Number(conf && conf.holdBaseExitDistM);
const holdTotalDist = Number(conf && conf.holdTotalDistM);
const holdSpeed = Number(conf && conf.holdSpeedKmh) || 800;
const delaySecNum = Number.isFinite(delaySec)
? Math.max(1, Math.round(delaySec))
: Math.max(1, Math.round((Number.isFinite(delayMin) ? delayMin : 0) * 60));
const delaySecText = `${delaySecNum}`;
const setK = Number.isFinite(setExit) ? this.minutesToStartTimeWithSeconds(setExit) : '当前K时';
const targetK = Number.isFinite(actualExit)
? this.minutesToStartTimeWithSeconds(actualExit)
: (Number.isFinite(setExit) && Number.isFinite(delayMin)
? this.minutesToStartTimeWithSeconds(setExit + delayMin)
: '更晚时刻');
let bankLine = '② 调整转弯坡度:将该盘旋点转弯坡度提高 5°~15°(每次 5°),直到实际切出时间不晚于计划相对K时。';
if (Number.isFinite(holdBaseDist) && holdBaseDist > 0 && Number.isFinite(setExit) && Number.isFinite(entryTime)) {
const allocatedDist = Math.max(0, (setExit - entryTime) * (holdSpeed * 1000 / 60));
const ratio = allocatedDist > 0 ? (allocatedDist / holdBaseDist) : 0;
const currentTurnDeg = Number(conf && conf.turnAngleDeg);
if (ratio > 0 && ratio < 1 && Number.isFinite(currentTurnDeg) && currentTurnDeg > 0 && currentTurnDeg < 85) {
const tanCur = Math.tan((currentTurnDeg * Math.PI) / 180);
const tanNeed = tanCur / ratio;
const targetDeg = Math.atan(tanNeed) * 180 / Math.PI;
const targetDegClamped = Math.min(85, Math.max(currentTurnDeg + 1, targetDeg));
bankLine = `② 调整转弯坡度:建议由 ${Math.round(currentTurnDeg)}° 提高到约 ${Math.round(targetDegClamped)}°(可先调到 ${Math.round(Math.min(targetDegClamped, currentTurnDeg + 10))}° 复测)。`;
}
}
let holdSpeedLine = '③ 调整该点盘旋时间(盘旋速度):提高该盘旋点速度,使实际切出时间不晚于计划相对K时。';
if (Number.isFinite(holdTotalDist) && holdTotalDist > 0 && Number.isFinite(setExit) && Number.isFinite(entryTime)) {
const availableMin = setExit - entryTime;
if (availableMin > 0.001) {
const reqHoldSpeed = (holdTotalDist / 1000) / (availableMin / 60);
const reqHoldSpeedRound = Math.ceil(reqHoldSpeed);
const delta = Math.max(0, Math.ceil(reqHoldSpeedRound - holdSpeed));
holdSpeedLine = `③ 调整该点盘旋时间(盘旋速度):将该盘旋点速度调到 ≥${reqHoldSpeedRound} km/h(当前约 ${Math.round(holdSpeed)} km/h,约 +${delta}),使该盘旋段在计划相对K时前切出。`;
} else {
holdSpeedLine = '③ 调整该点盘旋时间:当前可用盘旋时间不足,请优先顺延该点相对K时。';
}
}
return [
`① 调整该点相对K时:至少顺延 ${delaySecText}${setK}${targetK})。`,
bankLine,
holdSpeedLine
].join(' ');
},
// 线Safety Column
async runConflictCheck() {
// requestId
if (!this._conflictWorkerInited) {
@ -5594,6 +6028,9 @@ export default {
(lateArrivalLegs || []).forEach(leg => {
const kTimeStr = leg.actualArrival != null && Number.isFinite(leg.actualArrival) ? this.minutesToStartTime(leg.actualArrival) : '';
const part2 = kTimeStr ? ` ② 或将下一航点相对K时调至 ${kTimeStr} 或更晚` : ' ② 或将下一航点相对K时调晚';
const speedPart = (leg.requiredSpeedKmh != null && Number.isFinite(leg.requiredSpeedKmh))
? `① 将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h`
: '① 当前可用时间已不足,单纯提速无法满足到达时刻';
allRaw.push({
type: CONFLICT_TYPE.TIME,
subType: 'late_arrival',
@ -5602,11 +6039,12 @@ export default {
routeIds: [routeId],
fromWaypoint: leg.fromName,
toWaypoint: leg.toName,
suggestion: `① 将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h${part2} ③ 调整上游航段速度或时间`,
suggestion: `${speedPart}${part2} ③ 调整上游航段速度或时间`,
severity: 'high'
});
});
(holdDelayConflicts || []).forEach(conf => {
const holdSuggestion = this.buildHoldDelaySuggestion(conf);
allRaw.push({
type: CONFLICT_TYPE.TIME,
subType: 'hold_delay',
@ -5617,7 +6055,7 @@ export default {
toWaypoint: conf.toName,
time: this.minutesToStartTime(conf.setExitTime),
position: conf.holdCenter ? `经度 ${conf.holdCenter.lng.toFixed(5)}°, 纬度 ${conf.holdCenter.lat.toFixed(5)}°` : undefined,
suggestion: `实际切出将延迟 ${conf.delaySeconds} 秒。① 延长该盘旋点相对K时 ② 定时盘旋调转弯半径,非定时调上一航点速度或本点相对K时 ③ 微调上下游航点相对K时`,
suggestion: holdSuggestion,
severity: 'high',
holdCenter: conf.holdCenter,
positionLng: conf.holdCenter && conf.holdCenter.lng,
@ -5626,13 +6064,7 @@ export default {
});
});
} else {
let pathData = null;
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(route.waypoints);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
}
const pathData = this.buildPathDataForRouteTimeline(route.waypoints, routeId);
const timeline = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
routeIdToTimeline[routeId] = {
segments: timeline.segments,
@ -5669,6 +6101,9 @@ export default {
(lateArrivalLegs || []).forEach(leg => {
const kTimeStr = leg.actualArrival != null && Number.isFinite(leg.actualArrival) ? this.minutesToStartTime(leg.actualArrival) : '';
const part2 = kTimeStr ? ` ② 或将下一航点相对K时调至 ${kTimeStr} 或更晚` : ' ② 或将下一航点相对K时调晚';
const speedPart = (leg.requiredSpeedKmh != null && Number.isFinite(leg.requiredSpeedKmh))
? `① 将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h`
: '① 当前可用时间已不足,单纯提速无法满足到达时刻';
allRaw.push({
type: CONFLICT_TYPE.TIME,
subType: 'late_arrival',
@ -5677,11 +6112,12 @@ export default {
routeIds: [routeId],
fromWaypoint: leg.fromName,
toWaypoint: leg.toName,
suggestion: `① 将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h${part2} ③ 调整上游航段速度或时间`,
suggestion: `${speedPart}${part2} ③ 调整上游航段速度或时间`,
severity: 'high'
});
});
(holdDelayConflicts || []).forEach(conf => {
const holdSuggestion = this.buildHoldDelaySuggestion(conf);
allRaw.push({
type: CONFLICT_TYPE.TIME,
subType: 'hold_delay',
@ -5692,7 +6128,7 @@ export default {
toWaypoint: conf.toName,
time: this.minutesToStartTime(conf.setExitTime),
position: conf.holdCenter ? `经度 ${conf.holdCenter.lng.toFixed(5)}°, 纬度 ${conf.holdCenter.lat.toFixed(5)}°` : undefined,
suggestion: `实际切出将延迟 ${conf.delaySeconds} 秒。① 延长该盘旋点相对K时 ② 定时盘旋调转弯半径,非定时调上一航点速度或本点相对K时 ③ 微调上下游航点相对K时`,
suggestion: holdSuggestion,
severity: 'high',
holdCenter: conf.holdCenter,
positionLng: conf.holdCenter && conf.holdCenter.lng,
@ -5706,7 +6142,7 @@ export default {
if (idx % 8 === 7) await new Promise(r => setTimeout(r, 0));
}
// ---------- // worker ----------
// ---------- / worker ----------
const platformIcons = (this.$refs.cesiumMap && this.$refs.cesiumMap.getPlatformIconPositions)
? this.$refs.cesiumMap.getPlatformIconPositions()
: [];
@ -5714,6 +6150,12 @@ export default {
? (((this.$refs.cesiumMap.getFrontendDrawingsData() || {}).entities) || [])
: [];
const routeNamesById = {}
routeIdsWithTimeline.forEach((rid) => {
const rr = this.routes.find(r => r.id === rid)
routeNamesById[rid] = (rr && rr.name) ? rr.name : `航线${rid}`
})
const workerRaw = await this._runConflictWorkerOnce({
requestId,
routeIds: routeIdsWithTimeline,
@ -5721,9 +6163,9 @@ export default {
maxMinutes,
config,
routeTimelines: routeIdToTimeline,
routeNamesById,
platformIcons,
drawingsEntities,
spectrumLedger: this.spectrumLedger || []
drawingsEntities
});
if (workerRaw && Array.isArray(workerRaw)) {
@ -5805,7 +6247,9 @@ export default {
const step = n > 60 ? Math.ceil(n / 60) : 1; // 60
for (let i = 0; i < n; i += step) {
const w = wps[i] || {};
const s = `${take(w.lng)},${take(w.lat)},${take(w.alt)},${take(w.startTime)},${take(w.speed)},${take(w.pointType || w.point_type)},${take(w.segmentMode)},${take(w.segmentTargetMinutes || (w.displayStyle && w.displayStyle.segmentTargetMinutes))}`
const holdRaw = w.holdParams != null ? w.holdParams : w.hold_params
const holdStr = typeof holdRaw === 'string' ? holdRaw : (holdRaw != null ? JSON.stringify(holdRaw) : '')
const s = `${take(w.lng)},${take(w.lat)},${take(w.alt)},${take(w.startTime)},${take(w.speed)},${take(w.pointType || w.point_type)},${take(w.segmentMode)},${take(w.segmentTargetMinutes || (w.displayStyle && w.displayStyle.segmentTargetMinutes))},${take(holdStr)}`
for (let k = 0; k < s.length; k++) {
h = ((h << 5) + h) ^ s.charCodeAt(k);
}

69
ruoyi-ui/src/workers/conflictCheck.worker.js

@ -4,7 +4,9 @@ import {
defaultConflictConfig,
detectPlatformPlacementTooClose,
detectRestrictedZoneIntrusion,
detectSpectrumConflicts,
detectSafetyColumnSpatial,
detectSafetyColumnTemporal,
formatKLabel,
parseRestrictedZonesFromDrawings
} from '@/utils/conflictDetection'
import { getPositionFromTimeline } from '@/utils/timelinePosition'
@ -65,6 +67,30 @@ function bboxesOverlap(a, b) {
return !(a.maxLng < b.minLng || b.maxLng < a.minLng || a.maxLat < b.minLat || b.maxLat < a.minLat || a.maxAlt < b.minAlt || b.maxAlt < a.minAlt)
}
/** 从 timeline.segments 取当前时刻所在段的 speedKmh,用于间距不足时的速度建议 */
function getSpeedKmhFromTimeline(timelines, routeId, minutesFromK) {
const tl = timelines[routeId]
if (!tl || !Array.isArray(tl.segments) || tl.segments.length === 0) return 800
for (let i = 0; i < tl.segments.length; i++) {
const s = tl.segments[i]
if (minutesFromK >= s.startTime && minutesFromK < s.endTime) {
const sp = Number(s.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
}
const last = tl.segments[tl.segments.length - 1]
if (minutesFromK >= last.endTime) {
const sp = Number(last.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
const first = tl.segments[0]
if (minutesFromK < first.startTime) {
const sp = Number(first.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
return 800
}
self.onmessage = (evt) => {
const msg = evt && evt.data ? evt.data : {}
const { requestId } = msg
@ -76,7 +102,7 @@ self.onmessage = (evt) => {
const routeTimelines = msg.routeTimelines || {}
const platformIcons = Array.isArray(msg.platformIcons) ? msg.platformIcons : []
const drawingsEntities = Array.isArray(msg.drawingsEntities) ? msg.drawingsEntities : []
const spectrumLedger = Array.isArray(msg.spectrumLedger) ? msg.spectrumLedger : []
const routeNamesById = (msg.routeNamesById && typeof msg.routeNamesById === 'object') ? msg.routeNamesById : {}
const getPos = (routeId, minutesFromK) => {
const c = routeTimelines[routeId]
@ -93,9 +119,16 @@ self.onmessage = (evt) => {
}
const filteredRouteIds = routeIds.filter(rid => !!expanded[rid])
const safetyR = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters
const expandedSafety = {}
for (const rid of routeIds) {
expandedSafety[rid] = expandBboxByMeters(bboxes[rid], 2 * safetyR)
}
// detectTrackSeparation 内部仍是两两循环,这里通过“剔除 bbox 不交的航线对”来降低平均复杂度
// 做法:把 routeIds 按 bbox 相交关系分组(简化:两两判定时跳过),通过 getPos 闭包实现。
const canPair = (a, b) => bboxesOverlap(expanded[a], expanded[b])
const canPairSafety = (a, b) => bboxesOverlap(expandedSafety[a], expandedSafety[b])
const getPosWithPairHint = (routeId, minutesFromK) => getPos(routeId, minutesFromK)
@ -136,7 +169,7 @@ self.onmessage = (evt) => {
subType: 'track_separation',
title: '航迹间隔过小',
routeIds: [ridA, ridB],
time: `K+${(t >= 0 ? '+' : '-')}${String(Math.floor(Math.abs(t) / 60)).padStart(2, '0')}:${String(Math.floor(Math.abs(t) % 60)).padStart(2, '0')}`,
time: `${formatKLabel(t)}`,
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
severity: 'high',
@ -164,10 +197,34 @@ self.onmessage = (evt) => {
}
const restrictedConflicts = detectRestrictedZoneIntrusion(filteredRouteIds, minMinutes, maxMinutes, getPos, restrictedZones, config)
// 频谱冲突
const spectrumConflicts = (spectrumLedger && spectrumLedger.length >= 2) ? detectSpectrumConflicts(spectrumLedger, config) : []
const getSpeedKmhAtMinutes = (rid, mins) => getSpeedKmhFromTimeline(routeTimelines, rid, mins)
let safetySpatial = []
let safetyTemporal = []
if (filteredRouteIds.length >= 2) {
safetySpatial = detectSafetyColumnSpatial(
filteredRouteIds,
minMinutes,
maxMinutes,
getPos,
config,
routeNamesById,
canPairSafety,
getSpeedKmhAtMinutes
)
}
if (filteredRouteIds.length >= 2) {
safetyTemporal = detectSafetyColumnTemporal(
filteredRouteIds,
minMinutes,
maxMinutes,
getPos,
config,
routeNamesById
)
}
const allRaw = [...trackConflicts, ...placementConflicts, ...restrictedConflicts, ...spectrumConflicts]
const allRaw = [...trackConflicts, ...placementConflicts, ...restrictedConflicts, ...safetySpatial, ...safetyTemporal]
self.postMessage({ requestId, ok: true, conflicts: allRaw })
} catch (e) {
self.postMessage({ requestId, ok: false, error: (e && e.message) ? e.message : String(e) })

Loading…
Cancel
Save