From c1c6712bb9f0a2b3fbc6142288420e8b34e38074 Mon Sep 17 00:00:00 2001
From: menghao <1584479611@qq.com>
Date: Tue, 24 Mar 2026 13:19:16 +0800
Subject: [PATCH] =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=E5=9C=86?=
=?UTF-8?q?=E6=9F=B1=E4=BD=93=E3=80=81=E7=94=98=E7=89=B9=E5=9B=BE=E6=8B=96?=
=?UTF-8?q?=E6=8B=BD=E6=94=B9=E6=97=B6=E9=97=B4=E3=80=81=E7=9B=98=E6=97=8B?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ruoyi-ui/src/lang/en.js | 1 -
ruoyi-ui/src/lang/zh.js | 1 -
ruoyi-ui/src/utils/conflictDetection.js | 338 ++++++++++----
ruoyi-ui/src/utils/timelinePosition.js | 51 +-
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue | 60 +++
ruoyi-ui/src/views/cesiumMap/index.vue | 106 ++++-
ruoyi-ui/src/views/childRoom/ConflictDrawer.vue | 3 -
ruoyi-ui/src/views/childRoom/GanttDrawer.vue | 595 ++++++++++++++----------
ruoyi-ui/src/views/childRoom/index.vue | 560 +++++++++++++++++++---
ruoyi-ui/src/workers/conflictCheck.worker.js | 69 ++-
10 files changed, 1316 insertions(+), 468 deletions(-)
diff --git a/ruoyi-ui/src/lang/en.js b/ruoyi-ui/src/lang/en.js
index bc7fd5b..30275b0 100644
--- a/ruoyi-ui/src/lang/en.js
+++ b/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'
diff --git a/ruoyi-ui/src/lang/zh.js b/ruoyi-ui/src/lang/zh.js
index eb5e276..b948664 100644
--- a/ruoyi-ui/src/lang/zh.js
+++ b/ruoyi-ui/src/lang/zh.js
@@ -127,7 +127,6 @@ export default {
conflictTypeAll: '全部',
conflictTypeTime: '时间',
conflictTypeSpace: '空间',
- conflictTypeSpectrum: '频谱',
air: '空中',
sea: '海上',
ground: '地面'
diff --git a/ruoyi-ui/src/utils/conflictDetection.js b/ruoyi-ui/src/utils/conflictDetection.js
index 289e3fd..fb54358 100644
--- a/ruoyi-ui/src/utils/conflictDetection.js
+++ b/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
-}
-
/**
* 合并并规范化冲突列表,统一 id、severity、category 等供 UI 展示
*/
diff --git a/ruoyi-ui/src/utils/timelinePosition.js b/ruoyi-ui/src/utils/timelinePosition.js
index 9bd934e..373df54 100644
--- a/ruoyi-ui/src/utils/timelinePosition.js
+++ b/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)
diff --git a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
index 4f13a65..cd7149e 100644
--- a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
+++ b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
@@ -21,6 +21,10 @@
编辑航点
+
@@ -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();
}
};
@@ -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 {
diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue
index 7c06b3a..3eff97f 100644
--- a/ruoyi-ui/src/views/childRoom/index.vue
+++ b/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"
/>
@@ -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);
}
diff --git a/ruoyi-ui/src/workers/conflictCheck.worker.js b/ruoyi-ui/src/workers/conflictCheck.worker.js
index 8487b17..aeedaf6 100644
--- a/ruoyi-ui/src/workers/conflictCheck.worker.js
+++ b/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) })