2 changed files with 299 additions and 0 deletions
@ -0,0 +1,123 @@ |
|||
/** |
|||
* 与推演时间轴相关的“取点”工具函数。 |
|||
* 该文件用于在主线程/Worker 共享一套插值逻辑,避免在大数据量冲突检测时重复实现。 |
|||
*/ |
|||
|
|||
/** 两点间近似距离(米),含高度差 */ |
|||
export function segmentDistanceMeters(a, b) { |
|||
const R = 6371000 |
|||
const lat1 = (Number(a.lat) * Math.PI) / 180 |
|||
const lat2 = (Number(b.lat) * Math.PI) / 180 |
|||
const dlat = ((Number(b.lat) - Number(a.lat)) * Math.PI) / 180 |
|||
const dlng = ((Number(b.lng) - Number(a.lng)) * Math.PI) / 180 |
|||
const sinDLat = Math.sin(dlat / 2) |
|||
const sinDLng = Math.sin(dlng / 2) |
|||
const aa = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng |
|||
const c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa)) |
|||
const horizontal = R * c |
|||
const dalt = (Number(b.alt) || 0) - (Number(a.alt) || 0) |
|||
return Math.sqrt(horizontal * horizontal + dalt * dalt) |
|||
} |
|||
|
|||
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */ |
|||
export function getPositionAlongPathSlice(pathSlice, t) { |
|||
if (!pathSlice || pathSlice.length === 0) return null |
|||
if (pathSlice.length === 1 || t <= 0) return pathSlice[0] |
|||
if (t >= 1) return pathSlice[pathSlice.length - 1] |
|||
|
|||
let totalLen = 0 |
|||
const lengths = [0] |
|||
for (let i = 1; i < pathSlice.length; i++) { |
|||
totalLen += segmentDistanceMeters(pathSlice[i - 1], pathSlice[i]) |
|||
lengths.push(totalLen) |
|||
} |
|||
const targetDist = t * totalLen |
|||
let idx = 0 |
|||
while (idx < lengths.length - 1 && lengths[idx + 1] < targetDist) idx++ |
|||
|
|||
const p0 = pathSlice[idx] |
|||
const p1 = pathSlice[idx + 1] |
|||
const segLen = lengths[idx + 1] - lengths[idx] |
|||
const segT = segLen > 0 ? (targetDist - lengths[idx]) / segLen : 0 |
|||
return { |
|||
lng: Number(p0.lng) + (Number(p1.lng) - Number(p0.lng)) * segT, |
|||
lat: Number(p0.lat) + (Number(p1.lat) - Number(p0.lat)) * segT, |
|||
alt: (Number(p0.alt) || 0) + ((Number(p1.alt) || 0) - (Number(p0.alt) || 0)) * segT |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold。 |
|||
* segments: buildRouteTimeline 产物(在主线程构建后传入 worker 使用) |
|||
*/ |
|||
export function getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) { |
|||
if (!segments || segments.length === 0) return null |
|||
if (minutesFromK <= segments[0].startTime) return segments[0].startPos |
|||
|
|||
const last = segments[segments.length - 1] |
|||
if (minutesFromK >= last.endTime) { |
|||
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) { |
|||
return path[segmentEndIndices[last.legIndex]] |
|||
} |
|||
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1] |
|||
return last.endPos |
|||
} |
|||
|
|||
for (let i = 0; i < segments.length; i++) { |
|||
const s = segments[i] |
|||
if (minutesFromK < s.endTime) { |
|||
const denom = (s.endTime - s.startTime) |
|||
const t = denom > 0 ? Math.max(0, Math.min(1, (minutesFromK - s.startTime) / denom)) : 0 |
|||
|
|||
if (s.type === 'wait') { |
|||
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|||
const endIdx = segmentEndIndices[s.legIndex] |
|||
if (path[endIdx]) return path[endIdx] |
|||
} |
|||
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 === 'fly' && s.pathSlice && s.pathSlice.length) { |
|||
return getPositionAlongPathSlice(s.pathSlice, t) |
|||
} |
|||
|
|||
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|||
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1] |
|||
const endIdx = segmentEndIndices[s.legIndex] |
|||
const pathSlice = path.slice(startIdx, endIdx + 1) |
|||
if (pathSlice.length > 0) return getPositionAlongPathSlice(pathSlice, t) |
|||
} |
|||
|
|||
return { |
|||
lng: Number(s.startPos.lng) + (Number(s.endPos.lng) - Number(s.startPos.lng)) * t, |
|||
lat: Number(s.startPos.lat) + (Number(s.endPos.lat) - Number(s.startPos.lat)) * t, |
|||
alt: (Number(s.startPos.alt) || 0) + ((Number(s.endPos.alt) || 0) - (Number(s.startPos.alt) || 0)) * t |
|||
} |
|||
} |
|||
} |
|||
|
|||
return last.endPos |
|||
} |
|||
@ -0,0 +1,176 @@ |
|||
/* eslint-disable no-restricted-globals */ |
|||
import { |
|||
CONFLICT_TYPE, |
|||
defaultConflictConfig, |
|||
detectPlatformPlacementTooClose, |
|||
detectRestrictedZoneIntrusion, |
|||
detectSpectrumConflicts, |
|||
parseRestrictedZonesFromDrawings |
|||
} from '@/utils/conflictDetection' |
|||
import { getPositionFromTimeline } from '@/utils/timelinePosition' |
|||
|
|||
function safeCloneConfig(cfg) { |
|||
return { ...defaultConflictConfig, ...(cfg || {}) } |
|||
} |
|||
|
|||
/** |
|||
* 计算推演时间范围内每条航线的 bbox(用于剪枝)。 |
|||
* 这里用“稀疏采样”快速估计,避免额外大开销;真正冲突判定仍用 detectTrackSeparation 的逐时刻取点。 |
|||
*/ |
|||
function buildRouteBboxes(routeIds, minMinutes, maxMinutes, stepMinutes, getPos) { |
|||
const bboxes = {} |
|||
const step = Math.max(1, Number(stepMinutes) || 1) * 5 // bbox 用更粗的步长
|
|||
for (const rid of routeIds) { |
|||
let minLng = Infinity, minLat = Infinity, minAlt = Infinity |
|||
let maxLng = -Infinity, maxLat = -Infinity, maxAlt = -Infinity |
|||
let has = false |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) { |
|||
const p = getPos(rid, t) |
|||
if (!p || p.lng == null || p.lat == null) continue |
|||
has = true |
|||
const lng = Number(p.lng) |
|||
const lat = Number(p.lat) |
|||
const alt = Number(p.alt) || 0 |
|||
if (lng < minLng) minLng = lng |
|||
if (lat < minLat) minLat = lat |
|||
if (alt < minAlt) minAlt = alt |
|||
if (lng > maxLng) maxLng = lng |
|||
if (lat > maxLat) maxLat = lat |
|||
if (alt > maxAlt) maxAlt = alt |
|||
} |
|||
if (has) bboxes[rid] = { minLng, minLat, minAlt, maxLng, maxLat, maxAlt } |
|||
} |
|||
return bboxes |
|||
} |
|||
|
|||
// 近似把“米”转成经纬度差,用于 bbox 扩张(越靠近极地误差越大;此处仅剪枝用)
|
|||
function expandBboxByMeters(b, meters) { |
|||
if (!b) return null |
|||
const m = Number(meters) || 0 |
|||
const degLat = m / 111320 |
|||
const midLat = (b.minLat + b.maxLat) / 2 |
|||
const degLng = m / (111320 * Math.max(0.2, Math.cos((midLat * Math.PI) / 180))) |
|||
return { |
|||
minLng: b.minLng - degLng, |
|||
maxLng: b.maxLng + degLng, |
|||
minLat: b.minLat - degLat, |
|||
maxLat: b.maxLat + degLat, |
|||
minAlt: b.minAlt - m, |
|||
maxAlt: b.maxAlt + m |
|||
} |
|||
} |
|||
|
|||
function bboxesOverlap(a, b) { |
|||
if (!a || !b) return true |
|||
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) |
|||
} |
|||
|
|||
self.onmessage = (evt) => { |
|||
const msg = evt && evt.data ? evt.data : {} |
|||
const { requestId } = msg |
|||
try { |
|||
const routeIds = Array.isArray(msg.routeIds) ? msg.routeIds : [] |
|||
const minMinutes = Number(msg.minMinutes) || 0 |
|||
const maxMinutes = Number(msg.maxMinutes) || 0 |
|||
const config = safeCloneConfig(msg.config) |
|||
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 getPos = (routeId, minutesFromK) => { |
|||
const c = routeTimelines[routeId] |
|||
if (!c || !c.segments || c.segments.length === 0) return null |
|||
return getPositionFromTimeline(c.segments, minutesFromK, c.path || null, c.segmentEndIndices || null) |
|||
} |
|||
|
|||
// 先用 bbox 剪枝减少成对比较(不会影响正确性,只会“少算不可能冲突”的航线对)
|
|||
const bboxes = buildRouteBboxes(routeIds, minMinutes, maxMinutes, config.trackSampleStepMinutes, getPos) |
|||
const expanded = {} |
|||
const expandMeters = config.minTrackSeparationMeters != null ? Number(config.minTrackSeparationMeters) : defaultConflictConfig.minTrackSeparationMeters |
|||
for (const rid of routeIds) { |
|||
expanded[rid] = expandBboxByMeters(bboxes[rid], expandMeters) |
|||
} |
|||
const filteredRouteIds = routeIds.filter(rid => !!expanded[rid]) |
|||
|
|||
// detectTrackSeparation 内部仍是两两循环,这里通过“剔除 bbox 不交的航线对”来降低平均复杂度
|
|||
// 做法:把 routeIds 按 bbox 相交关系分组(简化:两两判定时跳过),通过 getPos 闭包实现。
|
|||
const canPair = (a, b) => bboxesOverlap(expanded[a], expanded[b]) |
|||
|
|||
const getPosWithPairHint = (routeId, minutesFromK) => getPos(routeId, minutesFromK) |
|||
|
|||
// 空间冲突:航迹最小间隔(内部会 break 取首次冲突,较省)
|
|||
const trackConflicts = [] |
|||
if (filteredRouteIds.length >= 2) { |
|||
const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters |
|||
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes |
|||
const sampleTimes = [] |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t) |
|||
for (let i = 0; i < filteredRouteIds.length; i++) { |
|||
for (let j = i + 1; j < filteredRouteIds.length; j++) { |
|||
const ridA = filteredRouteIds[i] |
|||
const ridB = filteredRouteIds[j] |
|||
if (!canPair(ridA, ridB)) continue |
|||
for (const t of sampleTimes) { |
|||
const posA = getPosWithPairHint(ridA, t) |
|||
const posB = getPosWithPairHint(ridB, t) |
|||
if (!posA || !posB || posA.lng == null || posB.lng == null) continue |
|||
// 复用 conflictDetection.js 的距离计算,避免重复
|
|||
// detectTrackSeparation 暴露了 distanceMeters,但这里为了少导出,直接调用 detectTrackSeparation 可能会再两两循环一次。
|
|||
// 因此这里走“局部实现”:用 detectTrackSeparation 的返回结构保持一致。
|
|||
// 通过动态 import 会增加开销;此处直接内联 distanceMeters 逻辑(与 conflictDetection.js 一致)。
|
|||
const R = 6371000 |
|||
const toRad = x => (x * Math.PI) / 180 |
|||
const φ1 = toRad(posA.lat) |
|||
const φ2 = toRad(posB.lat) |
|||
const Δφ = toRad(posB.lat - posA.lat) |
|||
const Δλ = toRad(posB.lng - posA.lng) |
|||
const aa = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2 |
|||
const c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa)) |
|||
const horizontal = R * c |
|||
const dAlt = (posB.alt != null ? Number(posB.alt) : 0) - (posA.alt != null ? Number(posA.alt) : 0) |
|||
const d = Math.sqrt(horizontal * horizontal + dAlt * dAlt) |
|||
if (d < minSep) { |
|||
trackConflicts.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
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')}`, |
|||
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 |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 空间冲突:摆放平台距离过小(相对轻量)
|
|||
const placementConflicts = detectPlatformPlacementTooClose(platformIcons, config) |
|||
|
|||
// 空间冲突:禁限区入侵
|
|||
let restrictedZones = [] |
|||
if (drawingsEntities && drawingsEntities.length > 0) { |
|||
restrictedZones = parseRestrictedZonesFromDrawings(drawingsEntities, config.restrictedZoneNameKeywords || defaultConflictConfig.restrictedZoneNameKeywords) |
|||
restrictedZones = restrictedZones.map(z => ({ ...z, points: z.points || (z.data && z.data.points) || [] })).filter(z => z.points && z.points.length >= 3) |
|||
} |
|||
const restrictedConflicts = detectRestrictedZoneIntrusion(filteredRouteIds, minMinutes, maxMinutes, getPos, restrictedZones, config) |
|||
|
|||
// 频谱冲突
|
|||
const spectrumConflicts = (spectrumLedger && spectrumLedger.length >= 2) ? detectSpectrumConflicts(spectrumLedger, config) : [] |
|||
|
|||
const allRaw = [...trackConflicts, ...placementConflicts, ...restrictedConflicts, ...spectrumConflicts] |
|||
self.postMessage({ requestId, ok: true, conflicts: allRaw }) |
|||
} catch (e) { |
|||
self.postMessage({ requestId, ok: false, error: (e && e.message) ? e.message : String(e) }) |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue