2008 lines
85 KiB
TypeScript
Executable File
2008 lines
85 KiB
TypeScript
Executable File
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<Severity, { bg: string; color: string; label: string }> = {
|
||
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<Severity, string> = {
|
||
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<RescueOpsItem[]>([]);
|
||
const [apiScenarios, setApiScenarios] = useState<RescueScenarioItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedIncident, setSelectedIncident] = useState(0);
|
||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||
const [selectedId, setSelectedId] = useState('');
|
||
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time');
|
||
const [detailView, setDetailView] = useState<DetailView>(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<Severity, number> = { 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 (
|
||
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-base">
|
||
{/* ── Header ── */}
|
||
<div className="px-5 py-3.5 border-b border-stroke flex items-center justify-between shrink-0">
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="text-base">📊</span>
|
||
<div>
|
||
<div className="text-title-4 font-bold">긴급구난 시나리오 관리</div>
|
||
<div className="text-label-2 text-fg-disabled">
|
||
시간 단계별 시나리오 비교·검토 및 구난 의사결정 지원 (SFR-009)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<select
|
||
value={selectedIncident}
|
||
onChange={(e) => setSelectedIncident(Number(e.target.value))}
|
||
className="prd-i w-[280px] text-label-2"
|
||
>
|
||
{ops.map((op, i) => (
|
||
<option key={op.rescueOpsSn} value={i}>
|
||
{op.opsCd} · {op.vesselNm}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
onClick={() => setNewScnModalOpen(true)}
|
||
className="cursor-pointer whitespace-nowrap font-semibold text-color-accent text-label-2 px-[14px] py-1.5 rounded-sm"
|
||
style={{
|
||
border: '1px solid rgba(6,182,212,.3)',
|
||
background: 'rgba(6,182,212,.08)',
|
||
}}
|
||
>
|
||
+ 신규 시나리오
|
||
</button>
|
||
<button
|
||
onClick={() => setGuideOpen((v) => !v)}
|
||
className="cursor-pointer whitespace-nowrap font-semibold text-label-2 px-[14px] py-1.5 rounded-sm"
|
||
style={{
|
||
border: '1px solid rgba(6,182,212,.15)',
|
||
background: guideOpen ? 'rgba(6,182,212,.12)' : 'transparent',
|
||
color: guideOpen ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
{guideOpen ? '▴ 관리 요건' : '▾ 관리 요건'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 시나리오 관리 요건 가이드라인 ── */}
|
||
{guideOpen && (
|
||
<div className="border-b border-stroke px-5 py-3 bg-bg-surface shrink-0">
|
||
<p className="text-label-1 font-bold mb-2">시나리오 관리 요건</p>
|
||
<ul className="flex flex-col gap-1">
|
||
{SCENARIO_MGMT_GUIDELINES.map((g, i) => (
|
||
<li key={i} className="text-caption text-fg-sub leading-relaxed flex gap-1.5">
|
||
<span className="text-color-accent shrink-0">{i + 1}.</span>
|
||
<span>{g}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Content: Left List + Right Detail ── */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* ═══ LEFT: 시나리오 목록 ═══ */}
|
||
<div
|
||
className="flex flex-col overflow-hidden shrink-0 border-r border-stroke bg-bg-surface"
|
||
style={{ width: '370px', minWidth: '370px' }}
|
||
>
|
||
{/* Sort bar */}
|
||
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
|
||
<span className="text-label-2 font-bold text-fg-disabled">
|
||
시나리오 목록 ({scenarios.length}개)
|
||
</span>
|
||
<div className="flex gap-1">
|
||
{(['time', 'risk'] as const).map((s) => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setSortBy(s)}
|
||
className={`cursor-pointer px-2 py-[3px] text-caption font-semibold rounded-sm border border-stroke ${
|
||
sortBy === s
|
||
? 'bg-[rgba(6,182,212,0.08)] text-color-accent'
|
||
: 'bg-bg-card text-fg-disabled'
|
||
}`}
|
||
>
|
||
{s === 'time' ? '시간순' : '위험도순'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card list */}
|
||
<div
|
||
className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2"
|
||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||
>
|
||
{loading && scenarios.length === 0 && (
|
||
<div className="text-center py-10 text-label-2 text-fg-disabled">
|
||
시나리오 로딩 중...
|
||
</div>
|
||
)}
|
||
{sorted.map((sc) => {
|
||
const isSel = selectedId === sc.id;
|
||
const sev = SEV_STYLE[sc.severity];
|
||
return (
|
||
<div
|
||
key={sc.id}
|
||
onClick={() => setSelectedId(sc.id)}
|
||
className={`hns-scn-card${isSel ? ' sel' : ''}`}
|
||
>
|
||
{/* Top: checkbox + ID + severity */}
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked.has(sc.id)}
|
||
onChange={(e) => {
|
||
e.stopPropagation();
|
||
toggleCheck(sc.id);
|
||
}}
|
||
style={{ accentColor: 'var(--color-accent)' }}
|
||
/>
|
||
<span className="text-label-1 font-bold">
|
||
{sc.id} {sc.name}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className="font-bold px-2 py-[2px] rounded-lg text-caption"
|
||
style={{ background: sev.bg, color: sev.color }}
|
||
>
|
||
{sev.label}
|
||
</span>
|
||
</div>
|
||
{/* Time row */}
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<span
|
||
className="font-bold font-mono text-color-accent text-caption px-1.5 py-[2px] rounded-[3px]"
|
||
style={{ background: 'rgba(6,182,212,0.1)' }}
|
||
>
|
||
{sc.timeStep}
|
||
</span>
|
||
<span className="text-caption text-fg-disabled font-mono">{sc.datetime}</span>
|
||
</div>
|
||
{/* KPI grid */}
|
||
<div className="grid grid-cols-4 gap-1 font-mono text-caption">
|
||
{[
|
||
{ 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) => (
|
||
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
|
||
<div className="text-fg-disabled text-caption">{m.label}</div>
|
||
<div className="font-bold" style={{ color: m.color }}>
|
||
{m.value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Description */}
|
||
<div className="text-fg-sub mt-1.5 text-caption leading-[1.4]">
|
||
{sc.description}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Bottom actions */}
|
||
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
||
<button
|
||
onClick={() => setDetailView(1)}
|
||
className="flex-1 cursor-pointer font-bold text-static-white text-label-2 p-2 rounded-sm bg-color-navy hover:bg-color-navy-hover"
|
||
>
|
||
📊 선택 시나리오 비교
|
||
</button>
|
||
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
||
📄 보고서
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ RIGHT: 상세/비교 ═══ */}
|
||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||
{/* Detail tabs */}
|
||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
||
{(['📋 시나리오 상세', '📊 비교 차트', '🗺 지도 오버레이'] as const).map((label, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => setDetailView(i as DetailView)}
|
||
className={`rsc-atab ${detailView === i ? 'on' : ''}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* View content */}
|
||
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
|
||
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
||
{detailView === 0 && selected && (
|
||
<div className="p-5">
|
||
{/* Header card */}
|
||
<div className="px-5 py-4 rounded-[10px] bg-bg-card border border-stroke mb-4">
|
||
<div className="flex items-center gap-2.5 mb-2.5">
|
||
<span className="text-base font-extrabold font-mono text-color-accent">
|
||
{selected.id}
|
||
</span>
|
||
<span className="text-label-1 font-bold">{selected.name}</span>
|
||
<span
|
||
className="px-2 py-[2px] rounded-lg text-caption font-bold font-mono"
|
||
style={{
|
||
background: SEV_STYLE[selected.severity].bg,
|
||
color: SEV_STYLE[selected.severity].color,
|
||
}}
|
||
>
|
||
{selected.severity}
|
||
</span>
|
||
<span className="ml-auto text-label-2 text-fg-disabled font-mono">
|
||
{selected.datetime}
|
||
</span>
|
||
</div>
|
||
{/* 6 KPI cards */}
|
||
<div
|
||
className="grid gap-2 text-caption text-center"
|
||
style={{ gridTemplateColumns: 'repeat(6,1fr)' }}
|
||
>
|
||
{[
|
||
{
|
||
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) => (
|
||
<div
|
||
key={kpi.label}
|
||
className="px-1 py-2 bg-bg-card rounded-md border border-stroke"
|
||
>
|
||
<div className="text-fg-disabled mb-1">{kpi.label}</div>
|
||
<div
|
||
className="text-lg font-extrabold font-mono"
|
||
style={{ color: kpi.color }}
|
||
>
|
||
{kpi.value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 2-column: 침수구획 + 구난판단 */}
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
{/* 침수 구획 */}
|
||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||
<div className="text-label-2 font-bold mb-2.5">🚢 침수 구획 상태</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{selected.compartments.map((c, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center justify-between px-2.5 py-1.5 bg-bg-base rounded"
|
||
style={{ borderLeft: `3px solid ${c.color}` }}
|
||
>
|
||
<span className="text-caption">{c.name}</span>
|
||
<span
|
||
className="text-caption font-semibold font-mono"
|
||
style={{ color: c.color }}
|
||
>
|
||
{c.status}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* 구난 판단 */}
|
||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||
<div className="text-label-2 font-bold mb-2.5">⚠️ 구난 판단 요약</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{selected.assessment.map((a, i) => (
|
||
<div
|
||
key={i}
|
||
className="px-2.5 py-2 bg-bg-base rounded"
|
||
style={{ borderLeft: `3px solid ${a.color}` }}
|
||
>
|
||
<div className="text-caption font-bold" style={{ color: a.color }}>
|
||
{a.label}
|
||
</div>
|
||
<div className="text-caption text-fg-sub mt-0.5">{a.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 대응 조치 이력 */}
|
||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||
<div className="text-label-2 font-bold mb-2.5">📋 대응 조치 이력</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{selected.actions.map((a, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center gap-2.5 px-2.5 py-1.5 bg-bg-base rounded"
|
||
>
|
||
<span
|
||
className="text-label-2 font-bold font-mono min-w-[40px]"
|
||
style={{ color: a.color }}
|
||
>
|
||
{a.time}
|
||
</span>
|
||
<div
|
||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||
style={{ background: a.color }}
|
||
/>
|
||
<span className="text-caption">{a.text}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── VIEW 1: 비교 차트 ─── */}
|
||
{detailView === 1 && <ScenarioComparison chartData={chartData} />}
|
||
|
||
{/* ─── VIEW 2: 지도 오버레이 ─── */}
|
||
{detailView === 2 && (
|
||
<ScenarioMapOverlay
|
||
ops={ops}
|
||
selectedIncident={selectedIncident}
|
||
scenarios={scenarios}
|
||
selectedId={selectedId}
|
||
checked={checked}
|
||
onSelectScenario={setSelectedId}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ 신규 시나리오 모달 ═══ */}
|
||
{newScnModalOpen && <NewScenarioModal ops={ops} onClose={() => setNewScnModalOpen(false)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══ 지도 오버레이 ═══ */
|
||
interface ScenarioMapOverlayProps {
|
||
ops: RescueOpsItem[];
|
||
selectedIncident: number;
|
||
scenarios: RescueScenario[];
|
||
selectedId: string;
|
||
checked: Set<string>;
|
||
onSelectScenario: (id: string) => void;
|
||
}
|
||
|
||
function ScenarioMapOverlay({
|
||
ops,
|
||
selectedIncident,
|
||
scenarios,
|
||
selectedId,
|
||
checked,
|
||
onSelectScenario,
|
||
}: ScenarioMapOverlayProps) {
|
||
const [popupId, setPopupId] = useState<string | null>(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 (
|
||
<div className="flex flex-col flex-1 overflow-hidden">
|
||
{/* 시나리오 선택 바 */}
|
||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface shrink-0 overflow-x-auto">
|
||
<span className="text-caption text-fg-disabled shrink-0">시나리오:</span>
|
||
{visibleScenarios.map((sc) => {
|
||
const sev = SEV_STYLE[sc.severity];
|
||
const isActive = selectedId === sc.id;
|
||
return (
|
||
<button
|
||
key={sc.id}
|
||
onClick={() => onSelectScenario(sc.id)}
|
||
className="cursor-pointer shrink-0 px-2 py-1 rounded text-caption font-semibold transition-all"
|
||
style={{
|
||
border: `1.5px solid ${isActive ? sev.color : sev.color + '40'}`,
|
||
background: isActive ? sev.bg : 'transparent',
|
||
color: isActive ? sev.color : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
{sc.id} {sc.timeStep}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 지도 영역 */}
|
||
<div className="flex-1 relative">
|
||
<Map
|
||
initialViewState={{ longitude: center[0], latitude: center[1], zoom: 11 }}
|
||
mapStyle={baseMapStyle}
|
||
style={{ width: '100%', height: '100%' }}
|
||
attributionControl={false}
|
||
>
|
||
{/* 사고 위치 마커 */}
|
||
{currentOp && currentOp.lon != null && currentOp.lat != null && (
|
||
<Marker longitude={currentOp.lon} latitude={currentOp.lat} anchor="center">
|
||
<div
|
||
className="flex items-center justify-center"
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
background: 'rgba(239,68,68,0.25)',
|
||
border: '2px solid var(--color-danger)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: '50%',
|
||
background: 'var(--color-danger)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</Marker>
|
||
)}
|
||
|
||
{/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */}
|
||
{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 (
|
||
<Marker
|
||
key={sc.id}
|
||
longitude={lng}
|
||
latitude={lat}
|
||
anchor="center"
|
||
onClick={(e) => {
|
||
e.originalEvent.stopPropagation();
|
||
onSelectScenario(sc.id);
|
||
setPopupId(popupId === sc.id ? null : sc.id);
|
||
}}
|
||
>
|
||
<div
|
||
className="cursor-pointer flex items-center justify-center transition-transform"
|
||
style={{
|
||
width: isActive ? 36 : 28,
|
||
height: isActive ? 36 : 28,
|
||
borderRadius: '50%',
|
||
background: sev.bg,
|
||
border: `2px solid ${sev.color}`,
|
||
transform: isActive ? 'scale(1.15)' : 'scale(1)',
|
||
boxShadow: isActive ? `0 0 12px ${sev.color}60` : 'none',
|
||
zIndex: isActive ? 10 : 1,
|
||
}}
|
||
>
|
||
<span
|
||
className="font-bold font-mono"
|
||
style={{ fontSize: isActive ? 11 : 9, color: sev.color }}
|
||
>
|
||
{sc.timeStep.replace('T+', '')}
|
||
</span>
|
||
</div>
|
||
</Marker>
|
||
);
|
||
})}
|
||
|
||
{/* 팝업 — 클릭한 시나리오 정보 표출 */}
|
||
{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 (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
anchor="bottom"
|
||
closeOnClick={false}
|
||
onClose={() => setPopupId(null)}
|
||
maxWidth="320px"
|
||
className="rescue-map-popup"
|
||
>
|
||
<div style={{ padding: '8px 4px', minWidth: 260, background: 'var(--bg-card)', color: 'var(--fg)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||
<span style={{ fontWeight: 800, fontFamily: 'monospace', color: sev.color, fontSize: 13 }}>
|
||
{sc.id}
|
||
</span>
|
||
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
|
||
<span
|
||
style={{
|
||
marginLeft: 'auto',
|
||
padding: '1px 6px',
|
||
borderRadius: 8,
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
background: sev.bg,
|
||
color: sev.color,
|
||
}}
|
||
>
|
||
{sev.label}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--fg-sub)', lineHeight: 1.5, marginBottom: 6 }}>
|
||
{sc.description}
|
||
</div>
|
||
{/* KPI */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||
{[
|
||
{ 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) => (
|
||
<div
|
||
key={m.label}
|
||
style={{
|
||
textAlign: 'center',
|
||
padding: '3px 2px',
|
||
borderRadius: 3,
|
||
background: 'var(--bg-base)',
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div>
|
||
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>{m.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* 구획 상태 */}
|
||
{sc.compartments.length > 0 && (
|
||
<div style={{ marginBottom: 4 }}>
|
||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-disabled)', marginBottom: 3 }}>
|
||
구획 상태
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||
{sc.compartments.map((c) => (
|
||
<span
|
||
key={c.name}
|
||
style={{
|
||
fontSize: 9,
|
||
padding: '1px 5px',
|
||
borderRadius: 3,
|
||
border: `1px solid ${c.color}40`,
|
||
color: c.color,
|
||
}}
|
||
>
|
||
{c.name}: {c.status}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
</Map>
|
||
|
||
{/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */}
|
||
{selected && (
|
||
<div
|
||
className="absolute bottom-3 left-3 z-10 rounded-lg border border-stroke overflow-hidden"
|
||
style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }}
|
||
>
|
||
<div className="px-3 py-2 border-b border-stroke flex items-center gap-2">
|
||
<span className="font-bold font-mono text-color-accent text-label-2">{selected.id}</span>
|
||
<span className="text-caption font-bold">{selected.timeStep}</span>
|
||
<span
|
||
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold"
|
||
style={{ background: SEV_STYLE[selected.severity].bg, color: SEV_STYLE[selected.severity].color }}
|
||
>
|
||
{SEV_STYLE[selected.severity].label}
|
||
</span>
|
||
</div>
|
||
<div className="px-3 py-2">
|
||
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-2">
|
||
{[
|
||
{ 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) => (
|
||
<div key={m.label} className="text-center p-1 bg-bg-base rounded">
|
||
<div className="text-fg-disabled" style={{ fontSize: 9 }}>{m.label}</div>
|
||
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="text-caption text-fg-sub leading-relaxed" style={{ fontSize: 10 }}>
|
||
{selected.description.slice(0, 120)}
|
||
{selected.description.length > 120 ? '...' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 우측 상단 — 범례 */}
|
||
<div
|
||
className="absolute top-3 right-3 z-10 rounded-lg border border-stroke px-3 py-2"
|
||
style={{ background: 'rgba(15,23,42,0.88)', backdropFilter: 'blur(8px)' }}
|
||
>
|
||
<div className="text-caption font-bold text-fg-disabled mb-1.5">시나리오 범례</div>
|
||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => (
|
||
<div key={sev} className="flex items-center gap-1.5 mb-0.5">
|
||
<span
|
||
className="inline-block rounded-full"
|
||
style={{ width: 8, height: 8, background: SEV_COLOR[sev] }}
|
||
/>
|
||
<span className="text-caption text-fg-sub">{SEV_STYLE[sev].label}</span>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
|
||
<span
|
||
className="inline-block rounded-full"
|
||
style={{ width: 8, height: 8, background: 'var(--color-danger)', border: '1px solid var(--color-danger)' }}
|
||
/>
|
||
<span className="text-caption text-fg-sub">사고 위치</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══ 신규 시나리오 생성 모달 ═══ */
|
||
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
|
||
const overlayRef = useRef<HTMLDivElement>(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) => (
|
||
<div
|
||
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-caption font-bold text-color-accent"
|
||
style={{ background: 'rgba(6,182,212,.12)' }}
|
||
>
|
||
{n}
|
||
</div>
|
||
);
|
||
const sectionTitleCls = 'text-label-2 font-bold mb-2.5 flex items-center gap-1.5';
|
||
|
||
return (
|
||
<div
|
||
ref={overlayRef}
|
||
onClick={(e) => {
|
||
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)' }}
|
||
>
|
||
<div
|
||
className="bg-bg-surface border border-stroke rounded-[14px] w-[700px] max-h-[88vh] flex flex-col overflow-hidden"
|
||
style={{ boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}
|
||
>
|
||
{/* ── 헤더 ── */}
|
||
<div className="px-6 pt-5 pb-4 border-b border-stroke shrink-0">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="text-lg">🚨</span>
|
||
<div>
|
||
<div className="text-title-4 font-bold">신규 긴급구난 시나리오 생성</div>
|
||
<div className="text-label-2 text-fg-disabled mt-0.5">
|
||
선박 사고 조건 및 구난 분석 파라미터를 설정합니다 (SFR-009)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span onClick={onClose} className="text-lg cursor-pointer text-fg-disabled p-1">
|
||
✕
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 본문 스크롤 ── */}
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-[18px] scrollbar-thin">
|
||
{/* ① 기본 정보 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(1)} 기본 정보</div>
|
||
<div className="grid grid-cols-2 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>
|
||
시나리오명 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<input type="text" placeholder="예: T+3h 기관실 침수 확대" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>
|
||
연계 사고 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<select defaultValue="0" className={inputCls}>
|
||
{ops.map((op, i) => (
|
||
<option key={op.rescueOpsSn} value={i}>
|
||
{op.opsCd} · {op.vesselNm}
|
||
</option>
|
||
))}
|
||
<option value="new">+ 신규 사고 등록...</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>
|
||
시간 단계 (Time Step) <span className="text-color-danger">*</span>
|
||
</label>
|
||
<select defaultValue="T+3h" className={inputCls}>
|
||
{[
|
||
'T+0h (사고 발생 직후)',
|
||
'T+1h',
|
||
'T+2h',
|
||
'T+3h',
|
||
'T+6h',
|
||
'T+12h',
|
||
'T+24h',
|
||
'T+48h',
|
||
].map((t) => (
|
||
<option key={t} value={t}>
|
||
{t}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>기준 시각</label>
|
||
<input type="datetime-local" defaultValue="2024-10-27T13:30" className={inputCls} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ② 선박 정보 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(2)} 선박 정보</div>
|
||
<div className="grid grid-cols-3 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>
|
||
선박명 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<input type="text" defaultValue="M/V SEA GUARDIAN" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>
|
||
선종 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<select defaultValue="화물선 (Cargo)" className={inputCls}>
|
||
{[
|
||
'유조선 (Tanker)',
|
||
'화물선 (Cargo)',
|
||
'컨테이너선 (Container)',
|
||
'여객선 (Passenger)',
|
||
'어선 (Fishing)',
|
||
'LNG선',
|
||
'케미컬선 (Chemical)',
|
||
'기타',
|
||
].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>총톤수 (GT)</label>
|
||
<input type="number" defaultValue={45000} step={100} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>선체 길이 (m)</label>
|
||
<input type="number" defaultValue={189} step={1} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>흘수 (m)</label>
|
||
<input type="number" defaultValue={8.5} step={0.1} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>승선 인원</label>
|
||
<input type="number" defaultValue={22} step={1} min={0} className={numCls} />
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>적재 화물</label>
|
||
<input type="text" placeholder="예: 일반화물, 벙커C 450kL" className={inputCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>연료유 종류 · 탑재량</label>
|
||
<input type="text" defaultValue="벙커C 450kL / MGO 80kL" className={inputCls} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ③ 사고 조건 · 선체 상태 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(3)} 사고 조건 · 선체 상태</div>
|
||
<div className="grid grid-cols-3 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>
|
||
사고 유형 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<select defaultValue="충돌 (Collision)" className={inputCls}>
|
||
{[
|
||
'충돌 (Collision)',
|
||
'좌초 (Grounding)',
|
||
'침수 (Flooding)',
|
||
'기관고장 (Engine Failure)',
|
||
'화재 (Fire)',
|
||
'전복 (Capsizing)',
|
||
'구조손상 (Structural)',
|
||
].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>손상 구역</label>
|
||
<select defaultValue="우현 중앙 (Starboard Mid)" className={inputCls}>
|
||
{[
|
||
'선수부 (Bow)',
|
||
'우현 중앙 (Starboard Mid)',
|
||
'좌현 중앙 (Port Mid)',
|
||
'선미부 (Stern)',
|
||
'기관실 (Engine Room)',
|
||
'선저 (Bottom)',
|
||
'복수 구역',
|
||
].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>파공 크기 (㎡)</label>
|
||
<input type="number" defaultValue={2.8} step={0.1} min={0} className={numCls} />
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-4 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>횡경사 (°)</label>
|
||
<input type="number" defaultValue={8.5} step={0.5} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>종경사 (°)</label>
|
||
<input type="number" defaultValue={2.1} step={0.1} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>건현 (m)</label>
|
||
<input type="number" defaultValue={3.2} step={0.1} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>GM (m)</label>
|
||
<input type="number" defaultValue={1.8} step={0.1} className={numCls} />
|
||
</div>
|
||
</div>
|
||
{/* 침수 상태 */}
|
||
<div className="mt-2.5 px-3.5 py-2.5 rounded-md border border-stroke bg-bg-card">
|
||
<div className="text-caption font-bold mb-2">💧 침수 상태</div>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
<div>
|
||
<label className="text-caption text-fg-disabled block mb-px">침수 구역 수</label>
|
||
<select
|
||
defaultValue="2개"
|
||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 outline-none"
|
||
>
|
||
{['1개', '2개', '3개', '4개 이상'].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-fg-disabled block mb-px">침수량 (톤)</label>
|
||
<input
|
||
type="number"
|
||
defaultValue={850}
|
||
step={50}
|
||
min={0}
|
||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-fg-disabled block mb-px">
|
||
침수 진행률 (t/h)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
defaultValue={120}
|
||
step={10}
|
||
min={0}
|
||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-fg-disabled block mb-px">
|
||
배수 능력 (t/h)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
defaultValue={80}
|
||
step={10}
|
||
min={0}
|
||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ④ 사고 위치 · 해상 조건 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(4)} 사고 위치 · 해상 조건</div>
|
||
<div className="grid gap-2.5 items-end" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||
<div>
|
||
<label className={labelCls}>위도 (Lat)</label>
|
||
<input type="text" defaultValue="34.5832" className={`${inputCls} font-mono`} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>경도 (Lon)</label>
|
||
<input type="text" defaultValue="128.4217" className={`${inputCls} font-mono`} />
|
||
</div>
|
||
<button
|
||
className="px-3.5 py-2 rounded-md border border-[rgba(6,182,212,.3)] text-color-accent text-label-2 font-semibold cursor-pointer whitespace-nowrap"
|
||
style={{ background: 'rgba(6,182,212,.08)' }}
|
||
>
|
||
📍 지도에서 선택
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-4 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>
|
||
풍향 / 풍속 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<div className="flex gap-1">
|
||
<select
|
||
defaultValue="SW"
|
||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
|
||
>
|
||
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
|
||
<option key={d}>{d}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="number"
|
||
defaultValue={12.5}
|
||
step={0.5}
|
||
min={0}
|
||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>
|
||
파고 (m) <span className="text-color-danger">*</span>
|
||
</label>
|
||
<input type="number" defaultValue={2.5} step={0.1} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>조류 방향 / 유속</label>
|
||
<div className="flex gap-1">
|
||
<select
|
||
defaultValue="NE"
|
||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
|
||
>
|
||
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
|
||
<option key={d}>{d}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="number"
|
||
defaultValue={1.2}
|
||
step={0.1}
|
||
min={0}
|
||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>수심 (m)</label>
|
||
<input type="number" defaultValue={25} step={1} min={0} className={numCls} />
|
||
</div>
|
||
</div>
|
||
<div className="mt-1.5 grid grid-cols-3 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>시정 (km)</label>
|
||
<input type="number" defaultValue={8} step={1} min={0} className={numCls} />
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>해상 상태 (Douglas)</label>
|
||
<select defaultValue="3 — 거침 (Moderate)" className={inputCls}>
|
||
{[
|
||
'0 — 평온 (Calm)',
|
||
'1 — 잔잔 (Smooth)',
|
||
'2 — 약간 거침 (Slight)',
|
||
'3 — 거침 (Moderate)',
|
||
'4 — 다소 높음 (Rough)',
|
||
'5 — 높음 (Very rough)',
|
||
'6 — 매우 높음 (High)',
|
||
].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>수온 (°C)</label>
|
||
<input type="number" defaultValue={17.2} step={0.5} className={numCls} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ⑤ 구난 분석 설정 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(5)} 구난 분석 설정</div>
|
||
<div className="grid grid-cols-3 gap-2.5">
|
||
<div>
|
||
<label className={labelCls}>
|
||
분석 모델 <span className="text-color-danger">*</span>
|
||
</label>
|
||
<select defaultValue="R&D 긴급구난 종합분석" className={inputCls}>
|
||
{[
|
||
'R&D 긴급구난 종합분석',
|
||
'HECSALV (Salvage)',
|
||
'NAPA Emergency',
|
||
'수동 입력 (직접 판단)',
|
||
].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>예측 시간</label>
|
||
<select defaultValue="12시간" className={inputCls}>
|
||
{['6시간', '12시간', '24시간', '48시간', '72시간'].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>분석 항목</label>
|
||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||
{['복원성', '예인력', '인양력', '유출 위험'].map((item) => (
|
||
<label
|
||
key={item}
|
||
className="flex items-center gap-px text-caption text-fg-sub cursor-pointer"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
defaultChecked
|
||
style={{ accentColor: 'var(--color-accent)', transform: 'scale(.85)' }}
|
||
/>
|
||
{item}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* R&D 연계 분석 */}
|
||
<div className="mt-2.5 px-3.5 py-2.5 flex flex-col gap-1.5 rounded-md border border-stroke bg-bg-card">
|
||
<div className="text-caption font-bold">🔗 R&D 연계 분석</div>
|
||
<div className="flex gap-3">
|
||
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
|
||
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} /> 유출유
|
||
확산예측 동시 실행
|
||
</label>
|
||
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
|
||
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} /> HNS
|
||
대기확산 연계 분석
|
||
</label>
|
||
</div>
|
||
<div className="text-caption text-fg-disabled leading-[1.5]">
|
||
화물 유출 가능성이 있는 경우, 긴급구난 분석 결과와 확산예측을 동시에 수행하여 종합
|
||
대응 판단을 지원합니다
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ⑥ 비고 */}
|
||
<div>
|
||
<div className={sectionTitleCls}>{sectionIcon(6)} 비고</div>
|
||
<textarea
|
||
placeholder="시나리오 설명, 가정 조건, 현장 상황 특이사항 등을 기록합니다..."
|
||
className="w-full h-[60px] px-3 py-2.5 rounded-md border border-stroke bg-bg-base text-label-2 outline-none resize-y leading-relaxed scrollbar-thin"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 하단 버튼 ── */}
|
||
<div className="px-6 py-4 border-t border-stroke shrink-0 flex gap-2 items-center">
|
||
<div className="flex-1 text-caption text-fg-disabled leading-[1.5]">
|
||
<span className="text-color-danger">*</span> 필수 입력 항목 · 해상 조건은 기상청/조사원
|
||
API 연계 시 자동 갱신
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-5 py-2.5 rounded-md border border-stroke bg-bg-card text-fg-sub text-caption font-semibold cursor-pointer"
|
||
>
|
||
취소
|
||
</button>
|
||
{done ? (
|
||
<button
|
||
onClick={onClose}
|
||
className="px-7 py-2.5 rounded-md border-none text-static-white text-caption font-bold cursor-pointer bg-color-success"
|
||
>
|
||
✅ 생성 완료 — 닫기
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={submitting}
|
||
className={`px-7 py-2.5 rounded-md border-none text-caption font-bold ${
|
||
submitting
|
||
? 'bg-bg-card text-fg-disabled cursor-wait'
|
||
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'
|
||
}`}
|
||
>
|
||
{submitting ? '⏳ 분석 중...' : '🚨 시나리오 생성 · 분석 실행'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══ 비교 차트 컴포넌트 ═══ */
|
||
function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||
const W = 480,
|
||
H = 180,
|
||
PX = 50,
|
||
PY = 20;
|
||
const pw = W - PX * 2,
|
||
ph = H - PY * 2;
|
||
const xStep = chartData.length > 1 ? pw / (chartData.length - 1) : pw;
|
||
|
||
if (chartData.length === 0) {
|
||
return (
|
||
<div className="p-10 text-center text-label-2 text-fg-disabled">
|
||
비교할 시나리오 데이터가 없습니다.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-5">
|
||
{/* Chart 1: GM 추이 */}
|
||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-4">
|
||
<div className="text-label-2 font-bold mb-2.5">📈 GM (복원심) 변화 추이 (m)</div>
|
||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 180 }}>
|
||
{/* Grid */}
|
||
{[0, 0.5, 1.0, 1.5, 2.0].map((v) => {
|
||
const y = PY + ph - (v / 2.0) * ph;
|
||
return (
|
||
<g key={v}>
|
||
<line x1={PX} x2={W - PX} y1={y} y2={y} stroke="var(--stroke-light)" />
|
||
<text
|
||
x={PX - 6}
|
||
y={y + 3}
|
||
textAnchor="end"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={8}
|
||
fontFamily="var(--font-mono)"
|
||
>
|
||
{v}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{/* GM=1.0 threshold */}
|
||
<line
|
||
x1={PX}
|
||
x2={W - PX}
|
||
y1={PY + ph - (1.0 / 2.0) * ph}
|
||
y2={PY + ph - (1.0 / 2.0) * ph}
|
||
stroke="rgba(239,68,68,.4)"
|
||
strokeDasharray="4"
|
||
/>
|
||
<text
|
||
x={W - PX + 4}
|
||
y={PY + ph - (1.0 / 2.0) * ph + 3}
|
||
fill="var(--color-danger)"
|
||
fontSize={7}
|
||
>
|
||
GM=1.0 위험
|
||
</text>
|
||
{/* Area */}
|
||
<polygon
|
||
points={`${PX},${PY + ph} ${chartData.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} ${PX + (chartData.length - 1) * xStep},${PY + ph}`}
|
||
fill="rgba(6,182,212,.08)"
|
||
/>
|
||
{/* Line + dots */}
|
||
<polyline
|
||
points={chartData
|
||
.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`)
|
||
.join(' ')}
|
||
fill="none"
|
||
stroke="var(--color-accent)"
|
||
strokeWidth={2}
|
||
/>
|
||
{chartData.map((d, i) => (
|
||
<g key={d.id}>
|
||
<circle
|
||
cx={PX + i * xStep}
|
||
cy={PY + ph - (d.gm / 2.0) * ph}
|
||
r={4}
|
||
fill={SEV_COLOR[d.severity]}
|
||
stroke="#0d1117"
|
||
strokeWidth={1.5}
|
||
/>
|
||
<text
|
||
x={PX + i * xStep}
|
||
y={PY + ph + 14}
|
||
textAnchor="middle"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={8}
|
||
fontFamily="var(--font-korean)"
|
||
>
|
||
{d.label}
|
||
</text>
|
||
<text
|
||
x={PX + i * xStep}
|
||
y={PY + ph - (d.gm / 2.0) * ph - 8}
|
||
textAnchor="middle"
|
||
fill="var(--fg-default)"
|
||
fontSize={8}
|
||
fontFamily="var(--font-mono)"
|
||
fontWeight={700}
|
||
>
|
||
{d.gm}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
|
||
{/* Charts 2 & 3: 2-column */}
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
{/* Chart 2: 횡경사 변화 */}
|
||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||
<div className="text-label-2 font-bold mb-2.5">📉 횡경사 (List) 변화 (°)</div>
|
||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
|
||
{[0, 5, 10, 15, 20, 25].map((v) => {
|
||
const y = PY + ph - (v / 25) * ph;
|
||
return (
|
||
<g key={v}>
|
||
<line x1={PX} x2={W - PX} y1={y} y2={y} stroke="var(--stroke-light)" />
|
||
<text
|
||
x={PX - 6}
|
||
y={y + 3}
|
||
textAnchor="end"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={7}
|
||
fontFamily="var(--font-mono)"
|
||
>
|
||
{v}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
<line
|
||
x1={PX}
|
||
x2={W - PX}
|
||
y1={PY + ph - (15 / 25) * ph}
|
||
y2={PY + ph - (15 / 25) * ph}
|
||
stroke="rgba(239,68,68,.3)"
|
||
strokeDasharray="4"
|
||
/>
|
||
<polyline
|
||
points={chartData
|
||
.map((d, i) => `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`)
|
||
.join(' ')}
|
||
fill="none"
|
||
stroke="var(--color-warning)"
|
||
strokeWidth={2}
|
||
/>
|
||
{chartData.map((d, i) => (
|
||
<g key={d.id}>
|
||
<circle
|
||
cx={PX + i * xStep}
|
||
cy={PY + ph - (d.list / 25) * ph}
|
||
r={3.5}
|
||
fill={SEV_COLOR[d.severity]}
|
||
stroke="#0d1117"
|
||
strokeWidth={1.5}
|
||
/>
|
||
<text
|
||
x={PX + i * xStep}
|
||
y={PY + ph + 14}
|
||
textAnchor="middle"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={7}
|
||
fontFamily="var(--font-korean)"
|
||
>
|
||
{d.label}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
|
||
{/* Chart 3: 유출률 변화 (bar) */}
|
||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||
<div className="text-label-2 font-bold mb-2.5">📊 유출률 변화 (L/min)</div>
|
||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
|
||
{[0, 50, 100, 150, 200].map((v) => {
|
||
const y = PY + ph - (v / 200) * ph;
|
||
return (
|
||
<g key={v}>
|
||
<line x1={PX} x2={W - PX} y1={y} y2={y} stroke="var(--stroke-light)" />
|
||
<text
|
||
x={PX - 6}
|
||
y={y + 3}
|
||
textAnchor="end"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={7}
|
||
fontFamily="var(--font-mono)"
|
||
>
|
||
{v}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{chartData.map((d, i) => {
|
||
const barW = xStep * 0.5;
|
||
const barH = (d.oil / 200) * ph;
|
||
return (
|
||
<g key={d.id}>
|
||
<rect
|
||
x={PX + i * xStep - barW / 2}
|
||
y={PY + ph - barH}
|
||
width={barW}
|
||
height={barH}
|
||
rx={3}
|
||
fill={SEV_COLOR[d.severity]}
|
||
opacity={0.7}
|
||
/>
|
||
<text
|
||
x={PX + i * xStep}
|
||
y={PY + ph - barH - 4}
|
||
textAnchor="middle"
|
||
fill="var(--fg-default)"
|
||
fontSize={8}
|
||
fontFamily="var(--font-mono)"
|
||
fontWeight={700}
|
||
>
|
||
{d.oil}
|
||
</text>
|
||
<text
|
||
x={PX + i * xStep}
|
||
y={PY + ph + 14}
|
||
textAnchor="middle"
|
||
fill="var(--fg-disabled)"
|
||
fontSize={7}
|
||
fontFamily="var(--font-korean)"
|
||
>
|
||
{d.label}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart 4: 비교 테이블 */}
|
||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||
<div className="text-label-2 font-bold mb-2.5">📋 시나리오 종합 비교표</div>
|
||
<table className="w-full border-collapse text-caption">
|
||
<thead>
|
||
<tr className="bg-bg-base">
|
||
<th className="py-[7px] px-2 text-left border-b-2 border-[var(--stroke-light)] text-fg-sub">
|
||
지표
|
||
</th>
|
||
{chartData.map((d) => (
|
||
<th
|
||
key={d.id}
|
||
className="py-[7px] px-2 text-center border-b-2 border-[var(--stroke-light)]"
|
||
style={{ color: SEV_COLOR[d.severity] }}
|
||
>
|
||
{d.id}
|
||
<br />
|
||
<span className="font-normal text-caption text-fg-disabled">{d.label}</span>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
{
|
||
label: 'GM (m)',
|
||
key: 'gm',
|
||
fmt: (d: ChartDataItem) => d.gm.toFixed(1),
|
||
clr: (d: ChartDataItem) => gmColor(d.gm),
|
||
},
|
||
{
|
||
label: '횡경사 (°)',
|
||
key: 'list',
|
||
fmt: (d: ChartDataItem) => `${d.list}°`,
|
||
clr: (d: ChartDataItem) => listColor(d.list),
|
||
},
|
||
{
|
||
label: '잔존부력 (%)',
|
||
key: 'buoy',
|
||
fmt: (d: ChartDataItem) => `${d.buoy}%`,
|
||
clr: (d: ChartDataItem) => buoyColor(d.buoy),
|
||
},
|
||
{
|
||
label: '유출률 (L/min)',
|
||
key: 'oil',
|
||
fmt: (d: ChartDataItem) => `${d.oil}`,
|
||
clr: (d: ChartDataItem) => oilColor(d.oil),
|
||
},
|
||
{
|
||
label: 'BM 비율 (%)',
|
||
key: 'bm',
|
||
fmt: (d: ChartDataItem) => `${d.bm}%`,
|
||
clr: (d: ChartDataItem) =>
|
||
d.bm > 100
|
||
? 'var(--color-danger)'
|
||
: d.bm > 85
|
||
? 'var(--color-warning)'
|
||
: 'var(--color-success)',
|
||
},
|
||
{
|
||
label: '위험 등급',
|
||
key: 'sev',
|
||
fmt: (d: ChartDataItem) => d.severity,
|
||
clr: (d: ChartDataItem) => SEV_COLOR[d.severity],
|
||
},
|
||
].map((row) => (
|
||
<tr key={row.label} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}>
|
||
<td className="py-1.5 px-2 font-semibold text-fg-sub">{row.label}</td>
|
||
{chartData.map((d) => (
|
||
<td
|
||
key={d.id}
|
||
className="py-1.5 px-2 text-center font-mono font-bold"
|
||
style={{ color: row.clr(d) }}
|
||
>
|
||
{row.fmt(d)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|