import { useState, useMemo } from 'react'; import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'; import type { DisplayControls } from './OilSpillView'; import { haversineDistance, computeBearing } from '@common/utils/geo'; interface AnalysisResult { area: number; particleCount: number; particlePercent: number; sensitiveCount: number; } interface RightPanelProps { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null; displayControls?: DisplayControls; onDisplayControlsChange?: (controls: DisplayControls) => void; windHydrModel?: string; windHydrModelOptions?: string[]; onWindHydrModelChange?: (model: string) => void; analysisTab?: 'polygon' | 'circle'; onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void; drawAnalysisMode?: 'polygon' | null; analysisPolygonPoints?: Array<{ lat: number; lon: number }>; circleRadiusNm?: number; onCircleRadiusChange?: (nm: number) => void; analysisResult?: AnalysisResult | null; incidentCoord?: { lat: number; lon: number } | null; centerPoints?: CenterPoint[]; predictionTime?: number; boomBlockedVolume?: number; onStartPolygonDraw?: () => void; onRunPolygonAnalysis?: () => void; onRunCircleAnalysis?: () => void; onCancelAnalysis?: () => void; onClearAnalysis?: () => void; } export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, displayControls, onDisplayControlsChange, windHydrModel, windHydrModelOptions = [], onWindHydrModelChange, analysisTab = 'polygon', onSwitchAnalysisTab, drawAnalysisMode, analysisPolygonPoints = [], circleRadiusNm = 5, onCircleRadiusChange, analysisResult, incidentCoord, centerPoints, predictionTime, boomBlockedVolume = 0, onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, onCancelAnalysis, onClearAnalysis, }: 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 weatheringStatus = useMemo(() => { const zero = { surface: 0, evaporation: 0, dispersion: 0, boom: 0, beached: 0 }; if (!summary) return zero; const total = summary.remainingVolume + summary.evaporationVolume + summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume; if (total <= 0) return zero; const pct = (v: number) => Math.round((v / total) * 100); return { surface: pct(summary.remainingVolume), evaporation: pct(summary.evaporationVolume), dispersion: pct(summary.dispersionVolume), boom: pct(boomBlockedVolume), beached: pct(summary.beachedVolume), }; }, [summary, boomBlockedVolume]); const spreadSummary = useMemo(() => { if (!incidentCoord || !centerPoints || centerPoints.length === 0) return null; const finalPoint = [...centerPoints].sort((a, b) => b.time - a.time)[0]; const distM = haversineDistance(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon }); const distKm = distM / 1000; const bearing = computeBearing(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon }); const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; const dirLabel = directions[Math.round(bearing / 45) % 8]; const speedMs = predictionTime && predictionTime > 0 ? distM / (predictionTime * 3600) : null; return { area: summary?.pollutionArea ?? null, distance: distKm, directionLabel: `${dirLabel} ${Math.round(bearing)}°`, speed: speedMs, }; }, [incidentCoord, centerPoints, summary, predictionTime]); return (
{/* Tab Header */}
{/* Scrollable Content */}
{/* 표시 정보 제어 */}
onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })} > 유향/유속 onDisplayControlsChange?.({ ...displayControls!, showWind: v })} > 풍향/풍속 onDisplayControlsChange?.({ ...displayControls!, showBeached: v })} > 해안부착 onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v }) } > 민감자원 onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })} > 시간 표시
{windHydrModelOptions.length > 1 && (
데이터 모델
)}
{/* 오염분석 */}
{/* 탭 전환 */}
{(['polygon', 'circle'] as const).map((tab) => ( ))}
{/* 다각형 패널 */} {analysisTab === 'polygon' && (

지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.

{!drawAnalysisMode && !analysisResult && ( )} {drawAnalysisMode === 'polygon' && (
지도를 클릭하여 꼭짓점을 추가하세요
현재 {analysisPolygonPoints.length}개 선택됨
)} {analysisResult && !drawAnalysisMode && ( )}
)} {/* 원 분석 패널 */} {analysisTab === 'circle' && (

반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.

반경 선택 (NM)
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => ( ))}
직접 입력 onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)} className="w-14 text-center py-1 px-1 bg-bg-base border border-stroke rounded text-label-2 font-mono text-fg outline-none focus:border-color-accent" style={{ colorScheme: 'dark' }} /> NM
{analysisResult && ( )}
)}
{/* 오염 종합 상황 */}
{/* 확산 예측 요약 */}
{/* 유출유 풍화 상태 */}
<>
{/* 사고 선박 제원 */} setShipExpanded(!shipExpanded)} >
{/* 선박 카드 */}
🚢
{vessel?.vesselNm || '—'}
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
사고
{/* 제원 */}
{/* 충돌 상대 */} {vessel2 && (
⚠ 충돌 상대: {vessel2.vesselNm}
{vessel2.flagCd} {vessel2.vesselTp}{' '} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
)}
{/* 선주 / 보험 */} setInsuranceExpanded(!insuranceExpanded)} >
{insurance && insurance.length > 0 ? ( <> {insurance .filter((ins) => ins.type === 'P&I') .map((ins, i) => ( ))} {insurance .filter((ins) => ins.type === 'H&M') .map((ins, i) => ( ))} {insurance .filter((ins) => ins.type === 'CLC') .map((ins, i) => ( ))} ) : (
보험 정보가 없습니다.
)}
{/* Bottom Action Buttons */}
); } // 위험도 등급 (방제대책본부 운영 규칙 유출량 기준 + 국가 위기경보 4단계) type SeverityColor = 'red' | 'orange' | 'yellow' | 'green'; interface SeverityLevel { label: string; color: SeverityColor; } const SEVERITY_LEVELS: SeverityLevel[] = [ { label: '심각', color: 'red' }, { label: '경계', color: 'orange' }, { label: '주의', color: 'yellow' }, { label: '관심', color: 'green' }, ]; /** 오염 종합 상황 — 유출량(kl) 기준 */ function getPollutionSeverity(volumeKl: number | null | undefined): SeverityLevel | null { if (volumeKl == null) return null; if (volumeKl >= 500) return SEVERITY_LEVELS[0]; // 심각 (중앙방제대책본부) if (volumeKl >= 50) return SEVERITY_LEVELS[1]; // 경계 (광역방제대책본부) if (volumeKl >= 10) return SEVERITY_LEVELS[2]; // 주의 (지역방제대책본부) return SEVERITY_LEVELS[3]; // 관심 } /** 확산 예측 요약 — 확산거리(km) + 속도(m/s) 중 높은 등급 */ function getSpreadSeverity( distanceKm: number | null | undefined, speedMs: number | null | undefined, ): SeverityLevel | null { if (distanceKm == null && speedMs == null) return null; const distLevel = distanceKm == null ? 3 : distanceKm >= 15 ? 0 : distanceKm >= 5 ? 1 : distanceKm >= 1 ? 2 : 3; const speedLevel = speedMs == null ? 3 : speedMs >= 0.3 ? 0 : speedMs >= 0.15 ? 1 : speedMs >= 0.05 ? 2 : 3; return SEVERITY_LEVELS[Math.min(distLevel, speedLevel)]; } // Helper Components const BADGE_STYLES: Record = { red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]', orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]', yellow: 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]', green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]', }; function Section({ title, badge, badgeColor, children, }: { title: string; badge?: string; badgeColor?: 'red' | 'orange' | 'yellow' | 'green'; children: React.ReactNode; }) { return (

