diff --git a/ruoyi-ui/src/utils/timelinePosition.js b/ruoyi-ui/src/utils/timelinePosition.js new file mode 100644 index 0000000..9bd934e --- /dev/null +++ b/ruoyi-ui/src/utils/timelinePosition.js @@ -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 + } diff --git a/ruoyi-ui/src/workers/conflictCheck.worker.js b/ruoyi-ui/src/workers/conflictCheck.worker.js new file mode 100644 index 0000000..8487b17 --- /dev/null +++ b/ruoyi-ui/src/workers/conflictCheck.worker.js @@ -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) }) + } +} +