Compare commits

..

No commits in common. "8093727efe54a9c47871771eff6f276cc71230a4" and "4f5260ae12e1dce3540819a1e2daada0ae7ed2fe" have entirely different histories.

12개의 변경된 파일255개의 추가작업 그리고 6324개의 파일을 삭제

파일 보기

@ -128,125 +128,55 @@ INSERT INTO RESCUE_OPS (
);
-- ============================================================
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
-- - 유출 모델링: 파공부 유출률 변화
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
-- ============================================================
INSERT INTO RESCUE_SCENARIO (
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
) VALUES
-- S-01: 사고 발생 (Initial Impact)
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
(
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
'[{"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)"}]',
'[{"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)"}]',
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","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)"}]',
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
1
),
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
(
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
'[{"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)"}]',
'[{"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)"}]',
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
'[{"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)"}]',
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
2
),
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
(
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
'[{"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)"}]',
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","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)"}]',
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
3
),
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
(
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
'[{"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)"}]',
'[{"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)"}]',
'[{"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)"}]',
4
),
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
(
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
'[{"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)"}]',
'[{"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)"}]',
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
5
),
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
(
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
'[{"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)"}]',
'[{"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":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
'[{"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)"}]',
6
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
'[{"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":"RISK","color":"var(--orange)"}]',
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
3
),
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
(
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
'[{"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)"}]',
'[{"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)"}]',
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
7
),
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
(
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
'[{"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)"}]',
'[{"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)"}]',
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
8
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
4
),
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
(
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
'[{"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)"}]',
'[{"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)"}]',
'[{"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)"}]',
9
),
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
(
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
'[{"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)"}]',
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
'[{"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)"}]',
10
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
'[{"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":"INTACT","color":"var(--green)"}]',
'[{"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)"}]',
'[{"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)"}]',
5
);

파일 보기

@ -4,10 +4,6 @@
## [Unreleased]
### 추가
- 관리자: 비식별화조치 메뉴 및 패널 추가
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
## [2026-04-13]
### 추가

