/** * 冲突检测工具:时间冲突、空间冲突、频谱冲突 * - 时间:航线时间窗重叠、无法按时到达、提前到达、盘旋时间不足、资源占用缓冲 * - 空间:航迹最小间隔、摆放平台距离过小、禁限区入侵(考虑高度带) * - 频谱:同频/邻频 + 时间 + 带宽 + 地理范围叠加判定,支持频谱资源台账 */ /** 冲突类型 */ 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 }