wing-ops/frontend/src/tabs/rescue/components/RescueScenarioView.tsx

2008 lines
85 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

import { 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>
);
}