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}