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 (
);
}
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 (
);
}
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}%
오염비율
{summary && (
해상잔존량
{summary.remainingVolume.toFixed(2)} m³
)}
{summary && (
연안부착량
{summary.beachedVolume.toFixed(2)} m³
)}
민감자원 포함
{result.sensitiveCount}개소
{onRerun && (
)}
);
}