wing-ops/frontend/src/tabs/rescue/components/RescueView.tsx
jeonghyo.k 29c5293ce7 feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가
- 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링)
- 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가
- MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달)
- OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동
- vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경
2026-04-15 14:40:28 +09:00

1623 lines
60 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useState, useEffect, useCallback } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { MapView } from '@common/components/map/MapView';
import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView';
import { fetchRescueOps } from '../services/rescueApi';
import type { RescueOpsItem } from '../services/rescueApi';
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/* ─── Types ─── */
type AccidentType =
| 'collision'
| 'grounding'
| 'turning'
| 'capsizing'
| 'sharpTurn'
| 'flooding'
| 'sinking';
type AnalysisTab = 'rescue' | 'damageStability' | 'longitudinalStrength';
/* ─── 사고 유형 데이터 ─── */
const accidentTypes: {
id: AccidentType;
label: string;
eng: string;
icon: string;
desc: string;
}[] = [
{ id: 'collision', label: '충돌', eng: 'Collision', icon: '💥', desc: '외력에 의한 선체 손상' },
{ id: 'grounding', label: '좌초', eng: 'Grounding', icon: '🪨', desc: '해저 접촉/암초 좌초' },
{ id: 'turning', label: '선회', eng: 'Turning', icon: '🔄', desc: '급격한 방향전환 사고' },
{ id: 'capsizing', label: '전복', eng: 'Capsizing', icon: '🔃', desc: '복원력 상실에 의한 전복' },
{
id: 'sharpTurn',
label: '급선회',
eng: 'Hard Turn',
icon: '↩️',
desc: '고속 급선회에 의한 경사',
},
{ id: 'flooding', label: '침수', eng: 'Flooding', icon: '🌊', desc: '해수 유입에 의한 침수' },
{ id: 'sinking', label: '침몰', eng: 'Sinking', icon: '⬇️', desc: '부력 상실에 의한 침몰' },
];
const analysisTabs: { id: AnalysisTab; label: string; icon: string }[] = [
{ id: 'rescue', label: '구난분석', icon: '🚨' },
{ id: 'damageStability', label: '손상복원성', icon: '⚖' },
{ id: 'longitudinalStrength', label: '종강도', icon: '📏' },
];
/* ─── 사고 유형별 파라미터 ─── */
const rscTypeData: Record<
AccidentType,
{
zone: string;
gm: string;
list: string;
trim: string;
buoy: number;
incident: string;
survivors: number;
total: number;
missing: number;
oilRate: string;
}
> = {
collision: {
zone: 'PREDICTED\nDAMAGE ZONE',
gm: '0.8',
list: '15.0',
trim: '2.5',
buoy: 30,
incident: 'M/V SEA GUARDIAN 충돌 사고',
survivors: 15,
total: 20,
missing: 5,
oilRate: '100L/min',
},
grounding: {
zone: 'GROUNDING\nIMPACT AREA',
gm: '1.2',
list: '8.0',
trim: '3.8',
buoy: 45,
incident: 'M/V OCEAN STAR 좌초 사고',
survivors: 22,
total: 25,
missing: 3,
oilRate: '50L/min',
},
turning: {
zone: 'PREDICTED\nDRIFT PATH',
gm: '1.5',
list: '12.0',
trim: '0.8',
buoy: 65,
incident: 'M/V PACIFIC WAVE 선회 사고',
survivors: 18,
total: 18,
missing: 0,
oilRate: '0L/min',
},
capsizing: {
zone: 'CAPSIZING\nRISK ZONE',
gm: '0.2',
list: '45.0',
trim: '1.2',
buoy: 10,
incident: 'M/V GRAND FORTUNE 전복 사고',
survivors: 8,
total: 15,
missing: 7,
oilRate: '200L/min',
},
sharpTurn: {
zone: 'VESSEL\nTURNING CIRCLE',
gm: '0.6',
list: '25.0',
trim: '0.5',
buoy: 50,
incident: 'M/V BLUE HORIZON 급선회 사고',
survivors: 20,
total: 20,
missing: 0,
oilRate: '0L/min',
},
flooding: {
zone: 'FLOODING\nSPREAD AREA',
gm: '0.5',
list: '18.0',
trim: '4.2',
buoy: 20,
incident: 'M/V EASTERN GLORY 침수 사고',
survivors: 12,
total: 16,
missing: 4,
oilRate: '80L/min',
},
sinking: {
zone: 'PREDICTED\nSINKING AREA',
gm: '0.1',
list: '35.0',
trim: '6.0',
buoy: 5,
incident: 'M/V HARMONY 침몰 사고',
survivors: 10,
total: 22,
missing: 12,
oilRate: '350L/min',
},
};
/* ─── 색상 헬퍼 ─── */
function gmColor(v: string) {
const n = parseFloat(v);
return n < 1.0
? 'var(--color-danger)'
: n < 1.5
? 'var(--color-caution)'
: 'var(--color-success)';
}
function listColor(v: string) {
const n = parseFloat(v);
return n > 20 ? 'var(--color-danger)' : n > 10 ? 'var(--color-caution)' : 'var(--color-success)';
}
function trimColor(v: string) {
const n = parseFloat(v);
return n > 3 ? 'var(--color-danger)' : n > 1.5 ? 'var(--color-caution)' : 'var(--color-success)';
}
function buoyColor(v: number) {
return v < 30 ? 'var(--color-danger)' : v < 50 ? 'var(--color-caution)' : 'var(--color-success)';
}
/* ─── 상단 사고 정보바 ─── */
function TopInfoBar({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
const [clock, setClock] = useState('');
useEffect(() => {
const tick = () => {
const now = new Date();
setClock(
`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')} KST`,
);
};
tick();
const iv = setInterval(tick, 1000);
return () => clearInterval(iv);
}, []);
return (
<div className="h-9 bg-bg-base border-b border-stroke flex items-center px-4 gap-3.5 flex-shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-title-3"></span>
<span className="text-label-1 font-bold text-fg font-korean"></span>
</div>
<div className="flex items-center gap-1.5 text-label-2 text-color-danger font-korean">
{/* <span className="w-2.5 h-2.5 rounded-sm bg-color-danger inline-block" /> */}
: {d.incident}
</div>
<div className="flex gap-3 text-caption font-mono text-fg-disabled ml-auto">
<span>
: <span className="text-fg">{d.survivors}</span>/{d.total}
</span>
<span>
: <span className="text-fg">{d.missing}</span>
</span>
<span>
GM: <span className="text-fg">{d.gm}m</span>
</span>
<span>
: <span className="text-fg">{d.list}°</span>
</span>
<span>
: <span className="text-fg">{d.oilRate}</span>
</span>
</div>
<div className="text-title-4 font-bold text-fg font-mono">{clock}</div>
<div className="text-caption text-fg-disabled font-korean">👤 Cmdr. KIM</div>
</div>
);
}
/* ─── 왼쪽 패널: 사고유형 + 긴급경고 + CCTV ─── */
function LeftPanel({
activeType,
onTypeChange,
incidents,
selectedAcdnt,
onSelectAcdnt,
}: {
activeType: AccidentType;
onTypeChange: (t: AccidentType) => void;
incidents: GscAccidentListItem[];
selectedAcdnt: GscAccidentListItem | null;
onSelectAcdnt: (item: GscAccidentListItem | null) => void;
}) {
const [acdntName, setAcdntName] = useState('');
const [acdntDate, setAcdntDate] = useState('');
const [acdntTime, setAcdntTime] = useState('');
const [acdntLat, setAcdntLat] = useState('');
const [acdntLon, setAcdntLon] = useState('');
const [showList, setShowList] = useState(false);
// 사고 선택 시 필드 자동 채움
const handlePickIncident = (item: GscAccidentListItem) => {
onSelectAcdnt(item);
setAcdntName(item.pollNm);
if (item.pollDate) {
const [d, t] = item.pollDate.split('T');
if (d) {
const [y, m, day] = d.split('-');
setAcdntDate(`${y}. ${m}. ${day}.`);
}
if (t) {
const [hhStr, mmStr] = t.split(':');
const hh = parseInt(hhStr, 10);
const ampm = hh >= 12 ? '오후' : '오전';
const hh12 = String(hh % 12 || 12).padStart(2, '0');
setAcdntTime(`${ampm} ${hh12}:${mmStr}`);
}
}
if (item.lat != null) setAcdntLat(String(item.lat));
if (item.lon != null) setAcdntLon(String(item.lon));
setShowList(false);
};
return (
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
{/* ── 사고 기본정보 ── */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
</div>
{/* 사고명 직접 입력 */}
<input
type="text"
placeholder="사고명 직접 입력"
value={acdntName}
onChange={(e) => {
setAcdntName(e.target.value);
if (selectedAcdnt) onSelectAcdnt(null);
}}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
{/* 또는 사고 리스트에서 선택 */}
<div className="relative">
<button
onClick={() => setShowList(!showList)}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
>
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
{selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
</span>
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
</button>
{showList && (
<div className="absolute left-0 right-0 top-full mt-0.5 z-30 bg-bg-card border border-stroke rounded shadow-lg max-h-[200px] overflow-y-auto scrollbar-thin">
{incidents.length === 0 && (
<div className="px-2 py-3 text-caption text-fg-disabled text-center font-korean">
</div>
)}
{incidents.map((item) => (
<button
key={item.acdntMngNo}
onClick={() => handlePickIncident(item)}
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
>
<div className="text-fg font-semibold truncate">{item.pollNm}</div>
<div className="text-fg-disabled text-[10px]">
{item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
</div>
</button>
))}
</div>
)}
</div>
{/* 사고 발생 일시 */}
<div className="text-[10px] text-fg-disabled font-korean mt-1"> </div>
<div className="flex gap-1">
<input
type="text"
placeholder="2026. 04. 11."
value={acdntDate}
onChange={(e) => setAcdntDate(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="오후 03:42"
value={acdntTime}
onChange={(e) => setAcdntTime(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
</div>
{/* 위도 / 경도 */}
<div className="flex gap-1 mt-0.5">
<input
type="text"
placeholder="위도"
value={acdntLat}
onChange={(e) => setAcdntLat(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="경도"
value={acdntLon}
onChange={(e) => setAcdntLon(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<button
className="shrink-0 px-1.5 py-1 text-[10px] font-bold rounded cursor-pointer"
style={{
background: 'rgba(239,68,68,0.15)',
color: 'var(--color-danger)',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
</button>
</div>
<div className="text-[10px] text-fg-disabled font-korean text-center mb-1">
</div>
{/* 구분선 */}
<div className="border-t border-stroke my-1" />
{/* 사고유형 제목 */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
(INCIDENT TYPE)
</div>
{/* 사고유형 버튼 */}
{accidentTypes.map((t) => (
<button
key={t.id}
onClick={() => onTypeChange(t.id)}
className={`flex items-center gap-2 w-full text-left px-2.5 py-[7px] rounded-md border transition-all cursor-pointer ${
activeType === t.id
? 'bg-[rgba(6,182,212,0.1)] border-[rgba(6,182,212,0.35)]'
: 'bg-bg-card border-stroke hover:border-[var(--stroke-light)] hover:bg-bg-surface-hover'
}`}
>
<span className="text-label-1">{t.icon}</span>
<div>
<div
className={`text-label-2 font-bold font-korean ${activeType === t.id ? 'text-color-accent' : 'text-fg'}`}
>
{t.label} ({t.eng})
</div>
<div className="text-caption text-fg-disabled font-korean mt-px">{t.desc}</div>
</div>
</button>
))}
{/* 긴급 경고 */}
<div className="text-caption font-bold text-fg-disabled font-korean mt-2.5 mb-1">
(CRITICAL ALERTS)
</div>
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
GM
</div>
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
5
</div>
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
-
</div>
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
88%
</div>
{/* CCTV 피드 */}
<div className="w-full aspect-[4/3] bg-bg-base border border-stroke rounded-md flex items-center justify-center relative overflow-hidden mt-1.5 flex-shrink-0">
<div
className="absolute inset-0"
style={{
background: 'repeating-linear-gradient(0deg, rgba(255,0,0,.03), transparent 2px)',
}}
/>
<div className="text-caption text-[rgba(255,60,60,0.35)] font-mono">CCTV FEED #1</div>
<div className="absolute top-1 left-1.5 text-caption text-[rgba(255,60,60,0.5)] font-mono">
REC
</div>
</div>
</div>
);
}
/* ─── 중앙 지도 영역 ─── */
/* ─── 오른쪽 분석 패널 ─── */
function RightPanel({
activeAnalysis,
onAnalysisChange,
activeType,
}: {
activeAnalysis: AnalysisTab;
onAnalysisChange: (t: AnalysisTab) => void;
activeType: AccidentType;
}) {
return (
<div className="w-[310px] min-w-[310px] bg-bg-base border-l border-stroke flex flex-col overflow-hidden">
{/* 분석 탭 */}
<div className="flex border-b border-stroke flex-shrink-0">
{analysisTabs.map((tab) => (
<button
key={tab.id}
onClick={() => onAnalysisChange(tab.id)}
className={`flex-1 py-2 text-caption font-semibold font-korean cursor-pointer text-center transition-all border-b-2 ${
activeAnalysis === tab.id
? 'text-color-accent border-b-[var(--color-accent)] bg-[rgba(6,182,212,0.04)]'
: 'text-fg-disabled border-b-transparent hover:text-fg'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* 패널 콘텐츠 */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{activeAnalysis === 'rescue' && <RescuePanel activeType={activeType} />}
{activeAnalysis === 'damageStability' && <DamageStabilityPanel activeType={activeType} />}
{activeAnalysis === 'longitudinalStrength' && <LongStrengthPanel activeType={activeType} />}
</div>
{/* Bottom Action Buttons */}
<div className="flex gap-1.5 p-3 border-t border-stroke flex-shrink-0">
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
💾
</button>
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold bg-bg-elevated border border-stroke text-fg font-korean cursor-pointer">
🔄
</button>
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
📄
</button>
</div>
</div>
);
}
/* ─── 구난 분석 패널 ─── */
function RescuePanel({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
const at = accidentTypes.find((t) => t.id === activeType)!;
return (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-label-1 font-bold text-fg font-korean"> (RESCUE ANALYSIS)</div>
<div className="text-caption text-fg-sub font-korean px-2 py-1 rounded bg-bg-card border border-stroke">
: {at.label} ({at.eng})
</div>
{/* 선박 단면도 SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2 relative">
<div className="absolute top-1.5 right-2 text-caption text-fg-disabled font-mono">
VESSEL STATUS
</div>
<svg viewBox="0 0 260 80" className="w-full" style={{ height: '65px' }}>
<rect
x="30"
y="24"
width="170"
height="28"
rx="4"
fill="var(--bg-surface-hover)"
stroke="var(--stroke-light)"
strokeWidth=".8"
/>
<rect
x="85"
y="15"
width="55"
height="10"
rx="2"
fill="var(--bg-surface-hover)"
stroke="var(--stroke-light)"
strokeWidth=".6"
/>
<rect
x="42"
y="27"
width="30"
height="18"
rx="1"
fill="color-mix(in srgb, var(--color-danger) 12%, transparent)"
stroke="color-mix(in srgb, var(--color-danger) 35%, transparent)"
strokeWidth=".6"
/>
<text
x="57"
y="39"
fill="color-mix(in srgb, var(--color-danger) 50%, transparent)"
fontSize="4.5"
textAnchor="middle"
fontFamily="var(--font-mono)"
>
FLOODED
</text>
<circle
cx="42"
cy="38"
r="6"
fill="none"
stroke="var(--color-danger)"
strokeWidth="1"
strokeDasharray="2,1"
/>
<text
x="42"
y="50"
fill="var(--color-danger)"
fontSize="4"
textAnchor="middle"
fontFamily="var(--font-mono)"
>
IMPACT
</text>
<line
x1="30"
y1="52"
x2="200"
y2="52"
stroke="var(--stroke-default)"
strokeWidth=".4"
strokeDasharray="3,2"
/>
<text
x="205"
y="55"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
WL
</text>
<line
x1="130"
y1="8"
x2="130"
y2="60"
stroke="color-mix(in srgb, var(--fg-disabled) 15%, transparent)"
strokeWidth=".4"
strokeDasharray="2,2"
/>
<text x="230" y="20" fill="var(--fg-disabled)" fontSize="5">
LIST: {d.list}°
</text>
<text x="230" y="28" fill="var(--fg-disabled)" fontSize="5">
TRIM: {d.trim}m
</text>
</svg>
</div>
{/* 핵심 지표 2×2 */}
<div className="grid grid-cols-2 gap-1.5">
<MetricCard
label="GM (메타센터 높이)"
value={d.gm}
unit="m"
color={gmColor(d.gm)}
sub={`${parseFloat(d.gm) < 1.0 ? '위험' : parseFloat(d.gm) < 1.5 ? '주의' : '정상'} (기준: 1.5m 이상)`}
/>
<MetricCard
label="LIST (횡경사)"
value={d.list}
unit="°"
color={listColor(d.list)}
sub={`${parseFloat(d.list) > 20 ? '위험' : parseFloat(d.list) > 10 ? '주의' : '정상'} (기준: 10° 이내)`}
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<MetricCard
label="TRIM (종경사)"
value={d.trim}
unit="m"
color={trimColor(d.trim)}
sub="선미 침하 (AFT SINKAGE)"
/>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">
(Reserve Buoyancy)
</div>
<div className="text-title-2 font-bold font-mono" style={{ color: buoyColor(d.buoy) }}>
{d.buoy}
<span className="text-label-2">%</span>
</div>
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
<div
className="h-full rounded-sm transition-all"
style={{ width: `${d.buoy}%`, background: buoyColor(d.buoy) }}
/>
</div>
</div>
</div>
{/* 긴급 조치 버튼 */}
<div className="text-label-2 font-bold text-fg-disabled font-korean">
(EMERGENCY ACTIONS)
</div>
<div className="grid grid-cols-2 gap-1.5">
{[
{ en: 'BALLAST INJECT', ko: '밸러스트 주입' },
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출' },
{ en: 'ENGINE STOP', ko: '기관 정지' },
{ en: 'ANCHOR DROP', ko: '묘 투하' },
].map((btn, i) => (
<button
key={i}
className="py-[7px] rounded-[5px] text-center cursor-pointer transition-all hover:brightness-125 bg-bg-card border border-stroke"
>
<div className="text-label-2 font-bold text-fg-sub font-mono">{btn.en}</div>
<div className="text-caption font-korean text-color-danger">{btn.ko}</div>
</button>
))}
</div>
{/* 구난 의사결정 프로세스 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(KRISO Decision Support)
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
<div className="flex items-center gap-1">
{['① 상태평가', '② 사례분석', '③ 장비선정'].map((label, i) => (
<Fragment key={i}>
{i > 0 && <span className="text-fg-disabled"></span>}
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
{label}
</div>
</Fragment>
))}
</div>
<div className="flex items-center gap-1">
{['④ 예인력', '⑤ 이초/인양', '⑥ 유출량'].map((label, i) => (
<Fragment key={i}>
{i > 0 && <span className="text-fg-disabled"></span>}
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
{label}
</div>
</Fragment>
))}
</div>
</div>
</div>
{/* 유체 정역학 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Hydrostatics)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
{[
{ label: '배수량(Δ)', value: '12,450 ton' },
{ label: '흘수(Draft)', value: '7.2 m' },
{ label: 'KG', value: '8.5 m' },
{ label: 'KM (횡)', value: '9.3 m' },
{ label: 'TPC', value: '22.8 t/cm' },
{ label: 'MTC', value: '185 t·m' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
</div>
{/* 예인력/이초력 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
/ (Towing & Refloating)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
{[
{ label: '필요 예인력', value: '285 kN' },
{ label: '비상 예인력', value: '420 kN' },
{ label: '이초 반력', value: '1,850 kN' },
{ label: '인양 안전성', value: 'FAIL' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
<div className="text-caption text-fg-disabled font-korean mt-1">
IMO Salvage Manual / Resistance Increase Ratio
</div>
</div>
{/* 유출량 추정 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Oil Outflow Estimation)
</div>
<div className="grid grid-cols-3 gap-1 text-caption font-mono">
{[
{ label: '현재 유출률', value: d.oilRate },
{ label: '누적 유출량', value: '6.8 kL' },
{ label: '24h 예측', value: '145 kL' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm text-center">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
<div className="mt-1 h-1 bg-bg-surface-hover rounded-sm">
<div
className="h-full rounded-sm"
style={{
width: '68%',
background: 'var(--color-danger)',
}}
/>
</div>
<div className="text-caption text-fg-disabled font-korean mt-0.5">
연료유: 210 kL | 잔량: 68%
</div>
</div>
{/* CBR 사례기반 추론 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
CBR (Case-Based Reasoning)
</div>
<div className="flex flex-col gap-0.5">
{[
{ pct: '94%', name: 'Hebei Spirit (2007)', desc: '태안 · 충돌 · 원유 12,547kL 유출' },
{ pct: '87%', name: 'Sea Empress (1996)', desc: '밀포드 · 좌초 · 72,000t 유출' },
{ pct: '82%', name: 'Rena (2011)', desc: '타우랑가 · 좌초 · 350t HFO 유출' },
].map((c, i) => (
<div
key={i}
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:brightness-125 bg-bg-card border border-stroke"
>
<div className="w-7 h-4 rounded-sm flex items-center justify-center text-caption font-bold font-mono text-fg-sub bg-bg-surface-hover">
{c.pct}
</div>
<div className="flex-1 text-caption font-korean">
<b className="text-fg-sub">{c.name}</b>
<br />
<span className="text-fg-disabled">{c.desc}</span>
</div>
</div>
))}
</div>
</div>
{/* 위험도 평가 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
2
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
{[
{ label: '침수 확대 → 전복', level: 'HIGH', danger: true },
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', danger: true },
{ label: '선체 절단 (BM 초과)', level: 'MED', danger: true },
{ label: '화재/폭발', level: 'LOW', danger: false },
].map((r, i) => (
<div key={i} className="flex items-center justify-between px-1.5 py-0.5 rounded-sm">
<span className="text-fg-sub">{r.label}</span>
<span
className={`px-1.5 py-px rounded-lg text-caption font-bold ${r.danger ? 'text-color-danger' : 'text-fg-disabled'}`}
>
{r.level}
</span>
</div>
))}
</div>
</div>
{/* 해상 e-Call */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
e-Call (GMDSS / VHF-DSC)
</div>
<div className="flex flex-col gap-0.5 text-caption font-mono text-fg-disabled">
{[
{ label: 'MMSI', value: '440123456', danger: false },
{ label: 'Nature of Distress', value: 'COLLISION', danger: true },
{ label: 'DSC Alert', value: 'SENT ✓', danger: false },
{ label: 'EPIRB', value: 'ACTIVATED ✓', danger: false },
{ label: 'VTS 인천', value: 'ACK 10:36', danger: false },
].map((r, i) => (
<div key={i} className="flex justify-between">
<span className="font-korean">{r.label}</span>
<b className={r.danger ? 'text-color-danger' : 'text-fg-sub'}>{r.value}</b>
</div>
))}
</div>
<button className="w-full mt-1 py-1 rounded text-caption font-bold cursor-pointer font-korean bg-bg-card border border-stroke text-fg-sub">
📡 DISTRESS RELAY
</button>
</div>
</div>
);
}
/* ─── 손상 복원성 패널 ─── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function DamageStabilityPanel(_props: { activeType: AccidentType }) {
return (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-label-1 font-bold text-fg font-korean">
(DAMAGE STABILITY)
</div>
<div className="text-caption text-fg-disabled font-korean leading-snug">
<br />
IMO A.749(18) / SOLAS Ch.II-1
</div>
{/* GZ Curve SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
GZ (Righting Lever Curve)
</div>
<svg viewBox="0 0 260 120" className="w-full" style={{ height: '100px' }}>
<line x1="30" y1="10" x2="30" y2="100" stroke="var(--stroke-default)" strokeWidth=".5" />
<line
x1="30"
y1="100"
x2="255"
y2="100"
stroke="var(--stroke-default)"
strokeWidth=".5"
/>
<line
x1="30"
y1="55"
x2="255"
y2="55"
stroke="var(--stroke-default)"
strokeWidth=".3"
strokeDasharray="3,3"
/>
<line
x1="30"
y1="32"
x2="255"
y2="32"
stroke="var(--stroke-default)"
strokeWidth=".3"
strokeDasharray="3,3"
/>
<polyline
points="30,100 55,80 80,55 105,38 130,30 155,32 180,42 205,60 230,85 250,100"
fill="none"
stroke="var(--color-info)"
strokeWidth="1.5"
/>
<text x="135" y="25" fill="var(--color-info)" fontSize="5" textAnchor="middle">
GZ
</text>
<polyline
points="30,100 55,88 80,72 105,62 130,58 155,62 180,75 205,92 220,100"
fill="none"
stroke="var(--color-danger)"
strokeWidth="1.5"
/>
<text x="140" y="54" fill="var(--color-danger)" fontSize="5" textAnchor="middle">
GZ
</text>
<line
x1="30"
y1="78"
x2="255"
y2="78"
stroke="var(--color-danger)"
strokeWidth=".6"
strokeDasharray="4,2"
/>
<text
x="240"
y="76"
fill="var(--color-danger)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
IMO MIN
</text>
<text x="5" y="14" fill="var(--fg-disabled)" fontSize="5" fontFamily="var(--font-mono)">
GZ(m)
</text>
<text x="5" y="35" fill="var(--fg-disabled)" fontSize="4.5" fontFamily="var(--font-mono)">
0.6
</text>
<text x="5" y="58" fill="var(--fg-disabled)" fontSize="4.5" fontFamily="var(--font-mono)">
0.4
</text>
<text x="5" y="82" fill="var(--fg-disabled)" fontSize="4.5" fontFamily="var(--font-mono)">
0.2
</text>
<text
x="5"
y="104"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
0
</text>
<text
x="30"
y="112"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
0°
</text>
<text
x="80"
y="112"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
15°
</text>
<text
x="130"
y="112"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
30°
</text>
<text
x="180"
y="112"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
45°
</text>
<text
x="230"
y="112"
fill="var(--fg-disabled)"
fontSize="4.5"
fontFamily="var(--font-mono)"
>
60°
</text>
</svg>
</div>
{/* 복원성 지표 */}
<div className="grid grid-cols-2 gap-1.5">
<MetricCard
label="GZ_max (최대복원력)"
value="0.25"
unit="m"
color="var(--color-danger)"
sub="기준: 0.2m 이상 ⚠"
/>
<MetricCard
label="θ_max (최대복원각)"
value="28"
unit="°"
color="var(--color-danger)"
sub="기준: 25° 이상 ✓"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean"> </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">
2 <span className="text-caption"></span>
</div>
<div className="text-caption text-fg-disabled font-korean">#1 / #3 </div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Margin Line </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">
0.12 <span className="text-caption">m</span>
</div>
<div className="text-caption text-fg-disabled font-korean"> </div>
</div>
</div>
{/* SOLAS 판정 */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
SOLAS 판정: 부적합 (FAIL)
</div>
<div className="text-caption text-fg-disabled font-korean leading-snug">
· Area(0~θ_f): 0.028 m·rad ( 0.015 )
<br />
· GZ_max 0.1m: 0.25m | θ_max 15°: 28° <br />·{' '}
<span className="text-color-danger">
Margin Line 침수: 0.12m
</span>
</div>
</div>
{/* 좌초시 복원성 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
(Grounded Stability)
</div>
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
{[
{ label: '지반반력', value: '1,240 kN' },
{ label: '접촉 면적', value: '12.5 m²' },
{ label: '제거력(Removal)', value: '1,850 kN' },
{ label: '좌초 GM', value: '0.65 m' },
].map((r, i) => (
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
<br />
<b className="text-fg-sub">{r.value}</b>
</div>
))}
</div>
</div>
{/* 탱크 상태 */}
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
(Tank Volume Status)
</div>
<div className="flex flex-col gap-0.5 text-caption font-korean">
{[
{ name: '#1 FP Tank', pct: 100, status: '침수', danger: true },
{ name: '#3 Port Tank', pct: 85, status: '85%', danger: true },
{ name: '#2 DB Tank', pct: 45, status: '45%', danger: false },
{ name: 'Ballast #4', pct: 72, status: '72%', danger: false },
{ name: 'Fuel Oil #5', pct: 68, status: '68%', danger: false },
].map((t, i) => (
<div key={i} className="flex items-center gap-1">
<div
className={`w-2 h-2 rounded-sm flex-shrink-0 ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
/>
<span className="w-[65px] text-fg-disabled">{t.name}</span>
<div className="flex-1 h-1 bg-bg-surface-hover rounded-sm">
<div
className={`h-full rounded-sm ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
style={{ width: `${t.pct}%` }}
/>
</div>
<span
className={`min-w-[35px] text-right ${t.danger ? 'text-fg-sub' : 'text-fg-disabled'}`}
>
{t.status}
</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ─── 종강도 패널 ─── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function LongStrengthPanel(_props: { activeType: AccidentType }) {
return (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-label-1 font-bold text-fg font-korean">
(LONGITUDINAL STRENGTH)
</div>
<div className="text-caption text-fg-disabled font-korean leading-snug">
<br />
IACS CSR / Classification Society
</div>
{/* 전단력 분포 SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Shear Force Distribution)
</div>
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
<line x1="25" y1="45" x2="255" y2="45" stroke="var(--stroke-default)" strokeWidth=".5" />
<line x1="25" y1="10" x2="25" y2="80" stroke="var(--stroke-default)" strokeWidth=".5" />
<line
x1="25"
y1="18"
x2="255"
y2="18"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
<line
x1="25"
y1="72"
x2="255"
y2="72"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
<text
x="240"
y="15"
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
fontSize="4"
>
LIMIT
</text>
<polyline
points="25,45 50,38 75,28 100,22 120,20 140,25 160,35 180,45 200,52 220,58 240,55 255,50"
fill="none"
stroke="var(--color-info)"
strokeWidth="1.5"
/>
<polyline
points="25,45 50,35 75,22 100,16 120,14 140,18 160,30 180,45 200,56 220,64 240,60 255,55"
fill="none"
stroke="var(--color-danger)"
strokeWidth="1.2"
strokeDasharray="3,2"
/>
<text x="80" y="12" fill="var(--color-danger)" fontSize="4.5">
SF
</text>
<text x="2" y="14" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
+SF
</text>
<text x="2" y="48" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
0
</text>
<text x="2" y="78" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
-SF
</text>
<text x="25" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
AP
</text>
<text x="130" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
MID
</text>
<text x="245" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
FP
</text>
</svg>
</div>
{/* 굽힘모멘트 분포 SVG */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
(Bending Moment Distribution)
</div>
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
<line x1="25" y1="45" x2="255" y2="45" stroke="var(--stroke-default)" strokeWidth=".5" />
<line x1="25" y1="10" x2="25" y2="80" stroke="var(--stroke-default)" strokeWidth=".5" />
<line
x1="25"
y1="15"
x2="255"
y2="15"
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
strokeWidth=".5"
strokeDasharray="3,2"
/>
<text
x="240"
y="12"
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
fontSize="4"
>
LIMIT
</text>
<polyline
points="25,45 50,40 75,32 100,22 130,18 160,22 190,32 220,40 255,45"
fill="none"
stroke="var(--color-info)"
strokeWidth="1.5"
/>
<polyline
points="25,45 50,38 75,26 100,16 130,12 160,16 190,28 220,38 255,45"
fill="none"
stroke="var(--color-danger)"
strokeWidth="1.2"
strokeDasharray="3,2"
/>
<text x="115" y="8" fill="var(--color-danger)" fontSize="4.5">
BM
</text>
<text x="2" y="14" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
+BM
</text>
<text x="2" y="48" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
0
</text>
<text x="25" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
AP
</text>
<text x="130" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
MID
</text>
<text x="245" y="88" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
FP
</text>
</svg>
</div>
{/* 종강도 지표 */}
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">SF / </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">
88<span className="text-caption">%</span>
</div>
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
<div className="h-full rounded-sm w-[88%] bg-color-danger" />
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">BM / </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">
92<span className="text-caption">%</span>
</div>
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
<div className="h-full rounded-sm w-[92%] bg-color-danger" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Section Modulus </div>
<div className="text-title-3 font-bold text-fg-sub font-mono">1.08</div>
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.00 이상 ✓</div>
</div>
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">Hull Girder ULS</div>
<div className="text-title-3 font-bold text-fg-sub font-mono">1.12</div>
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.10 </div>
</div>
</div>
{/* 판정 */}
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
판정: 주의 (CAUTION)
</div>
<div className="text-caption text-fg-disabled font-korean leading-snug">
· SF 최대: 허용치의 88% <span className="text-color-danger"> </span>
<br />· BM 최대: 허용치의 92% <span className="text-color-danger"> </span>
<br />
· Hogging
<br />· <span className="text-color-danger"> BM </span>
</div>
</div>
</div>
);
}
/* ─── 하단 바: 이벤트 로그 + 타임라인 ─── */
function BottomBar() {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="border-t border-stroke flex flex-col bg-bg-base flex-shrink-0">
{/* 제목 바 — 항상 표시 */}
<div className="flex items-center px-3 py-1 border-b border-stroke flex-shrink-0">
<span className="text-caption font-bold text-fg-disabled font-korean">
/ (EVENT LOG / COMMUNICATION TRANSCRIPT)
</span>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex-1 text-center text-caption text-fg-disabled hover:text-fg cursor-pointer transition-colors"
>
{isOpen ? '▼' : '▲'}
</button>
<div className="flex items-center gap-1.5">
{['전체', '긴급', '통신'].map((label, i) => (
<button
key={i}
className="px-2 py-px rounded-sm text-caption cursor-pointer font-korean border border-stroke text-fg-disabled hover:text-fg"
>
{label}
</button>
))}
</div>
</div>
{/* 이벤트 로그 내용 — 토글 */}
{isOpen && (
<div className="h-[120px] overflow-y-auto px-3 py-1 font-mono text-caption leading-[1.7] scrollbar-thin">
{[
{ time: '10:35', msg: 'SOS FROM M/V SEA GUARDIAN', important: true },
{ time: '10:35', msg: 'OIL LEAK DETECTED SENSOR #3', important: false },
{ time: '10:40', msg: 'CG HELO DISPATCHED', important: true },
{ time: '10:41', msg: 'GM CRITICAL ALERT — DAMAGE STABILITY FAIL', important: true },
{ time: '10:42', msg: 'Coast Guard 123 en route — ETA 15 min', important: false },
{
time: '10:43',
msg: 'LONGITUDINAL STRENGTH WARNING — BM 92% of LIMIT',
important: false,
},
{
time: '10:45',
msg: 'BALLAST TRANSFER INITIATED — PORT #2 → STBD #3',
important: false,
},
{ time: '10:48', msg: 'LIST INCREASING — 12° → 15°', important: false },
{ time: '10:50', msg: 'RESCUE HELO ON SCENE — HOISTING OPS', important: false },
{ time: '10:55', msg: '5 SURVIVORS RECOVERED BY HELO', important: true },
].map((e, i) => (
<div key={i}>
<span className="text-fg-disabled">[{e.time}]</span>{' '}
<span className={e.important ? 'text-color-accent' : 'text-fg'}>{e.msg}</span>
</div>
))}
</div>
)}
</div>
);
}
/* ─── 공통 메트릭 카드 ─── */
function MetricCard({
label,
value,
unit,
color,
sub,
}: {
label: string;
value: string;
unit: string;
color: string;
sub: string;
}) {
return (
<div className="bg-bg-card border border-stroke rounded-md p-2">
<div className="text-caption text-fg-disabled font-korean">{label}</div>
<div className="text-title-3 font-bold font-mono" style={{ color }}>
{value}
<span className="text-caption"> {unit}</span>
</div>
<div className="text-caption font-korean text-fg-disabled">{sub}</div>
</div>
);
}
/* ─── 긴급구난 목록 탭 ─── */
function RescueListView() {
const [opsList, setOpsList] = useState<RescueOpsItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const loadOps = useCallback(async () => {
setLoading(true);
try {
const items = await fetchRescueOps({ search: searchTerm || undefined });
setOpsList(items);
} catch (err) {
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
} finally {
setLoading(false);
}
}, [searchTerm]);
useEffect(() => {
loadOps();
}, [loadOps]);
const getStatusLabel = (sttsCd: string) => {
switch (sttsCd) {
case 'ACTIVE':
return { label: '대응중', color: 'var(--color-danger)' };
case 'STANDBY':
return { label: '대기', color: 'var(--color-warning)' };
case 'COMPLETED':
return { label: '종료', color: 'var(--color-success)' };
default:
return { label: sttsCd, color: 'var(--fg-disabled)' };
}
};
const getTypeLabel = (tpCd: string) => {
const map: Record<string, string> = {
collision: '충돌',
grounding: '좌초',
turning: '선회',
capsizing: '전복',
sharpTurn: '급선회',
flooding: '침수',
sinking: '침몰',
};
return map[tpCd] ?? tpCd;
};
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="px-5 py-4 flex items-center justify-between border-b border-stroke">
<span className="text-title-3 font-bold font-korean"> </span>
<div className="flex gap-2 items-center">
<input
type="text"
placeholder="선박명 / 사고번호 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-md text-fg-sub font-korean text-label-2 w-[200px] outline-none focus:border-[var(--color-accent)]"
/>
<button
className="rounded-sm text-label-2 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 font-korean"
style={{
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
+
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-4">
{loading ? (
<div className="text-center py-20 text-fg-disabled text-title-3"> ...</div>
) : opsList.length === 0 ? (
<div className="text-center py-20 text-fg-disabled text-title-3">
.
</div>
) : (
<table className="w-full border-collapse text-label-2 mt-3">
<thead>
<tr className="bg-bg-card border-b border-stroke">
{['상태', '사고번호', '선박명', '사고유형', '발생일시', '위치', '인명'].map((h) => (
<th
key={h}
className="py-2 px-2.5 text-left font-korean font-semibold text-fg-disabled text-label-2"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{opsList.map((r) => {
const status = getStatusLabel(r.sttsCd);
return (
<tr
key={r.rescueOpsSn}
className="border-b border-stroke hover:bg-bg-surface-hover cursor-pointer"
>
<td className="py-2 px-2.5">
<span
className="px-2 py-0.5 rounded-xl text-caption font-bold"
style={{
background: `color-mix(in srgb, ${status.color} 15%, transparent)`,
color: status.color,
}}
>
{status.label}
</span>
</td>
<td className="py-2 px-2.5 font-mono text-color-accent font-semibold">
{r.opsCd}
</td>
<td className="py-2 px-2.5 font-korean text-fg font-semibold">{r.vesselNm}</td>
<td className="py-2 px-2.5 font-korean">{getTypeLabel(r.acdntTpCd)}</td>
<td className="py-2 px-2.5 font-mono text-fg-disabled">
{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}
</td>
<td className="py-2 px-2.5 font-mono text-fg-disabled text-label-2">
{r.locDc ?? '—'}
</td>
<td className="py-2 px-2.5 font-mono">
{r.survivors ?? 0}/{r.totalCrew ?? 0}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
/* ═══ 메인 RescueView ═══ */
export function RescueView() {
const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState<AccidentType>('collision');
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | null>(null);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
useEffect(() => {
fetchGscAccidents()
.then((items) => setIncidents(items))
.catch(() => setIncidents([]));
}, []);
// 지도 클릭 시 좌표 선택
const handleMapClick = useCallback((lon: number, lat: number) => {
setIncidentCoord({ lon, lat });
setIsSelectingLocation(false);
}, []);
// 사고 선택 시 좌표 자동 반영 + 지도 이동
const handleSelectAcdnt = useCallback(
(item: GscAccidentListItem | null) => {
setSelectedAcdnt(item);
if (item && item.lat != null && item.lon != null) {
setIncidentCoord({ lon: item.lon, lat: item.lat });
setFlyToCoord({ lon: item.lon, lat: item.lat });
}
},
[],
);
if (activeSubTab === 'list') {
return (
<div className="flex flex-1 overflow-hidden">
<RescueListView />
</div>
);
}
if (activeSubTab === 'scenario') {
return <RescueScenarioView />;
}
if (activeSubTab === 'theory') {
return <RescueTheoryView />;
}
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* 상단 사고 정보바 */}
<TopInfoBar activeType={activeType} />
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
<div className="flex flex-1 overflow-hidden">
<LeftPanel
activeType={activeType}
onTypeChange={setActiveType}
incidents={incidents}
selectedAcdnt={selectedAcdnt}
onSelectAcdnt={handleSelectAcdnt}
/>
<div className="flex-1 relative overflow-hidden">
<MapView
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={new Set()}
showOverlays={false}
vessels={vessels}
onBoundsChange={setMapBounds}
/>
</div>
<RightPanel
activeAnalysis={activeAnalysis}
onAnalysisChange={setActiveAnalysis}
activeType={activeType}
/>
</div>
{/* 하단: 이벤트 로그 + 타임라인 */}
<BottomBar />
</div>
);
}