import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi'; import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi'; /* ─── Types ─── */ type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED'; type DetailView = 0 | 1 | 2; interface RescueScenario { id: string; name: string; severity: Severity; timeStep: string; datetime: string; gm: string; list: string; trim: string; buoyancy: number; oilRate: string; bmRatio: string; description: string; compartments: { name: string; status: string; color: string }[]; assessment: { label: string; value: string; color: string }[]; actions: { time: string; text: string; color: string }[]; } const SEV_STYLE: Record = { CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)', label: 'CRITICAL' }, HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)', label: 'HIGH' }, MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)', label: 'MEDIUM' }, RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)', label: 'RESOLVED' }, }; const SEV_COLOR: Record = { CRITICAL: 'var(--color-danger)', HIGH: 'var(--color-warning)', MEDIUM: 'var(--color-caution)', RESOLVED: 'var(--color-success)', }; /* ─── Color helpers ─── */ function gmColor(v: number) { return v < 1.0 ? 'var(--color-danger)' : v < 1.5 ? 'var(--color-caution)' : 'var(--color-success)'; } function listColor(v: number) { return v > 20 ? 'var(--color-danger)' : v > 10 ? '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 oilColor(v: number) { return v > 100 ? 'var(--color-danger)' : v > 30 ? 'var(--color-warning)' : v > 0 ? 'var(--color-caution)' : 'var(--color-success)'; } /* ─── API 시나리오 → 로컬 타입 변환 ─── */ function toRescueScenario(s: RescueScenarioItem, i: number): RescueScenario { return { id: `S-${String(i + 1).padStart(2, '0')}`, name: s.description?.split('.')[0] ?? s.timeStep, severity: s.svrtCd as Severity, timeStep: s.timeStep, datetime: s.scenarioDtm ? new Date(s.scenarioDtm).toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) + ' KST' : '—', gm: String(s.gmM ?? 0), list: String(s.listDeg ?? 0), trim: String(s.trimM ?? 0), buoyancy: s.buoyancyPct ?? 0, oilRate: s.oilRateLpm != null ? `${s.oilRateLpm} L/min` : '— L/min', bmRatio: s.bmRatioPct != null ? `${s.bmRatioPct}%` : '—%', description: s.description ?? '', compartments: s.compartments ?? [], assessment: s.assessment ?? [], actions: s.actions ?? [], }; } /* ─── ChartData 타입 ─── */ interface ChartDataItem { id: string; label: string; gm: number; list: number; buoy: number; oil: number; bm: number; severity: Severity; } /* ─── 시나리오 관리 요건 ─── */ const SCENARIO_MGMT_GUIDELINES = [ '긴급구난 R&D 분석 결과는 시간 단계별 시나리오의 형태로 관리되어야 함', '각 시나리오는 사고 발생 시점부터 구난 진행 단계별 상태 변화를 포함하여야 함', '시나리오별 분석 결과는 사고 단위로 기존 사고 정보와 연계되어 관리되어야 함', '동일 사고에 대해 복수 시나리오(시간대, 조건별)가 존재할 경우, 상호 비교·검토가 되어야 함', '시나리오별 분석결과는 긴급구난 대응 판단을 지원할 수 있도록 요약 정보 형태로 제공되어야 함', '시나리오 관리 기능은 기존 통합지원시스템의 흐름과 연계되어 실질적인 구난 대응 업무에 활용 가능하도록 반영되어야 함', '긴급구난 시나리오 관리 기능 구현 시 1차 구축 완료된 GIS기능을 활용하여 구축하여 재개발하거나 중복구현하지 않도록 함', ]; /* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */ const MOCK_SCENARIOS: RescueScenarioItem[] = [ { scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h', scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL', gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0, description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, { name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '위험 (GM 0.8m < IMO 1.0m)', color: 'var(--red)' }, { label: '유출 위험', value: '활발 유출중 (100 L/min)', color: 'var(--red)' }, { label: '선체 강도', value: 'BM 92% (경계)', color: 'var(--orange)' }, { label: '승선인원', value: '15/20 확인, 5명 수색중', color: 'var(--red)' }, ], actions: [ { time: '10:30', text: '충돌 발생, VHF Ch.16 조난 통보', color: 'var(--red)' }, { time: '10:32', text: 'EPIRB 자동 발신 확인', color: 'var(--red)' }, { time: '10:35', text: '해경 3009함 출동 지시', color: 'var(--orange)' }, { time: '10:42', text: '인근 선박 구조 활동 개시', color: 'var(--cyan)' }, ], sortOrd: 1, }, { scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m', scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL', gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0, description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, { name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '악화 (GM 0.7m, GZ 커브 감소)', color: 'var(--red)' }, { label: '유출 위험', value: '증가 (120 L/min)', color: 'var(--red)' }, { label: '선체 강도', value: 'BM 90% — 종강도 모니터링 개시', color: 'var(--orange)' }, { label: '승선인원', value: '15명 퇴선, 5명 수색중', color: 'var(--red)' }, ], actions: [ { time: '10:50', text: '잠수사 투입, 수중 손상 조사 개시', color: 'var(--cyan)' }, { time: '10:55', text: '파공 규모 확인: 1.2m×0.8m', color: 'var(--red)' }, { time: '11:00', text: '손상복원성 재계산 — IMO 기준 위험', color: 'var(--red)' }, { time: '11:00', text: '유출유 방제선 배치 요청', color: 'var(--orange)' }, ], sortOrd: 2, }, { scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h', scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL', gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0, description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#2 Port Tank', status: 'FLOODING', color: 'var(--red)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '한계 접근 (GM 0.65m)', color: 'var(--red)' }, { label: '유출 위험', value: '파공 확대 우려 (135 L/min)', color: 'var(--red)' }, { label: '선체 강도', value: 'BM 89% — Hogging 증가', color: 'var(--orange)' }, { label: '인명구조', value: '실종 5명 수색중, 표류 1.2nm', color: 'var(--red)' }, ], actions: [ { time: '11:10', text: '해경 3009함 현장 도착, SAR 구역 설정', color: 'var(--cyan)' }, { time: '11:15', text: 'Leeway 표류 예측 모델 적용', color: 'var(--cyan)' }, { time: '11:20', text: '회전익 항공기 수색 개시', color: 'var(--cyan)' }, { time: '11:30', text: '#2 Port Tank 2차 침수 징후', color: 'var(--red)' }, ], sortOrd: 3, }, { scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h', scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL', gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0, description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: 'Engine Room', status: 'RISK', color: 'var(--orange)' }, { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '위기 (GM 0.5m, FSE 보정)', color: 'var(--red)' }, { label: '유출 위험', value: '최대치 접근 (160 L/min)', color: 'var(--red)' }, { label: '선체 강도', value: 'BM 86% — Sagging 경고', color: 'var(--red)' }, { label: '승선인원', value: '실종 3명 발견, 2명 수색', color: 'var(--orange)' }, ], actions: [ { time: '12:00', text: '#2 Port Tank 격벽 관통 침수', color: 'var(--red)' }, { time: '12:10', text: '자유표면효과(FSE) 보정 재계산', color: 'var(--red)' }, { time: '12:15', text: '긴급 Counter-Flooding 검토', color: 'var(--orange)' }, { time: '12:30', text: '실종자 3명 추가 발견 구조', color: 'var(--green)' }, ], sortOrd: 4, }, { scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h', scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH', gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0, description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: 'Engine Room', status: 'RISK', color: 'var(--orange)' }, { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, ], assessment: [ { label: '복원력', value: '개선 중 (GM 0.55m, 경사 16°)', color: 'var(--orange)' }, { label: '유출 위험', value: '감소 추세 (140 L/min)', color: 'var(--orange)' }, { label: '선체 강도', value: 'BM 87% — Counter-Flooding 평가', color: 'var(--orange)' }, { label: '구조 상황', value: '실종 2명 수색 지속', color: 'var(--orange)' }, ], actions: [ { time: '12:45', text: 'Counter-Flooding — #3 Stbd 주입 개시', color: 'var(--orange)' }, { time: '13:00', text: '평형수 280톤 주입, 경사 교정 진행', color: 'var(--cyan)' }, { time: '13:15', text: '종강도 재계산 — 허용 범위 내', color: 'var(--cyan)' }, { time: '13:30', text: '횡경사 16° 안정화 확인', color: 'var(--green)' }, ], sortOrd: 5, }, { scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h', scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH', gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0, description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, ], assessment: [ { label: '복원력', value: '개선 (GM 0.7m, 예인 가능)', color: 'var(--orange)' }, { label: '유출 위험', value: '수중패치 효과 (80 L/min)', color: 'var(--orange)' }, { label: '선체 강도', value: 'BM 90% — 안정 범위', color: 'var(--green)' }, { label: '구조 상황', value: '전원 구조 완료', color: 'var(--green)' }, ], actions: [ { time: '14:00', text: '수중패치 설치 작업 개시', color: 'var(--cyan)' }, { time: '14:30', text: '수중패치 설치 완료', color: 'var(--green)' }, { time: '15:00', text: '해상크레인 도착, 잔류유 이적 준비', color: 'var(--cyan)' }, { time: '16:30', text: '잔류유 1차 이적 완료 (45kL)', color: 'var(--green)' }, ], sortOrd: 6, }, { scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h', scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM', gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0, description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, ], assessment: [ { label: '복원력', value: '안정 (GM 0.8m)', color: 'var(--orange)' }, { label: '유출 위험', value: '방제 진행 (55 L/min, 회수 35%)', color: 'var(--orange)' }, { label: '선체 강도', value: 'BM 91%', color: 'var(--green)' }, { label: '방제 현황', value: '오일붐 2중, 유회수기 3대', color: 'var(--cyan)' }, ], actions: [ { time: '17:00', text: '오일붐 1차 전개 (500m)', color: 'var(--cyan)' }, { time: '17:30', text: '오일붐 2차 전개 (이중 방어선)', color: 'var(--cyan)' }, { time: '17:45', text: '유회수기 3대 배치·가동', color: 'var(--cyan)' }, { time: '18:30', text: 'GNOME 확산 예측 갱신', color: 'var(--orange)' }, ], sortOrd: 7, }, { scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h', scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM', gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0, description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, ], assessment: [ { label: '복원력', value: '안정 (GM 0.9m)', color: 'var(--orange)' }, { label: '유출 위험', value: '억제 중 (30 L/min)', color: 'var(--green)' }, { label: '선체 강도', value: 'BM 94%', color: 'var(--green)' }, { label: '예인 상태', value: '목포항, ETA 14h, 3kn', color: 'var(--cyan)' }, ], actions: [ { time: '18:00', text: '예인 접속, 예인삭 250m 전개', color: 'var(--cyan)' }, { time: '18:30', text: '예인 개시 (목포항 방향)', color: 'var(--cyan)' }, { time: '20:00', text: '야간 감시 체제 전환', color: 'var(--orange)' }, { time: '22:30', text: '예인 진행률 30%, 선체 안정', color: 'var(--green)' }, ], sortOrd: 8, }, { scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h', scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM', gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0, description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '양호 (GM 1.0m, IMO 충족)', color: 'var(--green)' }, { label: '유출 위험', value: '미량 유출 (15 L/min)', color: 'var(--green)' }, { label: '선체 강도', value: 'BM 96% 정상', color: 'var(--green)' }, { label: '예인 상태', value: '진행률 65%, ETA 5.5h', color: 'var(--cyan)' }, ], actions: [ { time: '00:00', text: '야간 예인 정상 진행', color: 'var(--green)' }, { time: '02:00', text: '파랑 응답 분석 — 안전 확인', color: 'var(--green)' }, { time: '03:00', text: '잔류유 유출률 15 L/min', color: 'var(--green)' }, { time: '04:30', text: '목포항 VTS 통보, 입항 협의', color: 'var(--cyan)' }, ], sortOrd: 9, }, { scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h', scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED', gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0, description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', compartments: [ { name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' }, { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, { name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' }, ], assessment: [ { label: '복원력', value: '안전 (GM 1.2m)', color: 'var(--green)' }, { label: '유출 위험', value: '차단 완료', color: 'var(--green)' }, { label: '선체 강도', value: 'BM 98% 정상', color: 'var(--green)' }, { label: '최종 상태', value: '접안 완료, 상황 종료', color: 'var(--green)' }, ], actions: [ { time: '06:00', text: '목포항 접근, 도선사 대기', color: 'var(--cyan)' }, { time: '08:00', text: '도선사 승선, 접안 개시', color: 'var(--cyan)' }, { time: '09:30', text: '접안 완료, 잔류유 이적선 접현', color: 'var(--green)' }, { time: '10:30', text: '잔류유 전량 이적, 상황 종료', color: 'var(--green)' }, ], sortOrd: 10, }, ]; const MOCK_OPS: RescueOpsItem[] = [ { rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision', vesselNm: 'M/V SEA GUARDIAN', commanderNm: null, lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E', depthM: 25.0, currentDc: '2.5kn NE', gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0, totalCrew: 20, survivors: 15, missing: 5, hydroData: null, gmdssData: null, sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z', }, ]; /* ═══════════════════════════════════════════════════════════════════ RescueScenarioView ═══════════════════════════════════════════════════════════════════ */ export function RescueScenarioView() { const [ops, setOps] = useState([]); const [apiScenarios, setApiScenarios] = useState([]); const [loading, setLoading] = useState(true); const [selectedIncident, setSelectedIncident] = useState(0); const [checked, setChecked] = useState>(new Set()); const [selectedId, setSelectedId] = useState(''); const [sortBy, setSortBy] = useState<'time' | 'risk'>('time'); const [detailView, setDetailView] = useState(0); const [newScnModalOpen, setNewScnModalOpen] = useState(false); const [guideOpen, setGuideOpen] = useState(false); const loadScenarios = useCallback(async (opsSn: number) => { setLoading(true); try { const items = await fetchRescueScenarios(opsSn); setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS); } catch { setApiScenarios(MOCK_SCENARIOS); } finally { setLoading(false); } }, []); const loadOps = useCallback(async () => { try { const items = await fetchRescueOps(); if (items.length > 0) { setOps(items); loadScenarios(items[0].rescueOpsSn); } else { setOps(MOCK_OPS); setApiScenarios(MOCK_SCENARIOS); setLoading(false); } } catch { setOps(MOCK_OPS); setApiScenarios(MOCK_SCENARIOS); setLoading(false); } }, [loadScenarios]); useEffect(() => { loadOps(); }, [loadOps]); useEffect(() => { if (ops.length > 0 && ops[selectedIncident]) { loadScenarios(ops[selectedIncident].rescueOpsSn); } }, [selectedIncident, ops, loadScenarios]); /* API 시나리오 → 로컬 타입 변환 */ const scenarios: RescueScenario[] = apiScenarios.map(toRescueScenario); /* checked / selectedId: apiScenarios 변경 시 초기화 */ useEffect(() => { setChecked(new Set(scenarios.map((s) => s.id))); if (scenarios.length > 0) setSelectedId(scenarios[0].id); }, [apiScenarios]); // eslint-disable-line react-hooks/exhaustive-deps /* chartData: scenarios에서 파생 */ const chartData: ChartDataItem[] = scenarios.map((s) => ({ id: s.id, label: s.timeStep, gm: parseFloat(s.gm), list: parseFloat(s.list), buoy: s.buoyancy, oil: parseFloat(s.oilRate), bm: parseFloat(s.bmRatio), severity: s.severity, })); const selected = scenarios.find((s) => s.id === selectedId); const sorted = [...scenarios].sort((a, b) => { if (sortBy === 'risk') { const order: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, RESOLVED: 3 }; return order[a.severity] - order[b.severity]; } return 0; }); const toggleCheck = (id: string) => { setChecked((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; return (
{/* ── Header ── */}
📊
긴급구난 시나리오 관리
시간 단계별 시나리오 비교·검토 및 구난 의사결정 지원 (SFR-009)
{/* ── 시나리오 관리 요건 가이드라인 ── */} {guideOpen && (

시나리오 관리 요건

    {SCENARIO_MGMT_GUIDELINES.map((g, i) => (
  • {i + 1}. {g}
  • ))}
)} {/* ── Content: Left List + Right Detail ── */}
{/* ═══ LEFT: 시나리오 목록 ═══ */}
{/* Sort bar */}
시나리오 목록 ({scenarios.length}개)
{(['time', 'risk'] as const).map((s) => ( ))}
{/* Card list */}
{loading && scenarios.length === 0 && (
시나리오 로딩 중...
)} {sorted.map((sc) => { const isSel = selectedId === sc.id; const sev = SEV_STYLE[sc.severity]; return (
setSelectedId(sc.id)} className={`hns-scn-card${isSel ? ' sel' : ''}`} > {/* Top: checkbox + ID + severity */}
{ e.stopPropagation(); toggleCheck(sc.id); }} style={{ accentColor: 'var(--color-accent)' }} /> {sc.id} {sc.name}
{sev.label}
{/* Time row */}
{sc.timeStep} {sc.datetime}
{/* KPI grid */}
{[ { label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) }, { label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)), }, { label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) }, { label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)), }, ].map((m, i) => (
{m.label}
{m.value}
))}
{/* Description */}
{sc.description}
); })}
{/* Bottom actions */}
{/* ═══ RIGHT: 상세/비교 ═══ */}
{/* Detail tabs */}
{(['📋 시나리오 상세', '📊 비교 차트', '🗺 지도 오버레이'] as const).map((label, i) => ( ))}
{/* View content */}
{/* ─── VIEW 0: 시나리오 상세 ─── */} {detailView === 0 && selected && (
{/* Header card */}
{selected.id} {selected.name} {selected.severity} {selected.datetime}
{/* 6 KPI cards */}
{[ { label: 'GM (복원심)', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)), }, { label: '횡경사 (List)', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)), }, { label: '종경사 (Trim)', value: `${selected.trim}m`, color: parseFloat(selected.trim) > 3 ? 'var(--color-danger)' : 'var(--fg-default)', }, { label: '잔존부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy), }, { label: '유출률', value: selected.oilRate, color: oilColor(parseFloat(selected.oilRate)), }, { label: 'BM 비율', value: selected.bmRatio, color: parseFloat(selected.bmRatio) > 100 ? 'var(--color-danger)' : parseFloat(selected.bmRatio) > 85 ? 'var(--color-warning)' : 'var(--color-success)', }, ].map((kpi) => (
{kpi.label}
{kpi.value}
))}
{/* 2-column: 침수구획 + 구난판단 */}
{/* 침수 구획 */}
🚢 침수 구획 상태
{selected.compartments.map((c, i) => (
{c.name} {c.status}
))}
{/* 구난 판단 */}
⚠️ 구난 판단 요약
{selected.assessment.map((a, i) => (
{a.label}
{a.value}
))}
{/* 대응 조치 이력 */}
📋 대응 조치 이력
{selected.actions.map((a, i) => (
{a.time}
{a.text}
))}
)} {/* ─── VIEW 1: 비교 차트 ─── */} {detailView === 1 && } {/* ─── VIEW 2: 지도 오버레이 ─── */} {detailView === 2 && ( )}
{/* ═══ 신규 시나리오 모달 ═══ */} {newScnModalOpen && setNewScnModalOpen(false)} />}
); } /* ═══ 지도 오버레이 ═══ */ interface ScenarioMapOverlayProps { ops: RescueOpsItem[]; selectedIncident: number; scenarios: RescueScenario[]; selectedId: string; checked: Set; onSelectScenario: (id: string) => void; } function ScenarioMapOverlay({ ops, selectedIncident, scenarios, selectedId, checked, onSelectScenario, }: ScenarioMapOverlayProps) { const [popupId, setPopupId] = useState(null); const baseMapStyle = useBaseMapStyle(); const currentOp = ops[selectedIncident] ?? null; const center = useMemo<[number, number]>( () => currentOp?.lon != null && currentOp?.lat != null ? [currentOp.lon, currentOp.lat] : [126.25, 37.467], [currentOp], ); const visibleScenarios = useMemo( () => scenarios.filter((s) => checked.has(s.id)), [scenarios, checked], ); const selected = scenarios.find((s) => s.id === selectedId); return (
{/* 시나리오 선택 바 */}
시나리오: {visibleScenarios.map((sc) => { const sev = SEV_STYLE[sc.severity]; const isActive = selectedId === sc.id; return ( ); })}
{/* 지도 영역 */}
{/* 사고 위치 마커 */} {currentOp && currentOp.lon != null && currentOp.lat != null && (
)} {/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */} {visibleScenarios.map((sc, idx) => { const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2; const radius = 0.015 + idx * 0.003; const lng = center[0] + Math.cos(angle) * radius; const lat = center[1] + Math.sin(angle) * radius * 0.8; const sev = SEV_STYLE[sc.severity]; const isActive = selectedId === sc.id; return ( { e.originalEvent.stopPropagation(); onSelectScenario(sc.id); setPopupId(popupId === sc.id ? null : sc.id); }} >
{sc.timeStep.replace('T+', '')}
); })} {/* 팝업 — 클릭한 시나리오 정보 표출 */} {popupId && (() => { const sc = visibleScenarios.find((s) => s.id === popupId); if (!sc) return null; const idx = visibleScenarios.indexOf(sc); const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2; const radius = 0.015 + idx * 0.003; const lng = center[0] + Math.cos(angle) * radius; const lat = center[1] + Math.sin(angle) * radius * 0.8; const sev = SEV_STYLE[sc.severity]; return ( setPopupId(null)} maxWidth="320px" className="rescue-map-popup" >
{sc.id} {sc.timeStep} {sev.label}
{sc.description}
{/* KPI */}
{[ { label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) }, { label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) }, { label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) }, { label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) }, ].map((m) => (
{m.label}
{m.value}
))}
{/* 구획 상태 */} {sc.compartments.length > 0 && (
구획 상태
{sc.compartments.map((c) => ( {c.name}: {c.status} ))}
)}
); })()} {/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */} {selected && (
{selected.id} {selected.timeStep} {SEV_STYLE[selected.severity].label}
{[ { label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) }, { label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) }, { label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) }, { label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) }, ].map((m) => (
{m.label}
{m.value}
))}
{selected.description.slice(0, 120)} {selected.description.length > 120 ? '...' : ''}
)} {/* 우측 상단 — 범례 */}
시나리오 범례
{(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => (
{SEV_STYLE[sev].label}
))}
사고 위치
); } /* ═══ 신규 시나리오 생성 모달 ═══ */ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) { const overlayRef = useRef(null); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); const handleSubmit = () => { setSubmitting(true); setTimeout(() => { setSubmitting(false); setDone(true); }, 2000); }; /* ── shared className helpers ── */ const labelCls = 'text-caption text-fg-disabled block mb-1'; const inputCls = 'w-full px-3 py-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none'; const numCls = `${inputCls} font-mono`; const sectionIcon = (n: number) => (
{n}
); const sectionTitleCls = 'text-label-2 font-bold mb-2.5 flex items-center gap-1.5'; return (
{ if (e.target === overlayRef.current) onClose(); }} className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,.65)', backdropFilter: 'blur(6px)' }} >
{/* ── 헤더 ── */}
🚨
신규 긴급구난 시나리오 생성
선박 사고 조건 및 구난 분석 파라미터를 설정합니다 (SFR-009)
{/* ── 본문 스크롤 ── */}
{/* ① 기본 정보 */}
{sectionIcon(1)} 기본 정보
{/* ② 선박 정보 */}
{sectionIcon(2)} 선박 정보
{/* ③ 사고 조건 · 선체 상태 */}
{sectionIcon(3)} 사고 조건 · 선체 상태
{/* 침수 상태 */}
💧 침수 상태
{/* ④ 사고 위치 · 해상 조건 */}
{sectionIcon(4)} 사고 위치 · 해상 조건
{/* ⑤ 구난 분석 설정 */}
{sectionIcon(5)} 구난 분석 설정
{['복원성', '예인력', '인양력', '유출 위험'].map((item) => ( ))}
{/* R&D 연계 분석 */}
🔗 R&D 연계 분석
화물 유출 가능성이 있는 경우, 긴급구난 분석 결과와 확산예측을 동시에 수행하여 종합 대응 판단을 지원합니다
{/* ⑥ 비고 */}
{sectionIcon(6)} 비고