diff --git a/ruoyi-ui/src/utils/conflictDetection.js b/ruoyi-ui/src/utils/conflictDetection.js new file mode 100644 index 0000000..b5244cb --- /dev/null +++ b/ruoyi-ui/src/utils/conflictDetection.js @@ -0,0 +1,431 @@ +/** + * 冲突检测工具:时间冲突、空间冲突、频谱冲突 + * - 时间:航线时间窗重叠、无法按时到达、提前到达、盘旋时间不足、资源占用缓冲 + * - 空间:航迹最小间隔、摆放平台距离过小、禁限区入侵(考虑高度带) + * - 频谱:同频/邻频 + 时间 + 带宽 + 地理范围叠加判定,支持频谱资源台账 + */ + +/** 冲突类型 */ +export const CONFLICT_TYPE = { + TIME: 'time', + SPACE: 'space', + SPECTRUM: 'spectrum' +} + +/** 默认冲突配置(可由界面配置覆盖) */ +export const defaultConflictConfig = { + // 时间 + timeWindowOverlapMinutes: 0, // 时间窗重叠判定:两航线时间窗重叠即报(0=任意重叠) + resourceBufferMinutes: 0, // 资源占用前后缓冲(分钟) + // 空间 + minTrackSeparationMeters: 5000, // 推演中两平台航迹最小间隔(米) + minPlatformPlacementMeters: 3000, // 摆放平台图标最小间距(米) + trackSampleStepMinutes: 1, // 航迹采样步长(分钟) + restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区 + useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax) + // 频谱 + adjacentFreqGuardMHz: 5, // 邻频保护间隔(MHz),两频段中心差小于此视为邻频冲突 + spectrumConflictGeoOverlap: true // 频谱冲突是否要求地理范围叠加 +} + +/** + * 计算两点地表距离(米),近似球面 + */ +export function distanceMeters(lng1, lat1, alt1, lng2, lat2, alt2) { + 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)) + const horizontal = R * c + const dAlt = (alt2 != null ? Number(alt2) : 0) - (alt1 != null ? Number(alt1) : 0) + return Math.sqrt(horizontal * horizontal + dAlt * dAlt) +} + +/** + * 两段时间段是否重叠 [s1,e1] 与 [s2,e2](分钟数) + */ +export function timeRangesOverlap(s1, e1, s2, e2, bufferMinutes = 0) { + const a1 = s1 - bufferMinutes + const b1 = e1 + bufferMinutes + const a2 = s2 - bufferMinutes + const b2 = e2 + bufferMinutes + return !(b1 < a2 || b2 < a1) +} + +/** + * 时间冲突:航线时间窗重叠 + * routes: [{ id, name, waypoints: [{ startTime }] }],waypoints 需含 startTime(如 K+00:10) + * waypointStartTimeToMinutes: (startTimeStr) => number + */ +export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, config = {}) { + const buffer = config.resourceBufferMinutes != null ? config.resourceBufferMinutes : defaultConflictConfig.resourceBufferMinutes + const list = [] + const timeRanges = routes.map(r => { + if (!r.waypoints || r.waypoints.length === 0) return { routeId: r.id, routeName: r.name || `航线${r.id}`, min: 0, max: 0 } + const minutes = r.waypoints.map(w => waypointStartTimeToMinutes(w.startTime)).filter(m => Number.isFinite(m)) + const min = minutes.length ? Math.min(...minutes) : 0 + const max = minutes.length ? Math.max(...minutes) : 0 + return { routeId: r.id, routeName: r.name || `航线${r.id}`, min, max } + }) + for (let i = 0; i < timeRanges.length; i++) { + for (let j = i + 1; j < timeRanges.length; j++) { + const a = timeRanges[i] + const b = timeRanges[j] + if (a.min === a.max && b.min === b.max) continue + if (timeRangesOverlap(a.min, a.max, b.min, b.max, buffer)) { + list.push({ + type: CONFLICT_TYPE.TIME, + subType: 'time_window_overlap', + 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)} 重叠`, + suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。' + }) + } + } + } + return list +} + +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')}` +} + +/** + * 空间冲突:推演航迹最小间隔 + * getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null + * routeIds: 参与推演的航线 id 列表 + * minMinutes, maxMinutes: 推演时间范围(相对 K 分钟) + */ +export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, config = {}) { + const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters + const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes + const list = [] + 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] + 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 d = distanceMeters(posA.lng, posA.lat, posA.alt, posB.lng, posB.lat, posB.alt) + if (d < minSep) { + list.push({ + type: CONFLICT_TYPE.SPACE, + subType: 'track_separation', + title: '航迹间隔过小', + routeIds: [ridA, ridB], + time: `约 K+${formatMinutes(t)}`, + position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`, + suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`, + severity: 'high', + positionLng: (posA.lng + posB.lng) / 2, + positionLat: (posA.lat + posB.lat) / 2, + positionAlt: (posA.alt + posB.alt) / 2, + minutesFromK: t, + distanceMeters: d + }) + break // 同一对航线只报一次(取首次发生时刻) + } + } + } + } + return list +} + +/** + * 空间冲突:摆放平台图标距离过小 + * platformIcons: [{ id, lng, lat, name? }] + */ +export function detectPlatformPlacementTooClose(platformIcons, config = {}) { + const minDist = config.minPlatformPlacementMeters != null ? config.minPlatformPlacementMeters : defaultConflictConfig.minPlatformPlacementMeters + const list = [] + for (let i = 0; i < platformIcons.length; i++) { + for (let j = i + 1; j < platformIcons.length; j++) { + const a = platformIcons[i] + const b = platformIcons[j] + const lng1 = Number(a.lng) + const lat1 = Number(a.lat) + const lng2 = Number(b.lng) + const lat2 = Number(b.lat) + if (!Number.isFinite(lng1) || !Number.isFinite(lat1) || !Number.isFinite(lng2) || !Number.isFinite(lat2)) continue + const d = distanceMeters(lng1, lat1, 0, lng2, lat2, 0) + if (d < minDist) { + list.push({ + type: CONFLICT_TYPE.SPACE, + subType: 'platform_placement', + title: '平台摆放距离过小', + routeIds: [], + routeNames: [a.name || `平台${a.id}`, b.name || `平台${b.id}`], + position: `经度 ${((lng1 + lng2) / 2).toFixed(5)}°, 纬度 ${((lat1 + lat2) / 2).toFixed(5)}°`, + suggestion: `两平台距离约 ${(d / 1000).toFixed(1)} km,小于最小摆放间隔 ${minDist / 1000} km。建议移动其中一方或增大最小间隔配置。`, + severity: 'medium', + positionLng: (lng1 + lng2) / 2, + positionLat: (lat1 + lat2) / 2, + positionAlt: 0, + distanceMeters: d + }) + } + } + } + return list +} + +/** + * 点是否在多边形内(二维,不考虑高度) + * points: [{ lng, lat }] 或 [{ x, y }],顺时针或逆时针 + */ +export function pointInPolygon(lng, lat, points) { + if (!points || points.length < 3) return false + const x = Number(lng) + const y = Number(lat) + let inside = false + const n = points.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const pi = points[i] + const pj = points[j] + const xi = pi.lng != null ? Number(pi.lng) : Number(pi.x) + const yi = pi.lat != null ? Number(pi.lat) : Number(pi.y) + const xj = pj.lng != null ? Number(pj.lng) : Number(pj.x) + const yj = pj.lat != null ? Number(pj.lat) : Number(pj.y) + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside + } + return inside +} + +/** + * 空间冲突:禁限区入侵(考虑高度带) + * 每条航线在推演时间内采样位置,若落入禁限区多边形且高度在 [altMin, altMax] 内则报 + * getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } + * restrictedZones: [{ name, points: [{lng,lat}], altMin?, altMax? }],points 可从 frontend_drawings 中 type 为 polygon 且 name 含禁限关键词的实体解析 + */ +export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, restrictedZones, config = {}) { + const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes + const useAlt = config.useRestrictedAltitudeBand !== false + const list = [] + if (!restrictedZones || restrictedZones.length === 0) return list + for (const rid of routeIds) { + for (let t = minMinutes; t <= maxMinutes; t += step) { + const pos = getPositionAtMinutesFromK(rid, t) + if (!pos || pos.lng == null) continue + const alt = pos.alt != null ? Number(pos.alt) : 0 + for (const zone of restrictedZones) { + const points = zone.points || zone.positions + if (!points || points.length < 3) continue + const inPoly = pointInPolygon(pos.lng, pos.lat, points) + if (!inPoly) continue + const altMin = zone.altMin != null ? Number(zone.altMin) : -Infinity + const altMax = zone.altMax != null ? Number(zone.altMax) : Infinity + const inAlt = !useAlt || (alt >= altMin && alt <= altMax) + if (inAlt) { + list.push({ + type: CONFLICT_TYPE.SPACE, + subType: 'restricted_zone', + title: '禁限区入侵', + routeIds: [rid], + zoneName: zone.name || '禁限区', + time: `约 K+${formatMinutes(t)}`, + position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`, + suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`, + severity: 'high', + positionLng: pos.lng, + positionLat: pos.lat, + positionAlt: alt, + minutesFromK: t + }) + break // 该航线该时刻只报一个区 + } + } + } + } + return list +} + +/** + * 从 frontend_drawings 的 entities 中解析禁限区(polygon/rectangle/circle/sector) + * entity: { type, label/name, points/positions, data: { altMin, altMax } } + */ +export function parseRestrictedZonesFromDrawings(entities, keywords = defaultConflictConfig.restrictedZoneNameKeywords) { + const zones = [] + if (!entities || !Array.isArray(entities)) return zones + const nameMatches = (name) => { + const n = (name || '').toString() + return keywords.some(kw => n.includes(kw)) + } + for (const e of entities) { + const name = (e.label || e.name || (e.data && e.data.name) || '').toString() + if (!nameMatches(name)) continue + let points = e.points || (e.data && e.data.points) || (e.positions && e.positions.map(p => { + if (p.lng != null) return { lng: p.lng, lat: p.lat } + if (p.x != null) return { lng: p.x, lat: p.y } + return null + }).filter(Boolean)) + if (!points && e.data) { + if (e.type === 'circle' && e.data.center) { + const c = e.data.center + const r = (e.data.radius || 0) / 111320 // 约米转度 + const lng = c.lng != null ? c.lng : c.x + const lat = c.lat != null ? c.lat : c.y + const n = 32 + points = [] + for (let i = 0; i <= n; i++) { + const angle = (i / n) * 2 * Math.PI + points.push({ lng: lng + r * Math.cos(angle), lat: lat + r * Math.sin(angle) }) + } + } else if (e.type === 'rectangle' && (e.data.bounds || e.data.coordinates || (e.data.points && e.data.points.length >= 2))) { + const b = e.data.bounds || e.data.coordinates || {} + const west = b.west ?? b.minLng + const south = b.south ?? b.minLat + const east = b.east ?? b.maxLng + const north = b.north ?? b.maxLat + if (e.data.points && e.data.points.length >= 2) { + points = e.data.points + } else if (west != null && south != null && east != null && north != null) { + points = [ + { lng: west, lat: south }, + { lng: east, lat: south }, + { lng: east, lat: north }, + { lng: west, lat: north } + ] + } + } + } + if (!points || points.length < 3) continue + const altMin = e.data && e.data.altMin != null ? e.data.altMin : undefined + const altMax = e.data && e.data.altMax != null ? e.data.altMax : undefined + zones.push({ name, points, altMin, altMax }) + } + 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 展示 + */ +export function normalizeConflictList(conflictsByType, startId = 1) { + const list = [] + let id = startId + for (const c of conflictsByType) { + list.push({ + id: id++, + type: c.type || CONFLICT_TYPE.TIME, + subType: c.subType || '', + title: c.title || '冲突', + routeName: c.routeNames && c.routeNames.length ? c.routeNames.join('、') : c.routeName, + routeIds: c.routeIds || [], + fromWaypoint: c.fromWaypoint, + toWaypoint: c.toWaypoint, + time: c.time, + position: c.position, + suggestion: c.suggestion, + severity: c.severity || 'high', + positionLng: c.positionLng, + positionLat: c.positionLat, + positionAlt: c.positionAlt, + minutesFromK: c.minutesFromK, + holdCenter: c.holdCenter, + ...c + }) + } + return list +} diff --git a/ruoyi-ui/src/views/childRoom/ConflictDrawer.vue b/ruoyi-ui/src/views/childRoom/ConflictDrawer.vue new file mode 100644 index 0000000..ce1000d --- /dev/null +++ b/ruoyi-ui/src/views/childRoom/ConflictDrawer.vue @@ -0,0 +1,445 @@ + + + + +