wing-ops/frontend/src/common/utils/geo.ts
Nan Kyung Lee dc82574635 feat(prediction): 오염분석 다각형/원 분석 기능 구현
- 오염분석 섹션을 탭 UI로 개편 (다각형 분석 / 원 분석)
- 다각형 분석: 지도 클릭으로 꼭짓점 추가 후 분석 실행
- 원 분석: NM 프리셋 버튼(1·3·5·10·15·20·30·50) + 직접 입력, 사고지점 기준 자동 계산
- 분석 결과: 분석면적·오염비율·오염면적·해상잔존량·연안부착량·민감자원 개소 표시
- MapView: 다각형(PolygonLayer) / 원(ScatterplotLayer) 실시간 지도 시각화
- geo.ts: pointInPolygon, polygonAreaKm2, circleAreaKm2 유틸 함수 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:19:30 +09:00

303 lines
11 KiB
TypeScript
Executable File

import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '../types/boomLine'
const DEG2RAD = Math.PI / 180
const RAD2DEG = 180 / Math.PI
const EARTH_RADIUS = 6371000 // meters
/** 두 좌표 간 Haversine 거리 (m) */
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
const dLat = (p2.lat - p1.lat) * DEG2RAD
const dLon = (p2.lon - p1.lon) * DEG2RAD
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1.lat * DEG2RAD) * Math.cos(p2.lat * DEG2RAD) * Math.sin(dLon / 2) ** 2
return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
/** 두 좌표 간 방위각 (degrees, 0=북, 시계방향) */
export function computeBearing(from: BoomLineCoord, to: BoomLineCoord): number {
const dLon = (to.lon - from.lon) * DEG2RAD
const fromLat = from.lat * DEG2RAD
const toLat = to.lat * DEG2RAD
const y = Math.sin(dLon) * Math.cos(toLat)
const x = Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(dLon)
return ((Math.atan2(y, x) * RAD2DEG) + 360) % 360
}
/** 기준점에서 방위각+거리로 목적점 계산 */
export function destinationPoint(origin: BoomLineCoord, bearingDeg: number, distanceM: number): BoomLineCoord {
const brng = bearingDeg * DEG2RAD
const lat1 = origin.lat * DEG2RAD
const lon1 = origin.lon * DEG2RAD
const d = distanceM / EARTH_RADIUS
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng))
const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2))
return { lat: lat2 * RAD2DEG, lon: lon2 * RAD2DEG }
}
/** 폴리라인 총 길이 (m) */
export function computePolylineLength(coords: BoomLineCoord[]): number {
let total = 0
for (let i = 1; i < coords.length; i++) {
total += haversineDistance(coords[i - 1], coords[i])
}
return total
}
/** 두 선분 교차 판정 (2D) */
export function segmentsIntersect(
a1: BoomLineCoord, a2: BoomLineCoord,
b1: BoomLineCoord, b2: BoomLineCoord
): boolean {
const cross = (o: BoomLineCoord, a: BoomLineCoord, b: BoomLineCoord) =>
(a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon)
const d1 = cross(b1, b2, a1)
const d2 = cross(b1, b2, a2)
const d3 = cross(a1, a2, b1)
const d4 = cross(a1, a2, b2)
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true
}
// Collinear cases
const onSegment = (p: BoomLineCoord, q: BoomLineCoord, r: BoomLineCoord) =>
Math.min(p.lon, r.lon) <= q.lon && q.lon <= Math.max(p.lon, r.lon) &&
Math.min(p.lat, r.lat) <= q.lat && q.lat <= Math.max(p.lat, r.lat)
if (d1 === 0 && onSegment(b1, a1, b2)) return true
if (d2 === 0 && onSegment(b1, a2, b2)) return true
if (d3 === 0 && onSegment(a1, b1, a2)) return true
if (d4 === 0 && onSegment(a1, b2, a2)) return true
return false
}
/** AI 자동 배치 생성 */
export function generateAIBoomLines(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
incident: BoomLineCoord,
settings: AlgorithmSettings
): BoomLine[] {
if (trajectory.length === 0) return []
// 1. 최종 시간 입자들의 중심점 → 주요 확산 방향
const maxTime = Math.max(...trajectory.map(p => p.time))
const finalPoints = trajectory.filter(p => p.time === maxTime)
if (finalPoints.length === 0) return []
const centroid: BoomLineCoord = {
lat: finalPoints.reduce((s, p) => s + p.lat, 0) / finalPoints.length,
lon: finalPoints.reduce((s, p) => s + p.lon, 0) / finalPoints.length,
}
const mainBearing = computeBearing(incident, centroid)
const totalDist = haversineDistance(incident, centroid)
// 입자 분산 폭 계산 (최종 시간 기준)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perpBearing = (mainBearing + 90) % 360
let maxSpread = 0
for (const p of finalPoints) {
const bearing = computeBearing(incident, p)
const dist = haversineDistance(incident, p)
const perpDist = dist * Math.abs(Math.sin((bearing - mainBearing) * DEG2RAD))
if (perpDist > maxSpread) maxSpread = perpDist
}
const correction = settings.currentOrthogonalCorrection
const waveFactor = settings.waveHeightCorrectionFactor
// 2. 1차 방어선 (긴급) - 30% 지점, 해류 직교
const dist1 = totalDist * 0.3
const center1 = destinationPoint(incident, mainBearing, dist1)
const halfLen1 = Math.max(600, maxSpread * 0.4)
const line1Left = destinationPoint(center1, (mainBearing + 90 + correction) % 360, halfLen1)
const line1Right = destinationPoint(center1, (mainBearing - 90 + correction + 360) % 360, halfLen1)
const coords1 = [line1Left, line1Right]
const eff1 = Math.min(95, Math.max(60, 92 / waveFactor))
// 3. 2차 방어선 (중요) - 50% 지점, U형 3포인트
const dist2 = totalDist * 0.5
const center2 = destinationPoint(incident, mainBearing, dist2)
const halfLen2 = Math.max(450, maxSpread * 0.5)
const line2Left = destinationPoint(center2, (mainBearing + 90 + correction) % 360, halfLen2)
const line2Right = destinationPoint(center2, (mainBearing - 90 + correction + 360) % 360, halfLen2)
const line2Front = destinationPoint(center2, mainBearing, halfLen2 * 0.5)
const coords2 = [line2Left, line2Front, line2Right]
const eff2 = Math.min(90, Math.max(55, 85 / waveFactor))
// 4. 3차 방어선 (보통) - 80% 지점, 해안선 평행
const dist3 = totalDist * 0.8
const center3 = destinationPoint(incident, mainBearing, dist3)
const halfLen3 = Math.max(350, maxSpread * 0.6)
const line3Left = destinationPoint(center3, (mainBearing + 90) % 360, halfLen3)
const line3Right = destinationPoint(center3, (mainBearing - 90 + 360) % 360, halfLen3)
const coords3 = [line3Left, line3Right]
const eff3 = Math.min(85, Math.max(50, 78 / waveFactor))
const boomLines: BoomLine[] = [
{
id: `boom-ai-1-${Date.now()}`,
name: '1차 방어선 (고강도 차단형)',
priority: 'CRITICAL',
type: '고강도 차단형',
coords: coords1,
length: computePolylineLength(coords1),
angle: (mainBearing + 90 + correction) % 360,
efficiency: Math.round(eff1),
status: 'PLANNED',
},
{
id: `boom-ai-2-${Date.now()}`,
name: '2차 방어선 (외해용 포위망)',
priority: 'HIGH',
type: '외해용 중형 포위망',
coords: coords2,
length: computePolylineLength(coords2),
angle: (mainBearing + 90 + correction) % 360,
efficiency: Math.round(eff2),
status: 'PLANNED',
},
{
id: `boom-ai-3-${Date.now()}`,
name: '3차 방어선 (연안 경량형)',
priority: 'MEDIUM',
type: '연안 경량형',
coords: coords3,
length: computePolylineLength(coords3),
angle: (mainBearing + 90) % 360,
efficiency: Math.round(eff3),
status: 'PLANNED',
},
]
// 최소 효율 필터 경고 (라인은 유지하되 표시용)
for (const line of boomLines) {
if (line.efficiency < settings.minContainmentEfficiency) {
line.name += ' ⚠'
}
}
return boomLines
}
/** Ray casting — 점이 다각형 내부인지 판정 */
export function pointInPolygon(
point: { lat: number; lon: number },
polygon: { lat: number; lon: number }[]
): boolean {
if (polygon.length < 3) return false
let inside = false
const x = point.lon
const y = point.lat
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lon, yi = polygon[i].lat
const xj = polygon[j].lon, yj = polygon[j].lat
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
if (intersect) inside = !inside
}
return inside
}
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
if (polygon.length < 3) return 0
const n = polygon.length
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
const cosLat = Math.cos(latCenter * DEG2RAD)
let area = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
area += x1 * y2 - x2 * y1
}
return Math.abs(area) / 2
}
/** 원 면적 (km²) */
export function circleAreaKm2(radiusM: number): number {
return Math.PI * (radiusM / 1000) ** 2
}
/** 차단 시뮬레이션 실행 */
export function runContainmentAnalysis(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
boomLines: BoomLine[]
): ContainmentResult {
if (trajectory.length === 0 || boomLines.length === 0) {
return {
totalParticles: 0,
blockedParticles: 0,
passedParticles: 0,
overallEfficiency: 0,
perLineResults: [],
}
}
// 입자별 그룹핑
const particleMap = new Map<number, Array<{ lat: number; lon: number; time: number }>>()
for (const pt of trajectory) {
const pid = pt.particle ?? 0
if (!particleMap.has(pid)) particleMap.set(pid, [])
particleMap.get(pid)!.push(pt)
}
// 입자별 시간 정렬
for (const points of particleMap.values()) {
points.sort((a, b) => a.time - b.time)
}
const perLineBlocked = new Map<string, Set<number>>()
for (const line of boomLines) {
perLineBlocked.set(line.id, new Set())
}
const blockedParticleIds = new Set<number>()
// 각 입자의 이동 경로와 오일펜스 라인 교차 판정
for (const [pid, points] of particleMap) {
for (let i = 0; i < points.length - 1; i++) {
const segA1: BoomLineCoord = { lat: points[i].lat, lon: points[i].lon }
const segA2: BoomLineCoord = { lat: points[i + 1].lat, lon: points[i + 1].lon }
for (const line of boomLines) {
for (let j = 0; j < line.coords.length - 1; j++) {
if (segmentsIntersect(segA1, segA2, line.coords[j], line.coords[j + 1])) {
blockedParticleIds.add(pid)
perLineBlocked.get(line.id)!.add(pid)
}
}
}
}
}
const totalParticles = particleMap.size
const blocked = blockedParticleIds.size
const passed = totalParticles - blocked
return {
totalParticles,
blockedParticles: blocked,
passedParticles: passed,
overallEfficiency: totalParticles > 0 ? Math.round((blocked / totalParticles) * 100) : 0,
perLineResults: boomLines.map(line => {
const lineBlocked = perLineBlocked.get(line.id)!.size
return {
boomLineId: line.id,
boomLineName: line.name,
blocked: lineBlocked,
passed: totalParticles - lineBlocked,
efficiency: totalParticles > 0 ? Math.round((lineBlocked / totalParticles) * 100) : 0,
}
}),
}
}