{title}

{badge && ( {badge} )}
{children}
); } function ControlledCheckbox({ checked, onChange, children, disabled = false, }: { checked: boolean; onChange: (v: boolean) => void; children: string; disabled?: boolean; }) { return ( ); } function StatBox({ label, value, unit, color, }: { label: string; value: string; unit: string; color: string; }) { return (
{label} {value} {unit}
); } function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { return (
{label} {value}
); } function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) { return (
{label}
{value}%
); } function CollapsibleSection({ title, expanded, onToggle, children, }: { title: string; expanded: boolean; onToggle: () => void; children: React.ReactNode; }) { return (

{title}

{expanded ? '▾' : '▸'}
{expanded && children}
); } function SpecCard({ value, label, color }: { value: string; label: string; color: string }) { return (
{value}
{label}
); } function InfoRow({ label, value, mono, valueColor, }: { label: string; value: string; mono?: boolean; valueColor?: string; }) { return (
{label} {value}
); } function InsuranceCard({ title, color, items, }: { title: string; color: 'cyan' | 'purple' | 'red'; items: Array<{ label: string; value: string; mono?: boolean; valueColor?: string }>; }) { const colorMap = { cyan: { border: 'rgba(6,182,212,0.15)', bg: 'rgba(6,182,212,0.02)', text: 'var(--color-accent)', }, purple: { border: 'rgba(168,85,247,0.15)', bg: 'rgba(168,85,247,0.02)', text: 'var(--color-tertiary)', }, red: { border: 'rgba(239,68,68,0.15)', bg: 'rgba(239,68,68,0.02)', text: 'var(--color-danger)', }, }; const colors = colorMap[color]; return (
{title}
{items.map((item, i) => (
{item.label} {item.value}
))}
); } function PollResult({ result, summary, onClear, onRerun, radiusNm, }: { result: AnalysisResult; summary?: SimulationSummary | null; onClear?: () => void; onRerun?: () => void; radiusNm?: number; }) { const pollutedArea = ((result.area * result.particlePercent) / 100).toFixed(2); return (
{radiusNm && (
분석 결과 반경 {radiusNm} NM
)}
{result.area.toFixed(2)}
분석면적(km²)
{result.particlePercent}%
오염비율
{pollutedArea}
오염면적(km²)
{summary && (
해상잔존량 {summary.remainingVolume.toFixed(2)} m³
)} {summary && (
연안부착량 {summary.beachedVolume.toFixed(2)} m³
)}
민감자원 포함 {result.sensitiveCount}개소
{onRerun && ( )}
); }