- 오염분석 섹션을 탭 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>
303 lines
11 KiB
TypeScript
Executable File
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,
|
|
}
|
|
}),
|
|
}
|
|
}
|