690 lines
27 KiB
TypeScript
Executable File
690 lines
27 KiB
TypeScript
Executable File
import { useState } from 'react'
|
|
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
|
import type { DisplayControls } from './OilSpillView'
|
|
|
|
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
|
|
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,
|
|
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)
|
|
|
|
return (
|
|
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
|
{/* Tab Header */}
|
|
<div className="flex border-b border-border">
|
|
<button className="flex-1 py-3 text-center text-xs font-semibold text-primary-cyan border-b-2 border-primary-cyan 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={false} onChange={() => {}} disabled>
|
|
민감자원
|
|
</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-[9px] text-text-3 font-korean whitespace-nowrap">데이터 모델</span>
|
|
<select
|
|
value={windHydrModel}
|
|
onChange={e => onWindHydrModelChange?.(e.target.value)}
|
|
className="flex-1 text-[9px] bg-bg-3 border border-border rounded px-1 py-0.5 text-text-2 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-[9px] font-semibold font-korean border transition-colors ${
|
|
analysisTab === tab
|
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
|
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
|
}`}
|
|
>
|
|
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 다각형 패널 */}
|
|
{analysisTab === 'polygon' && (
|
|
<div>
|
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
|
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
|
</p>
|
|
{!drawAnalysisMode && !analysisResult && (
|
|
<button
|
|
onClick={onStartPolygonDraw}
|
|
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
|
>
|
|
📐 다각형 분석수행
|
|
</button>
|
|
)}
|
|
{drawAnalysisMode === 'polygon' && (
|
|
<div className="space-y-2">
|
|
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
|
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
|
<span className="text-text-3">현재 {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-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
|
>
|
|
분석 실행
|
|
</button>
|
|
<button
|
|
onClick={onCancelAnalysis}
|
|
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{analysisResult && !drawAnalysisMode && (
|
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 원 분석 패널 */}
|
|
{analysisTab === 'circle' && (
|
|
<div>
|
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
|
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
|
</p>
|
|
<div className="text-[9px] font-semibold text-text-2 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-[10px] font-semibold font-mono border transition-all ${
|
|
circleRadiusNm === nm
|
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
|
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
|
}`}
|
|
>
|
|
{nm}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 mb-2.5">
|
|
<span className="text-[9px] text-text-3 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-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
|
|
style={{ colorScheme: 'dark' }}
|
|
/>
|
|
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
|
<button
|
|
onClick={onRunCircleAnalysis}
|
|
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
|
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
|
>
|
|
분석 실행
|
|
</button>
|
|
</div>
|
|
{analysisResult && (
|
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* 오염 종합 상황 */}
|
|
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
|
|
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
|
<StatBox label="유출량" value={spill?.volume != null ? spill.volume.toFixed(2) : '—'} unit={spill?.unit || 'kl'} color="var(--t1)" />
|
|
<StatBox label="풍화량" value={summary ? summary.weatheredVolume.toFixed(2) : '—'} unit="m³" color="var(--orange)" />
|
|
<StatBox label="해상잔존" value={summary ? summary.remainingVolume.toFixed(2) : '—'} unit="m³" color="var(--blue)" />
|
|
<StatBox label="연안부착" value={summary ? summary.beachedVolume.toFixed(2) : '—'} unit="m³" color="var(--red)" />
|
|
<div className="col-span-2">
|
|
<StatBox label="오염해역면적" value={summary ? summary.pollutionArea.toFixed(2) : '—'} unit="km²" color="var(--cyan)" />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 확산 예측 요약 */}
|
|
<Section title="확산 예측 요약 (+18h)" badge="위험" badgeColor="red">
|
|
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
|
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
|
|
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
|
|
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
|
|
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 유출유 풍화 상태 */}
|
|
<Section title="유출유 풍화 상태">
|
|
<div className="flex flex-col gap-[3px] text-[8px]">
|
|
<ProgressBar label="수면잔류" value={58} color="var(--blue)" />
|
|
<ProgressBar label="증발" value={22} color="var(--cyan)" />
|
|
<ProgressBar label="분산" value={12} color="var(--green)" />
|
|
<ProgressBar label="펜스차단" value={5} color="var(--boom)" />
|
|
<ProgressBar label="해안도달" value={3} color="var(--red)" />
|
|
</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-[15px]" 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-[11px] font-bold text-text-1 font-korean">{vessel?.vesselNm || '—'}</div>
|
|
<div className="text-[8px] text-text-3 font-mono">IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}</div>
|
|
</div>
|
|
<span className="text-[7px] px-2 py-0.5 rounded bg-[rgba(239,68,68,0.12)] text-status-red font-bold">사고</span>
|
|
</div>
|
|
|
|
{/* 제원 */}
|
|
<div className="grid grid-cols-3 gap-1">
|
|
<SpecCard value={vessel?.loaM?.toFixed(1) || '—'} label="전장 LOA(m)" color="var(--purple)" />
|
|
<SpecCard value={vessel?.breadthM?.toFixed(1) || '—'} label="형폭 B(m)" color="var(--cyan)" />
|
|
<SpecCard value={vessel?.draftM?.toFixed(1) || '—'} label="흘수 d(m)" color="var(--green)" />
|
|
</div>
|
|
|
|
<div className="space-y-0.5 text-[9px] 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-[8px] font-bold text-status-orange font-korean mb-1">⚠ 충돌 상대: {vessel2.vesselNm}</div>
|
|
<div className="text-[8px] text-text-3 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-[9px] text-text-3 font-korean text-center py-4">보험 정보가 없습니다.</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
{/* Bottom Action Buttons */}
|
|
<div className="flex gap-1.5 p-3 border-t border-border">
|
|
<button className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-boom to-[#d97706] text-black font-korean">
|
|
💾 저장
|
|
</button>
|
|
<button
|
|
onClick={onOpenRecalc}
|
|
className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-status-orange font-korean"
|
|
>
|
|
🔄 재계산
|
|
</button>
|
|
<button
|
|
onClick={onOpenReport}
|
|
className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-primary-cyan to-primary-blue text-white font-korean"
|
|
>
|
|
📄 보고서
|
|
</button>
|
|
<button
|
|
onClick={onOpenBacktrack}
|
|
className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(168,85,247,0.1)] border border-[rgba(168,85,247,0.3)] text-purple-500 font-korean"
|
|
>
|
|
🔍 역추적
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Helper Components
|
|
function Section({
|
|
title,
|
|
badge,
|
|
badgeColor,
|
|
children
|
|
}: {
|
|
title: string
|
|
badge?: string
|
|
badgeColor?: 'red' | 'green'
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="bg-bg-3 border border-border rounded-md p-3.5 mb-2.5">
|
|
<div className="flex items-center justify-between mb-2.5">
|
|
<h4 className="text-xs font-semibold text-text-2 font-korean">{title}</h4>
|
|
{badge && (
|
|
<span
|
|
className={`text-[10px] font-semibold px-2 py-1 rounded-full ${
|
|
badgeColor === 'red'
|
|
? 'bg-[rgba(239,68,68,0.15)] text-status-red'
|
|
: 'bg-[rgba(34,197,94,0.15)] text-status-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-[10px] font-korean cursor-pointer ${
|
|
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
|
/>
|
|
{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-0 border border-border rounded-[3px]">
|
|
<span className="text-text-3 font-korean">
|
|
{label}
|
|
</span>
|
|
<span style={{ fontWeight: 700, color, fontFamily: 'var(--fM)' }}>
|
|
{value} <small className="font-normal text-text-3">{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-0 border border-border rounded-[3px] text-[9px]">
|
|
<span className="text-text-3 font-korean">{label}</span>
|
|
<span style={{ fontWeight: 700, color, fontFamily: 'var(--fM)' }}>{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-text-3 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, minWidth: '28px' }}
|
|
className="font-semibold 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-3 border border-border rounded-md p-3.5 mb-2.5">
|
|
<div
|
|
className="flex items-center justify-between cursor-pointer mb-2"
|
|
onClick={onToggle}
|
|
>
|
|
<h4 className="text-xs font-semibold text-text-2 font-korean">{title}</h4>
|
|
<span className="text-[10px] text-text-3">{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-0 border border-border rounded-md">
|
|
<div
|
|
style={{ color }}
|
|
className="text-xs font-extrabold font-mono"
|
|
>
|
|
{value}
|
|
</div>
|
|
<div className="text-[7px] text-text-3 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-0 rounded-[3px]">
|
|
<span className="text-text-3">{label}</span>
|
|
<span
|
|
style={{ color: valueColor || 'var(--t1)' }}
|
|
className={`font-semibold${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(--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 (
|
|
<div
|
|
className="rounded-md"
|
|
style={{
|
|
padding: '6px 8px',
|
|
border: `1px solid ${colors.border}`,
|
|
background: colors.bg
|
|
}}
|
|
>
|
|
<div
|
|
style={{ color: colors.text }}
|
|
className="text-[8px] font-bold font-korean mb-1"
|
|
>
|
|
{title}
|
|
</div>
|
|
<div className="space-y-0.5 text-[8px] font-korean">
|
|
{items.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex justify-between py-0.5 px-1"
|
|
>
|
|
<span className="text-text-3">{item.label}</span>
|
|
<span
|
|
style={{ color: item.valueColor || 'var(--t1)' }}
|
|
className={`font-semibold${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-0 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(--purple), var(--cyan))' }} />
|
|
{radiusNm && (
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
|
<span className="text-[9px] font-semibold text-primary-cyan 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-3 rounded">
|
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
|
</div>
|
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
|
</div>
|
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 text-[9px] font-korean">
|
|
{summary && (
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">해상잔존량</span>
|
|
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
|
</div>
|
|
)}
|
|
{summary && (
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">연안부착량</span>
|
|
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">민감자원 포함</span>
|
|
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1.5 mt-2">
|
|
<button
|
|
onClick={onClear}
|
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
|
>
|
|
초기화
|
|
</button>
|
|
{onRerun && (
|
|
<button
|
|
onClick={onRerun}
|
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold 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>
|
|
)
|
|
}
|