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 (
⚓
긴급구난지원
{/* */}
사고: {d.incident}
생존자: {d.survivors}/{d.total}
실종: {d.missing}
GM: {d.gm}m
횡경사: {d.list}°
유출량: {d.oilRate}
{clock}
👤 Cmdr. KIM
);
}
/* ─── 왼쪽 패널: 사고유형 + 긴급경고 + 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 (
{/* ── 사고 기본정보 ── */}
사고 기본정보
{/* 사고명 직접 입력 */}
{
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"
/>
{/* 또는 사고 리스트에서 선택 */}
{showList && (
{incidents.length === 0 && (
사고 데이터 없음
)}
{incidents.map((item) => (
))}
)}
{/* 사고 발생 일시 */}
사고 발생 일시
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"
/>
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"
/>
{/* 위도 / 경도 */}
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"
/>
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"
/>
지도에서 위치를 선택하세요
{/* 구분선 */}
{/* 사고유형 제목 */}
사고 유형 (INCIDENT TYPE)
{/* 사고유형 버튼 */}
{accidentTypes.map((t) => (
))}
{/* 긴급 경고 */}
긴급 경고 (CRITICAL ALERTS)
GM 위험 수준 — 전복 위험
승선자 5명 미확인
유류 유출 감지 - 방제 필요
종강도 한계치 88% 접근
{/* CCTV 피드 */}
);
}
/* ─── 중앙 지도 영역 ─── */
/* ─── 오른쪽 분석 패널 ─── */
function RightPanel({
activeAnalysis,
onAnalysisChange,
activeType,
}: {
activeAnalysis: AnalysisTab;
onAnalysisChange: (t: AnalysisTab) => void;
activeType: AccidentType;
}) {
return (
{/* 분석 탭 */}
{analysisTabs.map((tab) => (
))}
{/* 패널 콘텐츠 */}
{activeAnalysis === 'rescue' && }
{activeAnalysis === 'damageStability' && }
{activeAnalysis === 'longitudinalStrength' && }
{/* Bottom Action Buttons */}
);
}
/* ─── 구난 분석 패널 ─── */
function RescuePanel({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
const at = accidentTypes.find((t) => t.id === activeType)!;
return (
구난 분석 (RESCUE ANALYSIS)
현재 사고유형: {at.label} ({at.eng})
{/* 선박 단면도 SVG */}
VESSEL STATUS
{/* 핵심 지표 2×2 */}
20 ? '위험' : parseFloat(d.list) > 10 ? '주의' : '정상'} (기준: 10° 이내)`}
/>
잔존 부력 (Reserve Buoyancy)
{d.buoy}
%
{/* 긴급 조치 버튼 */}
긴급 조치 (EMERGENCY ACTIONS)
{[
{ en: 'BALLAST INJECT', ko: '밸러스트 주입' },
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출' },
{ en: 'ENGINE STOP', ko: '기관 정지' },
{ en: 'ANCHOR DROP', ko: '묘 투하' },
].map((btn, i) => (
))}
{/* 구난 의사결정 프로세스 */}
구난 의사결정 프로세스 (KRISO Decision Support)
{['① 상태평가', '② 사례분석', '③ 장비선정'].map((label, i) => (
{i > 0 && →}
{label}
))}
{['④ 예인력', '⑤ 이초/인양', '⑥ 유출량'].map((label, i) => (
{i > 0 && →}
{label}
))}
{/* 유체 정역학 */}
유체 정역학 (Hydrostatics)
{[
{ 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) => (
{r.label}
{r.value}
))}
{/* 예인력/이초력 */}
예인력 / 이초력 (Towing & Refloating)
{[
{ label: '필요 예인력', value: '285 kN' },
{ label: '비상 예인력', value: '420 kN' },
{ label: '이초 반력', value: '1,850 kN' },
{ label: '인양 안전성', value: 'FAIL' },
].map((r, i) => (
{r.label}
{r.value}
))}
※ IMO Salvage Manual / Resistance Increase Ratio 기반 산출
{/* 유출량 추정 */}
유출량 추정 (Oil Outflow Estimation)
{[
{ label: '현재 유출률', value: d.oilRate },
{ label: '누적 유출량', value: '6.8 kL' },
{ label: '24h 예측', value: '145 kL' },
].map((r, i) => (
{r.label}
{r.value}
))}
잔여 연료유: 210 kL | 탱크 잔량: 68% 유출
{/* CBR 사례기반 추론 */}
CBR 유사 사고 사례 (Case-Based Reasoning)
{[
{ 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) => (
{c.pct}
{c.name}
{c.desc}
))}
{/* 위험도 평가 */}
위험도 평가 — 2차사고 시나리오
{[
{ label: '침수 확대 → 전복', level: 'HIGH', danger: true },
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', danger: true },
{ label: '선체 절단 (BM 초과)', level: 'MED', danger: true },
{ label: '화재/폭발', level: 'LOW', danger: false },
].map((r, i) => (
{r.label}
{r.level}
))}
{/* 해상 e-Call */}
해상 e-Call (GMDSS / VHF-DSC)
{[
{ 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) => (
{r.label}
{r.value}
))}
);
}
/* ─── 손상 복원성 패널 ─── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function DamageStabilityPanel(_props: { activeType: AccidentType }) {
return (
손상 복원성 (DAMAGE STABILITY)
사고유형에 따른 손상 후 선체 복원력 분석
IMO A.749(18) / SOLAS Ch.II-1 기준 평가
{/* GZ Curve SVG */}
GZ 복원력 곡선 (Righting Lever Curve)
{/* 복원성 지표 */}
침수 구획수
2 구획
#1 선수탱크 / #3 좌현탱크
Margin Line 여유
0.12 m
침수 임계점 임박
{/* SOLAS 판정 */}
⚠ SOLAS 손상복원성 판정: 부적합 (FAIL)
· Area(0~θ_f): 0.028 m·rad (기준 0.015 ✓)
· GZ_max ≥ 0.1m: 0.25m ✓ | θ_max ≥ 15°: 28° ✓
·{' '}
Margin Line 침수: 0.12m — 추가 침수 시 전복 위험
{/* 좌초시 복원성 */}
좌초시 복원성 (Grounded Stability)
{[
{ 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) => (
{r.label}
{r.value}
))}
{/* 탱크 상태 */}
탱크 상태 (Tank Volume Status)
{[
{ 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) => (
))}
);
}
/* ─── 종강도 패널 ─── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function LongStrengthPanel(_props: { activeType: AccidentType }) {
return (
종강도 분석 (LONGITUDINAL STRENGTH)
선체 종방향 구조 응력 분석
IACS CSR / Classification Society 기준
{/* 전단력 분포 SVG */}
전단력 분포 (Shear Force Distribution)
{/* 굽힘모멘트 분포 SVG */}
굽힘모멘트 분포 (Bending Moment Distribution)
{/* 종강도 지표 */}
Section Modulus 여유
1.08
Req'd: 1.00 이상 ✓
Hull Girder ULS
1.12
Req'd: 1.10 이상 ⚠
{/* 판정 */}
⚠ 종강도 판정: 주의 (CAUTION)
· SF 최대: 허용치의 88% — 주의 구간
· BM 최대: 허용치의 92% — 경고 구간
· 중앙부 Hogging 모멘트 증가 — 추가 침수 시 선체 절단 위험
· 밸러스트 이동으로 BM 분산 필요
);
}
/* ─── 하단 바: 이벤트 로그 + 타임라인 ─── */
function BottomBar() {
const [isOpen, setIsOpen] = useState(true);
return (
{/* 제목 바 — 항상 표시 */}
이벤트 로그 / 통신 기록 (EVENT LOG / COMMUNICATION TRANSCRIPT)
{['전체', '긴급', '통신'].map((label, i) => (
))}
{/* 이벤트 로그 내용 — 토글 */}
{isOpen && (
{[
{ 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) => (
[{e.time}]{' '}
{e.msg}
))}
)}
);
}
/* ─── 공통 메트릭 카드 ─── */
function MetricCard({
label,
value,
unit,
color,
sub,
}: {
label: string;
value: string;
unit: string;
color: string;
sub: string;
}) {
return (
{label}
{value}
{unit}
{sub}
);
}
/* ─── 긴급구난 목록 탭 ─── */
function RescueListView() {
const [opsList, setOpsList] = useState([]);
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 = {
collision: '충돌',
grounding: '좌초',
turning: '선회',
capsizing: '전복',
sharpTurn: '급선회',
flooding: '침수',
sinking: '침몰',
};
return map[tpCd] ?? tpCd;
};
return (
{loading ? (
로딩 중...
) : opsList.length === 0 ? (
구난 작전 데이터가 없습니다.
) : (
{['상태', '사고번호', '선박명', '사고유형', '발생일시', '위치', '인명'].map((h) => (
|
{h}
|
))}
{opsList.map((r) => {
const status = getStatusLabel(r.sttsCd);
return (
|
{status.label}
|
{r.opsCd}
|
{r.vesselNm} |
{getTypeLabel(r.acdntTpCd)} |
{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}
|
{r.locDc ?? '—'}
|
{r.survivors ?? 0}/{r.totalCrew ?? 0}
|
);
})}
)}
);
}
/* ═══ 메인 RescueView ═══ */
export function RescueView() {
const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState('collision');
const [activeAnalysis, setActiveAnalysis] = useState('rescue');
const [incidents, setIncidents] = useState([]);
const [selectedAcdnt, setSelectedAcdnt] = useState(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(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 (
);
}
if (activeSubTab === 'scenario') {
return ;
}
if (activeSubTab === 'theory') {
return ;
}
return (
{/* 상단 사고 정보바 */}
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={new Set()}
showOverlays={false}
vessels={vessels}
onBoundsChange={setMapBounds}
/>
{/* 하단: 이벤트 로그 + 타임라인 */}
);
}