From fb74df5c1f017cb1eaf53ebf14c45ff78da6a563 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 16 Mar 2026 08:23:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(prediction):=20=EB=8B=A4=EA=B0=81=ED=98=95?= =?UTF-8?q?/=EC=9B=90=20=EC=98=A4=EC=97=BC=EB=B6=84=EC=84=9D=20+=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=EC=B5=9C=EC=86=8C=ED=99=94=20+=20Convex?= =?UTF-8?q?=20Hull=20=EB=A9=B4=EC=A0=81=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오염분석 버튼을 다각형 분석 / 원 분석으로 분리 - 다각형 분석: Convex Hull(Graham Scan) + Shoelace 알고리즘으로 확산 입자 외곽 다각형 면적(km²), 둘레(km), 꼭짓점 수 계산 - 원 분석: 향후 오픈 예정 팝업 - geo.ts에 convexHull, polygonAreaKm2, analyzeSpillPolygon 함수 추가 - OilSpillView → RightPanel에 oilTrajectory prop 전달 - 지도 범례에 최소화/펼치기 토글 버튼 추가 - CheckboxLabel 중복 className 경고 수정 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/common/components/map/MapView.tsx | 173 ++++++++++-------- frontend/src/common/utils/geo.ts | 95 ++++++++++ .../prediction/components/OilSpillView.tsx | 2 +- .../tabs/prediction/components/RightPanel.tsx | 62 ++++++- 4 files changed, 248 insertions(+), 84 deletions(-) diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index d276b06..6877a38 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1060,96 +1060,115 @@ interface MapLegendProps { } function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { + const [minimized, setMinimized] = useState(false) + if (dispersionResult && incidentCoord) { return ( -
-
-
📍
-
-

사고 위치

-
- {incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E +
+ {/* 헤더 + 최소화 버튼 */} +
setMinimized(!minimized)}> + 범례 + {minimized ? '▶' : '▼'} +
+ {!minimized && ( +
+
+
📍
+
+

사고 위치

+
+ {incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E +
+
+
+
+
+ 물질 + {dispersionResult.substance} +
+
+ 풍향 + SW {dispersionResult.windDirection}° +
+
+ 확산 구역 + {dispersionResult.zones.length}개 +
+
+
+
위험 구역
+
+
+
+ 치명적 위험 구역 (AEGL-3) +
+
+
+ 높은 위험 구역 (AEGL-2) +
+
+
+ 중간 위험 구역 (AEGL-1) +
+
+
+
+
🧭
+ 풍향 (방사형)
-
-
-
- 물질 - {dispersionResult.substance} -
-
- 풍향 - SW {dispersionResult.windDirection}° -
-
- 확산 구역 - {dispersionResult.zones.length}개 -
-
-
-
위험 구역
-
-
-
- 치명적 위험 구역 (AEGL-3) -
-
-
- 높은 위험 구역 (AEGL-2) -
-
-
- 중간 위험 구역 (AEGL-1) -
-
-
-
-
🧭
- 풍향 (방사형) -
+ )}
) } if (oilTrajectory.length > 0) { return ( -
-

범례

-
- {Array.from(selectedModels).map(model => ( -
-
- {model} -
- ))} - {selectedModels.size === 3 && ( -
- (앙상블 모드) -
- )} -
-
-
- 사고 지점 -
- {boomLines.length > 0 && ( - <> +
+ {/* 헤더 + 최소화 버튼 */} +
setMinimized(!minimized)}> +

범례

+ {minimized ? '▶' : '▼'} +
+ {!minimized && ( +
+
+ {Array.from(selectedModels).map(model => ( +
+
+ {model} +
+ ))} + {selectedModels.size === 3 && ( +
+ (앙상블 모드) +
+ )}
-
- 긴급 오일펜스 +
+ 사고 지점
-
-
- 중요 오일펜스 -
-
-
- 보통 오일펜스 -
- - )} -
+ {boomLines.length > 0 && ( + <> +
+
+
+ 긴급 오일펜스 +
+
+
+ 중요 오일펜스 +
+
+
+ 보통 오일펜스 +
+ + )} +
+
+ )}
) } diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index b522dd8..9c1c9b1 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -4,6 +4,101 @@ const DEG2RAD = Math.PI / 180 const RAD2DEG = 180 / Math.PI const EARTH_RADIUS = 6371000 // meters +// ============================================================ +// Convex Hull + 면적 계산 +// ============================================================ + +interface LatLon { lat: number; lon: number } + +/** Convex Hull (Graham Scan) — 입자 좌표 배열 → 외곽 다각형 좌표 반환 */ +export function convexHull(points: LatLon[]): LatLon[] { + if (points.length < 3) return [...points] + + // 가장 아래(lat 최소) 점 찾기 (동일하면 lon 최소) + const sorted = [...points].sort((a, b) => a.lat - b.lat || a.lon - b.lon) + const pivot = sorted[0] + + // pivot 기준 극각으로 정렬 + const rest = sorted.slice(1).sort((a, b) => { + const angleA = Math.atan2(a.lon - pivot.lon, a.lat - pivot.lat) + const angleB = Math.atan2(b.lon - pivot.lon, b.lat - pivot.lat) + if (angleA !== angleB) return angleA - angleB + // 같은 각도면 거리 순 + const dA = (a.lat - pivot.lat) ** 2 + (a.lon - pivot.lon) ** 2 + const dB = (b.lat - pivot.lat) ** 2 + (b.lon - pivot.lon) ** 2 + return dA - dB + }) + + const hull: LatLon[] = [pivot] + for (const p of rest) { + while (hull.length >= 2) { + const a = hull[hull.length - 2] + const b = hull[hull.length - 1] + const cross = (b.lon - a.lon) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lon - a.lon) + if (cross <= 0) hull.pop() + else break + } + hull.push(p) + } + return hull +} + +/** Shoelace 공식으로 다각형 면적 계산 (km²) — 위경도 좌표를 미터 변환 후 계산 */ +export function polygonAreaKm2(polygon: LatLon[]): number { + if (polygon.length < 3) return 0 + + // 중심 기준 위경도 → 미터 변환 + const centerLat = polygon.reduce((s, p) => s + p.lat, 0) / polygon.length + const mPerDegLat = 111320 + const mPerDegLon = 111320 * Math.cos(centerLat * DEG2RAD) + + const pts = polygon.map(p => ({ + x: (p.lon - polygon[0].lon) * mPerDegLon, + y: (p.lat - polygon[0].lat) * mPerDegLat, + })) + + // Shoelace + let area = 0 + for (let i = 0; i < pts.length; i++) { + const j = (i + 1) % pts.length + area += pts[i].x * pts[j].y + area -= pts[j].x * pts[i].y + } + return Math.abs(area) / 2 / 1_000_000 // m² → km² +} + +/** 오일 궤적 입자 → Convex Hull 외곽 다각형 + 면적 + 둘레 계산 */ +export function analyzeSpillPolygon(trajectory: LatLon[]): { + hull: LatLon[] + areaKm2: number + perimeterKm: number + particleCount: number +} { + if (trajectory.length < 3) { + return { hull: [], areaKm2: 0, perimeterKm: 0, particleCount: trajectory.length } + } + + const hull = convexHull(trajectory) + const areaKm2 = polygonAreaKm2(hull) + + // 둘레 계산 + let perimeter = 0 + for (let i = 0; i < hull.length; i++) { + const j = (i + 1) % hull.length + perimeter += haversineDistance( + { lat: hull[i].lat, lon: hull[i].lon }, + { lat: hull[j].lat, lon: hull[j].lon }, + ) + } + + return { + hull, + areaKm2, + perimeterKm: perimeter / 1000, + particleCount: trajectory.length, + } +} + /** 두 좌표 간 Haversine 거리 (m) */ export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number { const dLat = (p2.lat - p1.lat) * DEG2RAD diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 2bac89b..82e4d17 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -636,7 +636,7 @@ export function OilSpillView() {
{/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />} + {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} oilTrajectory={oilTrajectory} />} {/* 재계산 모달 */} void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null }) { +interface RightPanelProps { + onOpenBacktrack?: () => void + onOpenRecalc?: () => void + onOpenReport?: () => void + detail?: PredictionDetail | null + oilTrajectory?: Array<{ lat: number; lon: number; time: number }> +} + +export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, oilTrajectory = [] }: RightPanelProps) { const vessel = detail?.vessels?.[0] const vessel2 = detail?.vessels?.[1] const spill = detail?.spill const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null const [shipExpanded, setShipExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false) + const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null) return (
@@ -35,9 +45,50 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail {/* 오염분석 */}
- +
+ + +
+ {polygonResult && ( +
+
📐 Convex Hull 다각형 분석 결과
+
+
+
{polygonResult.areaKm2.toFixed(2)}
+
오염 면적 (km²)
+
+
+
{polygonResult.perimeterKm.toFixed(1)}
+
외곽 둘레 (km)
+
+
+
{polygonResult.particleCount.toLocaleString()}
+
분석 입자 수
+
+
+
{polygonResult.hullPoints}
+
외곽 꼭짓점
+
+
+
+ )}
{/* 오염 종합 상황 */} @@ -226,8 +277,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str {children}