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 피드 */}
CCTV FEED #1
● REC
); } /* ─── 중앙 지도 영역 ─── */ /* ─── 오른쪽 분석 패널 ─── */ 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
FLOODED IMPACT WL LIST: {d.list}° TRIM: {d.trim}m
{/* 핵심 지표 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)
비손상 GZ 손상 GZ IMO MIN GZ(m) 0.6 0.4 0.2 0 15° 30° 45° 60°
{/* 복원성 지표 */}
침수 구획수
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) => (
{t.name}
{t.status}
))}
); } /* ─── 종강도 패널 ─── */ // 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)
LIMIT 손상 후 SF ▲ +SF 0 -SF AP MID FP
{/* 굽힘모멘트 분포 SVG */}
굽힘모멘트 분포 (Bending Moment Distribution)
LIMIT 손상 후 BM ▲ +BM 0 AP MID FP
{/* 종강도 지표 */}
SF 최대/허용 비율
88%
BM 최대/허용 비율
92%
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 (
긴급구난 사고 목록
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)]" />
{loading ? (
로딩 중...
) : opsList.length === 0 ? (
구난 작전 데이터가 없습니다.
) : ( {['상태', '사고번호', '선박명', '사고유형', '발생일시', '위치', '인명'].map((h) => ( ))} {opsList.map((r) => { const status = getStatusLabel(r.sttsCd); return ( ); })}
{h}
{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} />
{/* 하단: 이벤트 로그 + 타임라인 */}
); }