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(() => {
if (!summary) return null;
const total = summary.remainingVolume + summary.evaporationVolume
+ summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume;
if (total <= 0) return null;
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-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
style={{ colorScheme: 'dark' }}
/>
NM
{analysisResult && (
)}
)}
{/* 오염 종합 상황 */}
{/* 확산 예측 요약 */}
{/* 유출유 풍화 상태 */}
{weatheringStatus ? (
<>
>
) : (
시뮬레이션 실행 후 표시됩니다
)}
{/* 사고 선박 제원 */}
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 */}
)
}
// Helper Components
function Section({
title,
badge,
badgeColor,
children
}: {
title: string
badge?: string
badgeColor?: 'red' | '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(--cyan)'
},
purple: {
border: 'rgba(168,85,247,0.15)',
bg: 'rgba(168,85,247,0.02)',
text: 'var(--purple)'
},
red: {
border: 'rgba(239,68,68,0.15)',
bg: 'rgba(239,68,68,0.02)',
text: 'var(--red)'
}
}
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 && (
)}
)
}