feat(prediction): 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산

- 오염분석 버튼을 다각형 분석 / 원 분석으로 분리
- 다각형 분석: 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) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-16 08:23:22 +09:00
부모 b25eccee37
커밋 fb74df5c1f
4개의 변경된 파일248개의 추가작업 그리고 84개의 파일을 삭제

파일 보기

@ -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 (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 min-w-[200px] z-[20]">
<div className="flex items-center gap-1.5 mb-2.5">
<div className="text-base">📍</div>
<div>
<h4 className="text-[11px] font-bold text-primary-orange"> </h4>
<div className="text-[8px] text-text-3 font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg min-w-[200px] z-[20]">
{/* 헤더 + 최소화 버튼 */}
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
<span className="text-[10px] font-bold text-text-3 uppercase tracking-wider"></span>
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
</div>
{!minimized && (
<div className="px-3.5 pb-3.5">
<div className="flex items-center gap-1.5 mb-2.5">
<div className="text-base">📍</div>
<div>
<h4 className="text-[11px] font-bold text-primary-orange"> </h4>
<div className="text-[8px] text-text-3 font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
</div>
</div>
</div>
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
</div>
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}</span>
</div>
</div>
<div>
<h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
<span> (AEGL-3)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
<span> (AEGL-2)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
<span> (AEGL-1)</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
<div className="text-xs">🧭</div>
<span className="text-[9px] text-text-3"> ()</span>
</div>
</div>
</div>
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
</div>
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}</span>
</div>
</div>
<div>
<h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
<span> (AEGL-3)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
<span> (AEGL-2)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
<span> (AEGL-1)</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
<div className="text-xs">🧭</div>
<span className="text-[9px] text-text-3"> ()</span>
</div>
)}
</div>
)
}
if (oilTrajectory.length > 0) {
return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 min-w-[180px] z-[20]">
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5"></h4>
<div className="flex flex-col gap-1.5">
{Array.from(selectedModels).map(model => (
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
<span className="font-korean">{model}</span>
</div>
))}
{selectedModels.size === 3 && (
<div className="flex items-center gap-2 text-[9px] text-text-3">
<span className="font-korean">( )</span>
</div>
)}
<div className="h-px bg-border my-1" />
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
<span className="font-korean"> </span>
</div>
{boomLines.length > 0 && (
<>
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md min-w-[180px] z-[20]">
{/* 헤더 + 최소화 버튼 */}
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3"></h4>
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
</div>
{!minimized && (
<div className="px-3.5 pb-3.5">
<div className="flex flex-col gap-1.5">
{Array.from(selectedModels).map(model => (
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
<span className="font-korean">{model}</span>
</div>
))}
{selectedModels.size === 3 && (
<div className="flex items-center gap-2 text-[9px] text-text-3">
<span className="font-korean">( )</span>
</div>
)}
<div className="h-px bg-border my-1" />
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#ef4444] rounded-[1px]" />
<span className="font-korean"> </span>
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#f97316] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#eab308] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
</>
)}
</div>
{boomLines.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#ef4444] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#f97316] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#eab308] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
</>
)}
</div>
</div>
)}
</div>
)
}

파일 보기

@ -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

파일 보기

@ -636,7 +636,7 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} oilTrajectory={oilTrajectory} />}
{/* 재계산 모달 */}
<RecalcModal

파일 보기

@ -1,13 +1,23 @@
import { useState } from 'react'
import type { PredictionDetail } from '../services/predictionApi'
import { analyzeSpillPolygon } from '@common/utils/geo'
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail }: { onOpenBacktrack?: () => 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 (
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
@ -35,9 +45,50 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 오염분석 */}
<Section title="오염분석">
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
📐
</button>
<div className="flex gap-2 mb-2">
<button
onClick={() => {
if (oilTrajectory.length < 3) {
alert('확산 예측을 먼저 실행하세요.')
return
}
const result = analyzeSpillPolygon(oilTrajectory)
setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length })
}}
className="flex-1 py-2.5 px-2 rounded text-[10px] font-bold font-korean border cursor-pointer transition-colors hover:brightness-110"
style={{ background: 'rgba(168,85,247,0.1)', border: '1px solid rgba(168,85,247,0.3)', color: '#a855f7' }}>
📐
</button>
<button
onClick={() => alert('원 분석 기능은 향후 오픈 예정입니다.')}
className="flex-1 py-2.5 px-2 rounded text-[10px] font-bold font-korean border cursor-pointer transition-colors hover:brightness-110"
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.3)', color: 'var(--cyan)' }}>
</button>
</div>
{polygonResult && (
<div className="flex flex-col gap-1.5">
<div className="text-[9px] font-bold text-purple-400 font-korean mb-0.5">📐 Convex Hull </div>
<div className="grid grid-cols-2 gap-1">
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-purple-400">{polygonResult.areaKm2.toFixed(2)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km²)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-primary-cyan">{polygonResult.perimeterKm.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-status-orange">{polygonResult.particleCount.toLocaleString()}</div>
<div className="text-[7px] text-text-3 font-korean"> </div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{polygonResult.hullPoints}</div>
<div className="text-[7px] text-text-3 font-korean"> </div>
</div>
</div>
</div>
)}
</Section>
{/* 오염 종합 상황 */}
@ -226,8 +277,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str
<input
type="checkbox"
defaultChecked={checked}
className="w-[13px] h-[13px]"
className="accent-[var(--cyan)]"
className="w-[13px] h-[13px] accent-[var(--cyan)]"
/>
{children}
</label>