- {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)
-
-
-
-
-
๐งญ
-
ํํฅ (๋ฐฉ์ฌํ)
-
+ )}
-
๋ฒ๋ก
-
- {Array.from(selectedModels).map(model => (
-
- ))}
- {selectedModels.size === 3 && (
-
- (์์๋ธ ๋ชจ๋)
-
- )}
-
-
- {boomLines.length > 0 && (
- <>
+
+ {/* ํค๋ + ์ต์ํ ๋ฒํผ */}
+
setMinimized(!minimized)}>
+
๋ฒ๋ก
+ {minimized ? 'โถ' : 'โผ'}
+
+ {!minimized && (
+
+
+ {Array.from(selectedModels).map(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() {