파일 보기

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import AdminSidebar from './AdminSidebar';
import AdminPlaceholder from './AdminPlaceholder';
import { findMenuLabel } from './adminMenuConfig';
@ -19,15 +19,9 @@ import MonitorVesselPanel from './MonitorVesselPanel';
import CollectHrPanel from './CollectHrPanel';
import MonitorForecastPanel from './MonitorForecastPanel';
import VesselMaterialsPanel from './VesselMaterialsPanel';
import DeidentifyPanel from './DeidentifyPanel';
import RndPoseidonPanel from './RndPoseidonPanel';
import RndKospsPanel from './RndKospsPanel';
import RndHnsAtmosPanel from './RndHnsAtmosPanel';
import RndRescuePanel from './RndRescuePanel';
import SystemArchPanel from './SystemArchPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => React.JSX.Element> = {
const PANEL_MAP: Record<string, () => JSX.Element> = {
users: () => <UsersPanel />,
permissions: () => <PermissionsPanel />,
menus: () => <MenusPanel />,
@ -48,12 +42,6 @@ const PANEL_MAP: Record<string, () => React.JSX.Element> = {
'monitor-vessel': () => <MonitorVesselPanel />,
'collect-hr': () => <CollectHrPanel />,
'monitor-forecast': () => <MonitorForecastPanel />,
deidentify: () => <DeidentifyPanel />,
'rnd-poseidon': () => <RndPoseidonPanel />,
'rnd-kosps': () => <RndKospsPanel />,
'rnd-hns-atmos': () => <RndHnsAtmosPanel />,
'rnd-rescue': () => <RndRescuePanel />,
'system-arch': () => <SystemArchPanel />,
};
export function AdminView() {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,638 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | '충북대 API';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'chungbuk-api',
name: '충북대 API 서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: 'API 호출',
},
{
id: 'atmos-compute',
name: 'HNS 대기확산 연산',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 시작 즉시',
},
{
id: 'result-receive',
name: '결과 수신',
status: '지연',
lastReceived: '2026-04-11 06:00',
cycle: '연산 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: 'SST',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: '해류',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '대기안정도',
size: '14 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:07',
source: '충북대 API',
dataType: 'API 호출 요청',
size: '0.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:15',
source: '충북대 API',
dataType: 'HNS 확산 결과',
size: '-',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: 'SSH',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상청',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:07',
source: '충북대 API',
dataType: 'API 호출 요청',
size: '0.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:20',
source: '충북대 API',
dataType: 'HNS 확산 결과',
size: '12 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 03:20',
source: '충북대 API',
dataType: '피해범위 데이터',
size: '4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:05',
source: '기상청',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: 'SST',
size: '97 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '주의',
message: '충북대 API 결과 수신 지연 — 최근 응답 15분 지연 (2026-04-11 06:15)',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '정보',
message: 'HYCOM 데이터 정상 수신 완료 (2026-04-11 06:00)',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 HNS 대기확산 예측 완료: 2회/4회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface HnsAtmosData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchHnsAtmosData(): Promise<HnsAtmosData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndHnsAtmosPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchHnsAtmosData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1">HNS () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">2 / 4</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="충북대 API"> API</option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -1,638 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'kosps-server',
name: '광주 KOSPS 서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: '수신 즉시',
},
{
id: 'fortran-dll',
name: 'KOSPS Fortran DLL 연산',
status: '지연',
lastReceived: '2026-04-11 05:45',
cycle: '예측 시작 즉시',
},
{
id: 'result-api',
name: '결과 수신 API',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '28 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 05:55',
source: 'KOSPS DLL',
dataType: 'DLL 호출 요청',
size: '3 MB',
receiveStatus: '수신완료',
processStatus: '처리중',
},
{
id: 'log-08',
timestamp: '2026-04-11 05:45',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '-',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 03:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '140 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상청',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:00',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '27 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:05',
source: '기상청',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: 'KOSPS DLL',
dataType: 'DLL 호출 요청',
size: '-',
receiveStatus: '수신실패',
processStatus: '오류',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '경고',
message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과',
timestamp: '2026-04-11 05:45',
},
{
id: 'alert-02',
level: '주의',
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 KOSPS 예측 완료: 3회 / 6회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface KospsData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchKospsData(): Promise<KospsData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndKospsPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchKospsData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> (KOSPS) </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">3 / 6</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="KOSPS DLL">KOSPS DLL</option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -1,665 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상수치모델' | '기상기술';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상수치모델(KMA)',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'relay',
name: '기상기술 중계서버',
status: '지연',
lastReceived: '2026-04-11 05:30',
cycle: '3시간 주기',
},
{
id: 'api',
name: '해경 9층 API서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: '수신 즉시',
},
{
id: 'gpu',
name: 'GPU 연산서버',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 시작 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상수치모델',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: '기상수치모델',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:00',
source: '기상수치모델',
dataType: '강수량',
size: '11 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '310 MB',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 03:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '140 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '97 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '305 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:05',
source: '기상수치모델',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '-',
receiveStatus: '수신실패',
processStatus: '오류',
},
{
id: 'log-16',
timestamp: '2026-04-10 21:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '36 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-17',
timestamp: '2026-04-10 21:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '139 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-18',
timestamp: '2026-04-10 21:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '302 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '경고',
message: '기상기술 중계서버 데이터 30분 지연 — 06:00 배치 전처리 미완료',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '주의',
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: 'GPU 연산서버 금일 처리 완료: 4회 / 8회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface PoseidonData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchPoseidonData(): Promise<PoseidonData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndPoseidonPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchPoseidonData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">4 / 8</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상수치모델"></option>
<option value="기상기술"></option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -1,638 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | '긴급구난시스템';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'rescue',
name: '해경 긴급구난 시스템',
status: '정상',
lastReceived: '2026-04-11 06:30',
cycle: '내부 연계',
},
{
id: 'analysis',
name: '구난 분석 연산',
status: '정상',
lastReceived: '2026-04-11 06:35',
cycle: '연계 시작 즉시',
},
{
id: 'result',
name: '결과 연계 수신',
status: '정상',
lastReceived: '2026-04-11 06:40',
cycle: '분석 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:40',
source: '긴급구난시스템',
dataType: '구난 가능성 판단',
size: '1.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:35',
source: '긴급구난시스템',
dataType: '선체상태 분석',
size: '3.4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:30',
source: '긴급구난시스템',
dataType: '사고선 위치정보',
size: '0.8 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:30',
source: '긴급구난시스템',
dataType: '비상배인력 정보',
size: '0.5 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-09',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:30',
source: '긴급구난시스템',
dataType: '선체상태 분석',
size: '3.1 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:30',
source: '긴급구난시스템',
dataType: '구난 가능성 판단',
size: '1.1 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:30',
source: '긴급구난시스템',
dataType: '사고선 위치정보',
size: '0.8 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-10 21:30',
source: '긴급구난시스템',
dataType: '비상배인력 정보',
size: '0.4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '정보',
message: '해경 긴급구난 시스템 정상 연계 중',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '정보',
message: 'HYCOM 데이터 정상 수신',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 긴급구난 분석 완료: 5회/6회',
timestamp: '2026-04-11 06:40',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface RescueData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchRescueData(): Promise<RescueData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:40:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndRescuePanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchRescueData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">5 / 6</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="긴급구난시스템"></option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -15,7 +15,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
{ id: 'system-arch', label: '시스템구조' },
],
},
{
@ -92,17 +91,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
{ id: 'monitor-vessel', label: '선박위치정보' },
],
},
{
id: 'rnd',
label: 'R&D과제',
children: [
{ id: 'rnd-poseidon', label: '유출유확산예측(포세이돈)' },
{ id: 'rnd-kosps', label: '유출유확산예측(KOSPS)' },
{ id: 'rnd-hns-atmos', label: 'HNS대기확산(충북대)' },
{ id: 'rnd-rescue', label: '긴급구난과제' },
],
},
{ id: 'deidentify', label: '비식별화조치' },
],
},
];

