wing-ops/frontend/src/tabs/prediction/components/RightPanel.tsx

995 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-disabled 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-disabled hover:text-fg-sub'
}`}
>
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
</button>
))}
</div>
{/* 다각형 패널 */}
{analysisTab === 'polygon' && (
<div>
<p className="text-label-2 text-fg-disabled 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-disabled">
{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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled font-korean">{label}</span>
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>
{value} <small className="font-normal text-fg-disabled">{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-disabled 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-disabled 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-disabled">{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-disabled 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-disabled">{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-disabled">{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-disabled 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-disabled 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-disabled 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-disabled"></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-disabled"></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-disabled"> </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-disabled 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>
);
}