992 lines
35 KiB
TypeScript
Executable File
992 lines
35 KiB
TypeScript
Executable File
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 (
|
|
<div className="w-full min-w-0 h-full bg-bg-surface border-l border-stroke flex flex-col overflow-hidden">
|
|
{/* Tab Header */}
|
|
<div className="flex border-b border-stroke">
|
|
<button className="flex-1 py-3 text-center text-label-1 font-medium text-color-accent border-b-2 border-color-accent transition-all font-korean">
|
|
분석 요약
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scrollable Content */}
|
|
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent">
|
|
{/* 표시 정보 제어 */}
|
|
<Section title="표시 정보 제어">
|
|
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
|
|
<ControlledCheckbox
|
|
checked={displayControls?.showCurrent ?? true}
|
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
|
|
>
|
|
유향/유속
|
|
</ControlledCheckbox>
|
|
<ControlledCheckbox
|
|
checked={displayControls?.showWind ?? true}
|
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
|
|
>
|
|
풍향/풍속
|
|
</ControlledCheckbox>
|
|
<ControlledCheckbox
|
|
checked={displayControls?.showBeached ?? false}
|
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
|
>
|
|
해안부착
|
|
</ControlledCheckbox>
|
|
<ControlledCheckbox
|
|
checked={displayControls?.showSensitiveResources ?? false}
|
|
onChange={(v) =>
|
|
onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })
|
|
}
|
|
>
|
|
민감자원
|
|
</ControlledCheckbox>
|
|
<ControlledCheckbox
|
|
checked={displayControls?.showTimeLabel ?? false}
|
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
|
|
>
|
|
시간 표시
|
|
</ControlledCheckbox>
|
|
</div>
|
|
{windHydrModelOptions.length > 1 && (
|
|
<div className="flex items-center gap-2 mt-1.5">
|
|
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
|
|
데이터 모델
|
|
</span>
|
|
<select
|
|
value={windHydrModel}
|
|
onChange={(e) => onWindHydrModelChange?.(e.target.value)}
|
|
className="flex-1 text-label-2 bg-bg-card border border-stroke rounded px-1 py-0.5 text-fg-sub font-korean"
|
|
>
|
|
{windHydrModelOptions.map((m) => (
|
|
<option key={m} value={m}>
|
|
{m}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* 오염분석 */}
|
|
<Section title="오염분석">
|
|
{/* 탭 전환 */}
|
|
<div className="flex gap-[3px] mb-2">
|
|
{(['polygon', 'circle'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => {
|
|
onSwitchAnalysisTab?.(tab);
|
|
onClearAnalysis?.();
|
|
}}
|
|
className={`flex-1 py-1.5 px-1 rounded text-label-2 font-medium font-korean border transition-colors ${
|
|
analysisTab === tab
|
|
? 'border-color-accent bg-[rgba(6,182,212,0.08)] text-color-accent'
|
|
: 'border-stroke bg-bg-card text-fg-default hover:text-fg-sub'
|
|
}`}
|
|
>
|
|
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 다각형 패널 */}
|
|
{analysisTab === 'polygon' && (
|
|
<div>
|
|
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
|
|
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
|
</p>
|
|
{!drawAnalysisMode && !analysisResult && (
|
|
<button
|
|
onClick={onStartPolygonDraw}
|
|
className="w-full py-2 rounded-sm text-label-2 font-bold font-korean mb-0 transition-colors hover:bg-[rgba(6,182,212,0.08)]"
|
|
style={{
|
|
border: '1px solid var(--color-accent)',
|
|
color: 'var(--color-accent)',
|
|
background: 'transparent',
|
|
}}
|
|
>
|
|
다각형 분석수행
|
|
</button>
|
|
)}
|
|
{drawAnalysisMode === 'polygon' && (
|
|
<div className="space-y-2">
|
|
<div className="text-label-2 text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
|
지도를 클릭하여 꼭짓점을 추가하세요
|
|
<br />
|
|
<span className="text-fg-default">
|
|
현재 {analysisPolygonPoints.length}개 선택됨
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={onRunPolygonAnalysis}
|
|
disabled={analysisPolygonPoints.length < 3}
|
|
className="flex-1 py-1.5 rounded text-label-2 font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
|
style={{
|
|
background:
|
|
'linear-gradient(135deg, var(--color-tertiary), var(--color-accent))',
|
|
}}
|
|
>
|
|
분석 실행
|
|
</button>
|
|
<button
|
|
onClick={onCancelAnalysis}
|
|
className="py-1.5 px-2 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{analysisResult && !drawAnalysisMode && (
|
|
<PollResult
|
|
result={analysisResult}
|
|
summary={summary}
|
|
onClear={onClearAnalysis}
|
|
onRerun={onStartPolygonDraw}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 원 분석 패널 */}
|
|
{analysisTab === 'circle' && (
|
|
<div>
|
|
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
|
|
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
|
</p>
|
|
<div className="text-label-2 font-medium text-fg-sub font-korean mb-1.5">
|
|
반경 선택 (NM)
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
|
<button
|
|
key={nm}
|
|
onClick={() => onCircleRadiusChange?.(nm)}
|
|
className={`w-8 h-7 rounded text-label-2 font-medium font-mono border transition-all ${
|
|
circleRadiusNm === nm
|
|
? 'border-color-accent bg-[rgba(6,182,212,0.1)] text-color-accent'
|
|
: 'border-stroke bg-bg-base text-fg-default hover:text-fg-sub'
|
|
}`}
|
|
>
|
|
{nm}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 mb-2.5">
|
|
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
|
|
직접 입력
|
|
</span>
|
|
<input
|
|
type="number"
|
|
min="0.1"
|
|
max="100"
|
|
step="0.1"
|
|
value={circleRadiusNm}
|
|
onChange={(e) => 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' }}
|
|
/>
|
|
<span className="text-label-2 text-fg-default font-korean">NM</span>
|
|
<button
|
|
onClick={onRunCircleAnalysis}
|
|
className="ml-auto py-1 px-3 rounded-sm text-label-2 font-bold font-korean transition-colors hover:bg-[rgba(6,182,212,0.08)]"
|
|
style={{
|
|
border: '1px solid var(--color-accent)',
|
|
color: 'var(--color-accent)',
|
|
background: 'transparent',
|
|
}}
|
|
>
|
|
분석 실행
|
|
</button>
|
|
</div>
|
|
{analysisResult && (
|
|
<PollResult
|
|
result={analysisResult}
|
|
summary={summary}
|
|
onClear={onClearAnalysis}
|
|
radiusNm={circleRadiusNm}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* 오염 종합 상황 */}
|
|
<Section
|
|
title="오염 종합 상황"
|
|
badge={getPollutionSeverity(spill?.volume)?.label}
|
|
badgeColor={getPollutionSeverity(spill?.volume)?.color}
|
|
>
|
|
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
|
<StatBox
|
|
label="유출량"
|
|
value={spill?.volume != null ? spill.volume.toFixed(2) : '—'}
|
|
unit={spill?.unit || 'kl'}
|
|
color="var(--fg-default)"
|
|
/>
|
|
<StatBox
|
|
label="풍화량"
|
|
value={summary ? summary.weatheredVolume.toFixed(2) : '—'}
|
|
unit="m³"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<StatBox
|
|
label="해상잔존"
|
|
value={summary ? summary.remainingVolume.toFixed(2) : '—'}
|
|
unit="m³"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<StatBox
|
|
label="연안부착"
|
|
value={summary ? summary.beachedVolume.toFixed(2) : '—'}
|
|
unit="m³"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<div className="col-span-2">
|
|
<StatBox
|
|
label="오염해역면적"
|
|
value={summary ? summary.pollutionArea.toFixed(2) : '—'}
|
|
unit="km²"
|
|
color="var(--fg-default)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 확산 예측 요약 */}
|
|
<Section
|
|
title={`확산 예측 요약 (+${predictionTime ?? 18}h)`}
|
|
badge={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.label}
|
|
badgeColor={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.color}
|
|
>
|
|
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
|
<PredictionCard
|
|
value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'}
|
|
label="영향 면적"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<PredictionCard
|
|
value={
|
|
spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'
|
|
}
|
|
label="확산 거리"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<PredictionCard
|
|
value={spreadSummary?.directionLabel ?? '—'}
|
|
label="확산 방향"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<PredictionCard
|
|
value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'}
|
|
label="확산 속도"
|
|
color="var(--fg-default)"
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 유출유 풍화 상태 */}
|
|
<Section title="유출유 풍화 상태">
|
|
<div className="flex flex-col gap-[3px] text-label-2">
|
|
<>
|
|
<ProgressBar
|
|
label="수면잔류"
|
|
value={weatheringStatus.surface}
|
|
color="var(--color-info)"
|
|
/>
|
|
<ProgressBar
|
|
label="증발"
|
|
value={weatheringStatus.evaporation}
|
|
color="var(--color-accent)"
|
|
/>
|
|
<ProgressBar
|
|
label="분산"
|
|
value={weatheringStatus.dispersion}
|
|
color="var(--color-success)"
|
|
/>
|
|
<ProgressBar
|
|
label="펜스차단"
|
|
value={weatheringStatus.boom}
|
|
color="var(--color-boom)"
|
|
/>
|
|
<ProgressBar
|
|
label="해안도달"
|
|
value={weatheringStatus.beached}
|
|
color="var(--color-danger)"
|
|
/>
|
|
</>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 사고 선박 제원 */}
|
|
<CollapsibleSection
|
|
title="🚢 사고 선박 제원"
|
|
expanded={shipExpanded}
|
|
onToggle={() => setShipExpanded(!shipExpanded)}
|
|
>
|
|
<div className="space-y-2">
|
|
{/* 선박 카드 */}
|
|
<div
|
|
className="flex items-center gap-2 p-2 border border-[rgba(6,182,212,0.15)] rounded-md"
|
|
style={{
|
|
background: 'linear-gradient(135deg, rgba(6,182,212,0.06), rgba(168,85,247,0.04))',
|
|
}}
|
|
>
|
|
<div
|
|
className="w-[30px] h-[30px] rounded-md flex items-center justify-center text-subtitle"
|
|
style={{
|
|
background: 'rgba(6,182,212,0.1)',
|
|
border: '1px solid rgba(6,182,212,0.2)',
|
|
}}
|
|
>
|
|
🚢
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-label-2 font-bold text-fg font-korean">
|
|
{vessel?.vesselNm || '—'}
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-mono">
|
|
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
|
|
</div>
|
|
</div>
|
|
<span className="text-label-2 px-2 py-0.5 rounded bg-[rgba(239,68,68,0.12)] text-color-danger font-bold">
|
|
사고
|
|
</span>
|
|
</div>
|
|
|
|
{/* 제원 */}
|
|
<div className="grid grid-cols-3 gap-1">
|
|
<SpecCard
|
|
value={vessel?.loaM?.toFixed(1) || '—'}
|
|
label="전장 LOA(m)"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<SpecCard
|
|
value={vessel?.breadthM?.toFixed(1) || '—'}
|
|
label="형폭 B(m)"
|
|
color="var(--fg-default)"
|
|
/>
|
|
<SpecCard
|
|
value={vessel?.draftM?.toFixed(1) || '—'}
|
|
label="흘수 d(m)"
|
|
color="var(--fg-default)"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-0.5 text-label-2 font-korean">
|
|
<InfoRow
|
|
label="총톤수(GT)"
|
|
value={vessel?.gt ? `${vessel.gt.toLocaleString()}톤` : '—'}
|
|
/>
|
|
<InfoRow
|
|
label="재화중량(DWT)"
|
|
value={vessel?.dwt ? `${vessel.dwt.toLocaleString()}톤` : '—'}
|
|
/>
|
|
<InfoRow label="건조" value={vessel?.builtYr ? `${vessel.builtYr}` : '—'} />
|
|
<InfoRow label="주기관" value={vessel?.engineDc || '—'} mono />
|
|
<InfoRow label="선적" value={vessel?.flagCd || '—'} />
|
|
<InfoRow label="호출부호" value={vessel?.callsign || '—'} mono />
|
|
</div>
|
|
|
|
{/* 충돌 상대 */}
|
|
{vessel2 && (
|
|
<div className="p-1.5 bg-[rgba(249,115,22,0.04)] border border-[rgba(249,115,22,0.12)] rounded">
|
|
<div className="text-label-2 font-bold text-color-warning font-korean mb-1">
|
|
⚠ 충돌 상대: {vessel2.vesselNm}
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-korean leading-relaxed">
|
|
{vessel2.flagCd} {vessel2.vesselTp}{' '}
|
|
{vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* 선주 / 보험 */}
|
|
<CollapsibleSection
|
|
title="🏢 선주 / 보험"
|
|
expanded={insuranceExpanded}
|
|
onToggle={() => setInsuranceExpanded(!insuranceExpanded)}
|
|
>
|
|
<div className="space-y-2">
|
|
{insurance && insurance.length > 0 ? (
|
|
<>
|
|
{insurance
|
|
.filter((ins) => ins.type === 'P&I')
|
|
.map((ins, i) => (
|
|
<InsuranceCard
|
|
key={`pi-${i}`}
|
|
title="🚢 P&I"
|
|
color="cyan"
|
|
items={[
|
|
{ label: '보험사', value: ins.insurer },
|
|
{ label: '한도', value: `${ins.currency} ${ins.value}`, mono: true },
|
|
]}
|
|
/>
|
|
))}
|
|
{insurance
|
|
.filter((ins) => ins.type === 'H&M')
|
|
.map((ins, i) => (
|
|
<InsuranceCard
|
|
key={`hm-${i}`}
|
|
title="🚢 선체보험 (H&M)"
|
|
color="cyan"
|
|
items={[
|
|
{ label: '보험사', value: ins.insurer },
|
|
{ label: '보험가액', value: `${ins.currency} ${ins.value}`, mono: true },
|
|
]}
|
|
/>
|
|
))}
|
|
{insurance
|
|
.filter((ins) => ins.type === 'CLC')
|
|
.map((ins, i) => (
|
|
<InsuranceCard
|
|
key={`clc-${i}`}
|
|
title="🛢 유류오염배상 (CLC)"
|
|
color="red"
|
|
items={[
|
|
{ label: '발급기관', value: ins.insurer },
|
|
{ label: 'CLC 한도', value: `${ins.currency} ${ins.value}`, mono: true },
|
|
]}
|
|
/>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="text-label-2 text-fg-default font-korean text-center py-4">
|
|
보험 정보가 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
{/* Bottom Action Buttons */}
|
|
<div className="flex gap-1.5 p-3 border-t border-stroke">
|
|
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
|
|
저장
|
|
</button>
|
|
<button
|
|
onClick={onOpenRecalc}
|
|
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke text-fg-sub font-korean hover:bg-[var(--bg-surface-hover)] transition-colors"
|
|
>
|
|
재계산
|
|
</button>
|
|
<button
|
|
onClick={onOpenReport}
|
|
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke text-fg-sub font-korean hover:bg-[var(--bg-surface-hover)] transition-colors"
|
|
>
|
|
보고서
|
|
</button>
|
|
<button
|
|
onClick={onOpenBacktrack}
|
|
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
|
>
|
|
역추적
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 위험도 등급 (방제대책본부 운영 규칙 유출량 기준 + 국가 위기경보 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<string, string> = {
|
|
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 (
|
|
<div className="bg-bg-card border border-stroke rounded-md p-3.5 mb-2.5">
|
|
<div className="flex items-center justify-between mb-2.5">
|
|
<h4 className="text-label-1 font-medium text-fg-sub font-korean">{title}</h4>
|
|
{badge && (
|
|
<span
|
|
className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${
|
|
BADGE_STYLES[badgeColor ?? 'green']
|
|
}`}
|
|
>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ControlledCheckbox({
|
|
checked,
|
|
onChange,
|
|
children,
|
|
disabled = false,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
children: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<label
|
|
className={`flex items-center gap-1.5 text-label-2 font-korean cursor-pointer ${
|
|
disabled ? 'text-fg-default cursor-not-allowed opacity-40' : 'text-fg-sub'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="w-[13px] h-[13px] accent-[var(--color-accent)]"
|
|
/>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function StatBox({
|
|
label,
|
|
value,
|
|
unit,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
unit: string;
|
|
color: string;
|
|
}) {
|
|
return (
|
|
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px]">
|
|
<span className="text-fg-default font-korean">{label}</span>
|
|
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>
|
|
{value} <small className="font-normal text-fg-default">{unit}</small>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) {
|
|
return (
|
|
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px] text-label-2">
|
|
<span className="text-fg-default font-korean">{label}</span>
|
|
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-fg-default font-korean" style={{ minWidth: '38px' }}>
|
|
{label}
|
|
</span>
|
|
<div
|
|
className="flex-1 h-[5px] overflow-hidden rounded-[3px]"
|
|
style={{ background: 'rgba(255,255,255,0.05)' }}
|
|
>
|
|
<div
|
|
style={{ height: '100%', width: `${value}%`, background: color, borderRadius: '3px' }}
|
|
/>
|
|
</div>
|
|
<span
|
|
style={{ color: 'var(--fg-default)', minWidth: '28px' }}
|
|
className="font-medium text-right font-mono"
|
|
>
|
|
{value}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CollapsibleSection({
|
|
title,
|
|
expanded,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="bg-bg-card border border-stroke rounded-md p-3.5 mb-2.5">
|
|
<div className="flex items-center justify-between cursor-pointer mb-2" onClick={onToggle}>
|
|
<h4 className="text-label-1 font-medium text-fg-sub font-korean">{title}</h4>
|
|
<span className="text-label-2 text-fg-default">{expanded ? '▾' : '▸'}</span>
|
|
</div>
|
|
{expanded && children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpecCard({ value, label, color }: { value: string; label: string; color: string }) {
|
|
return (
|
|
<div className="text-center py-[6px] px-0.5 bg-bg-base border border-stroke rounded-md">
|
|
<div style={{ color }} className="text-label-1 font-bold font-mono">
|
|
{value}
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-korean">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({
|
|
label,
|
|
value,
|
|
mono,
|
|
valueColor,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
valueColor?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex justify-between py-[3px] px-[6px] bg-bg-base rounded-[3px]">
|
|
<span className="text-fg-default">{label}</span>
|
|
<span
|
|
style={{ color: valueColor || 'var(--fg-default)' }}
|
|
className={`font-medium${mono ? ' font-mono' : ''}`}
|
|
>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className="rounded-md"
|
|
style={{
|
|
padding: '6px 8px',
|
|
border: `1px solid ${colors.border}`,
|
|
background: colors.bg,
|
|
}}
|
|
>
|
|
<div style={{ color: colors.text }} className="text-label-2 font-bold font-korean mb-1">
|
|
{title}
|
|
</div>
|
|
<div className="space-y-0.5 text-label-2 font-korean">
|
|
{items.map((item, i) => (
|
|
<div key={i} className="flex justify-between py-0.5 px-1">
|
|
<span className="text-fg-default">{item.label}</span>
|
|
<span
|
|
style={{ color: item.valueColor || 'var(--fg-default)' }}
|
|
className={`font-medium${item.mono ? ' font-mono' : ''}`}
|
|
>
|
|
{item.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className="mt-1 p-2.5 bg-bg-base border border-[rgba(168,85,247,0.2)] rounded-md"
|
|
style={{ position: 'relative', overflow: 'hidden' }}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '2px',
|
|
background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))',
|
|
}}
|
|
/>
|
|
{radiusNm && (
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-label-2 font-medium text-fg font-korean">분석 결과</span>
|
|
<span className="text-label-2 font-medium text-color-accent font-mono">
|
|
반경 {radiusNm} NM
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-3 gap-1 mb-2">
|
|
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
|
|
<div
|
|
className="text-title-4 font-bold font-mono"
|
|
style={{ color: 'var(--color-danger)' }}
|
|
>
|
|
{result.area.toFixed(2)}
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-korean mt-0.5">분석면적(km²)</div>
|
|
</div>
|
|
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
|
|
<div
|
|
className="text-title-4 font-bold font-mono"
|
|
style={{ color: 'var(--color-warning)' }}
|
|
>
|
|
{result.particlePercent}%
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-korean mt-0.5">오염비율</div>
|
|
</div>
|
|
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
|
|
<div
|
|
className="text-title-4 font-bold font-mono"
|
|
style={{ color: 'var(--color-accent)' }}
|
|
>
|
|
{pollutedArea}
|
|
</div>
|
|
<div className="text-label-2 text-fg-default font-korean mt-0.5">오염면적(km²)</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 text-label-2 font-korean">
|
|
{summary && (
|
|
<div className="flex justify-between">
|
|
<span className="text-fg-default">해상잔존량</span>
|
|
<span className="font-medium font-mono" style={{ color: 'var(--color-info)' }}>
|
|
{summary.remainingVolume.toFixed(2)} m³
|
|
</span>
|
|
</div>
|
|
)}
|
|
{summary && (
|
|
<div className="flex justify-between">
|
|
<span className="text-fg-default">연안부착량</span>
|
|
<span className="font-medium font-mono" style={{ color: 'var(--color-danger)' }}>
|
|
{summary.beachedVolume.toFixed(2)} m³
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span className="text-fg-default">민감자원 포함</span>
|
|
<span className="font-medium font-mono" style={{ color: 'var(--color-warning)' }}>
|
|
{result.sensitiveCount}개소
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1.5 mt-2">
|
|
<button
|
|
onClick={onClear}
|
|
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
|
|
>
|
|
초기화
|
|
</button>
|
|
{onRerun && (
|
|
<button
|
|
onClick={onRerun}
|
|
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
|
>
|
|
재분석
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|