파일 보기

@ -1,7 +1,4 @@
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 { useState, useEffect, useCallback, useRef } from 'react';
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
@ -106,295 +103,6 @@ interface ChartDataItem {
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
*/
@ -408,15 +116,14 @@ export function RescueScenarioView() {
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);
setApiScenarios(items);
} catch (err) {
console.error('[rescue] 시나리오 조회 실패:', err);
} finally {
setLoading(false);
}
@ -425,17 +132,14 @@ export function RescueScenarioView() {
const loadOps = useCallback(async () => {
try {
const items = await fetchRescueOps();
setOps(items);
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);
} catch (err) {
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
setLoading(false);
}
}, [loadScenarios]);
@ -525,35 +229,9 @@ export function RescueScenarioView() {
>
+
</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: 시나리오 목록 ═══ */}
@ -698,7 +376,7 @@ export function RescueScenarioView() {
</div>
{/* View content */}
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* ─── VIEW 0: 시나리오 상세 ─── */}
{detailView === 0 && selected && (
<div className="p-5">
@ -858,14 +536,37 @@ export function RescueScenarioView() {
{/* ─── VIEW 2: 지도 오버레이 ─── */}
{detailView === 2 && (
<ScenarioMapOverlay
ops={ops}
selectedIncident={selectedIncident}
scenarios={scenarios}
selectedId={selectedId}
checked={checked}
onSelectScenario={setSelectedId}
/>
<div className="p-5">
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
<div className="text-title-4 font-bold mb-1.5">GIS </div>
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
.
</div>
<div className="flex gap-2 justify-center flex-wrap">
{scenarios.map((sc) => (
<div
key={sc.id}
className="px-3 py-1.5 rounded-md text-caption"
style={{
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
background: SEV_STYLE[sc.severity].bg,
}}
>
<span className="font-bold" style={{ color: SEV_STYLE[sc.severity].color }}>
{sc.id}
</span>
<span className="text-fg-sub ml-1.5">{sc.name}</span>
</div>
))}
</div>
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
<div className="text-label-2 text-fg-disabled">
</div>
</div>
</div>
</div>
)}
</div>
</div>
@ -877,310 +578,6 @@ export function RescueScenarioView() {
);
}
/* ═══ 지도 오버레이 ═══ */
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);

파일 보기

@ -1,12 +1,9 @@
import { Fragment, useState, useEffect, useCallback } from 'react';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { MapView } from '@common/components/map/MapView';
import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView';
import { fetchRescueOps } from '../services/rescueApi';
import type { RescueOpsItem } from '../services/rescueApi';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
/* ─── Types ─── */
type AccidentType =
@ -224,145 +221,12 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
function LeftPanel({
activeType,
onTypeChange,
incidents,
selectedAcdnt,
onSelectAcdnt,
}: {
activeType: AccidentType;
onTypeChange: (t: AccidentType) => void;
incidents: IncidentListItem[];
selectedAcdnt: IncidentListItem | null;
onSelectAcdnt: (item: IncidentListItem | null) => void;
}) {
const [acdntName, setAcdntName] = useState('');
const [acdntDate, setAcdntDate] = useState('');
const [acdntTime, setAcdntTime] = useState('');
const [acdntLat, setAcdntLat] = useState('');
const [acdntLon, setAcdntLon] = useState('');
const [showList, setShowList] = useState(false);
// 사고 선택 시 필드 자동 채움
const handlePickIncident = (item: IncidentListItem) => {
onSelectAcdnt(item);
setAcdntName(item.acdntNm);
const dt = new Date(item.occrnDtm);
setAcdntDate(
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
);
setAcdntTime(
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
);
setAcdntLat(String(item.lat));
setAcdntLon(String(item.lng));
setShowList(false);
};
return (
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
{/* ── 사고 기본정보 ── */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
</div>
{/* 사고명 직접 입력 */}
<input
type="text"
placeholder="사고명 직접 입력"
value={acdntName}
onChange={(e) => {
setAcdntName(e.target.value);
if (selectedAcdnt) onSelectAcdnt(null);
}}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
{/* 또는 사고 리스트에서 선택 */}
<div className="relative">
<button
onClick={() => setShowList(!showList)}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
>
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
</span>
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
</button>
{showList && (
<div className="absolute left-0 right-0 top-full mt-0.5 z-30 bg-bg-card border border-stroke rounded shadow-lg max-h-[200px] overflow-y-auto scrollbar-thin">
{incidents.length === 0 && (
<div className="px-2 py-3 text-caption text-fg-disabled text-center font-korean">
</div>
)}
{incidents.map((item) => (
<button
key={item.acdntSn}
onClick={() => handlePickIncident(item)}
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
>
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
<div className="text-fg-disabled text-[10px]">
{item.acdntCd} · {item.regionNm}
</div>
</button>
))}
</div>
)}
</div>
{/* 사고 발생 일시 */}
<div className="text-[10px] text-fg-disabled font-korean mt-1"> </div>
<div className="flex gap-1">
<input
type="text"
placeholder="2026. 04. 11."
value={acdntDate}
onChange={(e) => setAcdntDate(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="오후 03:42"
value={acdntTime}
onChange={(e) => setAcdntTime(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
</div>
{/* 위도 / 경도 */}
<div className="flex gap-1 mt-0.5">
<input
type="text"
placeholder="위도"
value={acdntLat}
onChange={(e) => setAcdntLat(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="경도"
value={acdntLon}
onChange={(e) => setAcdntLon(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<button
className="shrink-0 px-1.5 py-1 text-[10px] font-bold rounded cursor-pointer"
style={{
background: 'rgba(239,68,68,0.15)',
color: 'var(--color-danger)',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
</button>
</div>
<div className="text-[10px] text-fg-disabled font-korean text-center mb-1">
</div>
{/* 구분선 */}
<div className="border-t border-stroke my-1" />
{/* 사고유형 제목 */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
(INCIDENT TYPE)
@ -426,6 +290,191 @@ function LeftPanel({
}
/* ─── 중앙 지도 영역 ─── */
function CenterMap({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
return (
<div className="flex-1 relative overflow-hidden bg-bg-base">
{/* 해양 배경 그라데이션 */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(ellipse at 30% 40%, rgba(6,90,130,.25) 0%, transparent 60%), radial-gradient(ellipse at 70% 60%, rgba(8,60,100,.2) 0%, transparent 50%), linear-gradient(180deg, #0a1628, #0d1f35 50%, #091520)',
}}
/>
{/* 격자 */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(rgba(30,60,100,.12) 1px, transparent 1px), linear-gradient(90deg, rgba(30,60,100,.12) 1px, transparent 1px)',
backgroundSize: '80px 80px',
}}
/>
{/* 해안선 힌트 */}
<div
className="absolute right-0 top-0 w-[55%] h-full"
style={{
background:
'linear-gradient(225deg, rgba(30,50,70,.55), rgba(20,35,50,.25) 35%, transparent 55%)',
clipPath:
'polygon(60% 0%, 65% 5%, 70% 12%, 72% 20%, 68% 30%, 65% 40%, 60% 50%, 55% 58%, 50% 65%, 45% 72%, 42% 80%, 48% 88%, 100% 100%, 100% 0%)',
}}
/>
{/* 사고 해역 정보 */}
<div className="absolute top-2.5 left-2.5 z-20 bg-[var(--dropdown-bg)] border border-stroke rounded-md px-3 py-2 backdrop-blur-sm font-mono text-caption text-fg-disabled">
<div className="text-label-2 font-bold text-fg font-korean mb-1"> </div>
<div className="grid gap-x-1.5 gap-y-px" style={{ gridTemplateColumns: '32px 1fr' }}>
<span></span>
<b className="text-fg">37°28'N, 126°15'E</b>
<span></span>
<b className="text-fg">45m</b>
<span></span>
<b className="text-fg">2.5 knots NE</b>
</div>
</div>
{/* 선박 모형 */}
<div className="absolute z-[15] top-[42%] left-[46%] -rotate-[15deg]">
<div
className="relative w-[72px] h-5"
style={{
background: 'linear-gradient(90deg, #4a3728, #6b4c33)',
borderRadius: '3px 10px 10px 3px',
border: '1px solid rgba(255,150,50,.4)',
boxShadow: '0 0 18px rgba(255,100,0,.2)',
}}
>
<div
className="absolute w-0.5 h-[7px] bg-fg-disabled rounded-[1px]"
style={{ top: '-3px', left: '60%' }}
/>
</div>
<div className="text-caption text-center mt-0.5 font-mono text-[rgba(255,200,150,0.5)]">
M/V SEA GUARDIAN
</div>
</div>
{/* 예측 구역 원 */}
<div
className="absolute z-[5] rounded-full"
style={{
top: '32%',
left: '32%',
width: '220px',
height: '220px',
background:
'radial-gradient(circle, rgba(6,182,212,.07), rgba(6,182,212,.02) 50%, transparent 70%)',
border: '1.5px dashed rgba(6,182,212,.2)',
}}
/>
{/* 구역 라벨 */}
<div className="absolute z-[6] text-caption font-korean font-semibold tracking-wider uppercase whitespace-pre-line text-[rgba(6,182,212,0.45)] top-1/2 left-[36%]">
{d.zone.replace('\\n', '\n')}
</div>
{/* SAR 자산 */}
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[10%] left-[42%]">
ETA 5 MIN
</div>
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[14%] left-[56%]">
ETA 15 MIN
</div>
<div className="absolute z-[12] text-title-3 opacity-60 top-[7%] left-[52%] -rotate-[30deg]">
🚁
</div>
<div className="absolute z-[12] text-caption font-mono text-[rgba(200,220,255,0.45)] top-[20%] left-[60%]">
6M
</div>
<div className="absolute z-[12] text-label-2 opacity-45 top-[28%] left-[54%]">🚢</div>
{/* 환경 민감 구역 */}
<div className="absolute z-10 px-3.5 py-2 rounded bg-[rgba(34,197,94,0.06)] border border-[rgba(34,197,94,0.2)] bottom-[6%] right-[6%]">
<div className="text-caption font-bold font-serif uppercase tracking-wider leading-snug text-[rgba(34,197,94,0.55)]">
ENVIRONMENTALLY SENSITIVE
<br />
AREA: AQUACULTURE FARM
</div>
</div>
{/* 지도 컨트롤 */}
<div className="absolute top-2.5 right-2.5 z-20 flex flex-col gap-0.5">
{['🗺', '🔍', '📐'].map((ico, i) => (
<button
key={i}
className="w-7 h-7 bg-[rgba(13,17,23,0.85)] border border-stroke rounded text-fg-disabled text-label-1 flex items-center justify-center cursor-pointer hover:text-fg"
>
{ico}
</button>
))}
</div>
{/* 스케일 바 */}
<div className="absolute bottom-2.5 left-2.5 z-20 bg-[rgba(13,17,23,0.8)] border border-stroke rounded px-2.5 py-1 text-caption text-fg-disabled font-mono">
<div
className="w-[70px] h-0.5 mb-0.5"
style={{ background: 'linear-gradient(90deg, #e4e8f1 50%, var(--stroke-default) 50%)' }}
/>
5 km · Zoom: 100%
</div>
{/* 사고 유형 표시 */}
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
<div className="text-caption text-fg-disabled font-korean"> </div>
<div className="text-label-2 font-bold font-korean text-color-accent">
{at.icon} {at.label} ({at.eng})
</div>
</div> */}
{/* 타임라인 시뮬레이션 컨트롤 */}
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap">TIMELINE</div>
<div className="flex items-center gap-1.5 text-caption text-fg-disabled font-mono">
<span>[-6h]</span>
<span className="font-bold text-fg">[NOW]</span>
<span>[+6H]</span>
<span>[+12H]</span>
<span>[+24H]</span>
</div>
<div className="relative w-24 h-1.5 bg-bg-surface-hover rounded-sm">
<div
className="absolute rounded-full border-2 border-bg-0 bg-color-accent"
style={{
left: '35%',
top: '-3px',
width: '12px',
height: '12px',
boxShadow: '0 0 8px rgba(6,182,212,.4)',
}}
/>
</div>
<div className="flex items-center gap-1.5">
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
</button>
<button
className="w-8 h-8 rounded-full text-color-accent text-title-4 flex items-center justify-center cursor-pointer hover:brightness-125"
style={{
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
}}
>
</button>
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
</button>
</div>
<div className="text-caption text-fg-disabled font-mono whitespace-nowrap">
<b className="text-color-accent">10:45</b> KST
</div>
</div>
</div>
);
}
/* ─── 오른쪽 분석 패널 ─── */
function RightPanel({
activeAnalysis,
@ -1523,44 +1572,6 @@ export function RescueView() {
const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState<AccidentType>('collision');
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
useEffect(() => {
fetchIncidentsRaw()
.then((items) => setIncidents(items))
.catch(() => setIncidents([]));
}, []);
// 지도 클릭 시 좌표 선택
const handleMapClick = useCallback((lon: number, lat: number) => {
setIncidentCoord({ lon, lat });
setIsSelectingLocation(false);
}, []);
// 사고 선택 시 사고유형 자동 매핑
const handleSelectAcdnt = useCallback(
(item: IncidentListItem | null) => {
setSelectedAcdnt(item);
if (item) {
const typeMap: Record<string, AccidentType> = {
collision: 'collision',
grounding: 'grounding',
turning: 'turning',
capsizing: 'capsizing',
sharpTurn: 'sharpTurn',
flooding: 'flooding',
sinking: 'sinking',
};
const mapped = typeMap[item.acdntTpCd];
if (mapped) setActiveType(mapped);
setIncidentCoord({ lon: item.lng, lat: item.lat });
}
},
[],
);
if (activeSubTab === 'list') {
return (
@ -1585,23 +1596,8 @@ export function RescueView() {
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
<div className="flex flex-1 overflow-hidden">
<LeftPanel
activeType={activeType}
onTypeChange={setActiveType}
incidents={incidents}
selectedAcdnt={selectedAcdnt}
onSelectAcdnt={handleSelectAcdnt}
/>
<div className="flex-1 relative overflow-hidden">
<MapView
incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={new Set()}
showOverlays={false}
/>
</div>
<LeftPanel activeType={activeType} onTypeChange={setActiveType} />
<CenterMap activeType={activeType} />
<RightPanel
activeAnalysis={activeAnalysis}
onAnalysisChange={setActiveAnalysis}