Merge pull request 'release: 2026-04-14 (267건 커밋)' (#171) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s

This commit is contained in:
dnlee 2026-04-14 13:09:56 +09:00
커밋 20d5c08bc7
101개의 변경된 파일8051개의 추가작업 그리고 1596개의 파일을 삭제

파일 보기

@ -127,6 +127,28 @@ wing/
## 진행 중 작업 (완료 후 삭제)
### 폰트 크기 업스케일 작업 (진행 중)
반드시 `memory/font-upscale-plan.md`를 읽고 Phase 진행 상황을 확인할 것.
**토큰 변경 매핑 (이름 유지, 값만 변경):**
- `caption`/`label-2`/`title-6`: 11px → **12px** (0.75rem)
- `label-1`/`title-5`: 12px → **13px** (0.8125rem)
- `body-2`/`title-4`: 13px → **14px** (0.875rem)
- `body-1`/`title-3`: 14px → **16px** (1rem)
**네비게이션 클래스 교체:**
- TopBar 메인탭: `text-title-4``text-title-2` (16px)
- SubMenuBar 서브탭: `text-title-5``text-title-4` (14px)
**작업 범위:**
- Phase 1: tailwind.config.js + base.css 토큰 값 수정
- Phase 2: components.css 하드코딩(27곳) + wing.css `.wing-tab` text-xs→text-caption
- Phase 3: TopBar.tsx + SubMenuBar.tsx 클래스 교체
- Phase 4: text-xs→text-caption, text-sm→text-body-2 스크립트 교체 (design 페이지 제외, 608건)
- Phase 5: prediction 탭 인라인 fontSize 수정
- Phase 6 (보류): wing-header-bar 패딩 — 폰트 변경 후 유저 확인 후 진행
### 디자인 시스템 폰트+색상 통일 작업
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.

파일 보기

@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
);
-- ============================================================
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
-- - 유출 모델링: 파공부 유출률 변화
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
-- ============================================================
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유 유출 개시. 좌현 경사 15°, GM 위험수준.',
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
'[{"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 < 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)"}]',
'[{"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)"}]',
1
),
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
(
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)"}]',
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)"}]',
2
),
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
(
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
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)"}]',
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+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
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)"}]',
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
),
-- 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
),
-- 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,
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
'[{"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
'목포항 접안 완료. 잔류유 전량 이적(총 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
);

파일 보기

@ -4,6 +4,16 @@
## [Unreleased]
## [2026-04-14]
### 추가
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
- 관리자: 비식별화조치 메뉴 및 패널 추가
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
### 변경
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
## [2026-04-13]
### 추가

파일 보기

@ -120,7 +120,7 @@ export function LoginPage() {
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
className="absolute text-body-2 text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<svg
@ -174,7 +174,7 @@ export function LoginPage() {
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
className="absolute text-body-2 text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<svg
@ -249,7 +249,7 @@ export function LoginPage() {
color: '#67e8f9',
}}
>
<span className="text-sm shrink-0 mt-px">
<span className="text-body-2 shrink-0 mt-px">
<svg
width="14"
height="14"
@ -303,7 +303,7 @@ export function LoginPage() {
<button
type="submit"
disabled={isLoading}
className="w-full text-color-accent text-sm font-bold rounded-md border"
className="w-full text-color-accent text-body-2 font-bold rounded-md border"
style={{
padding: '12px',
background: isLoading

파일 보기

@ -21,7 +21,7 @@ export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
key={item.id}
onClick={() => setActiveSubTab(item.id)}
className={`
px-4 py-2.5 text-title-5 font-medium transition-all duration-200
px-4 py-2.5 text-title-4 font-medium transition-all duration-200
font-korean tracking-navigation
${activeSubTab === item.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'}
`}

파일 보기

@ -56,11 +56,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
title="홈으로 이동"
>
<img
src="/wing_logo_white.svg"
alt="WING 해양환경 위기대응"
className="h-3.5 wing-logo"
/>
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-5 wing-logo" />
</button>
{/* Divider */}
@ -87,7 +83,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
onClick={handleClick}
title={tab.label}
className={`
px-2.5 xl:px-4 py-2 text-title-4 font-bold transition-all duration-200
px-2.5 xl:px-4 py-2 text-title-2 font-bold transition-all duration-200
font-korean tracking-navigation border-b-2 border-transparent
${isIncident ? 'ml-1' : ''}
${isMonitor ? 'ml-1 flex items-center gap-1.5' : ''}
@ -127,7 +123,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* Right Section */}
<div className="flex items-center gap-3">
{/* Status Badge */}
{/* <div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-color-danger animate-pulse">
{/* <div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-caption font-medium text-color-danger animate-pulse">
<div className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse" />
</div> */}

파일 보기

@ -141,7 +141,7 @@ export function BacktrackReplayBar({
className="w-2 h-2 rounded-full bg-color-tertiary"
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
/>
<span className="text-xs font-bold"> </span>
<span className="text-caption font-bold"> </span>
</div>
<div className="flex items-center gap-1.5">
@ -180,7 +180,7 @@ export function BacktrackReplayBar({
{/* Play/Pause */}
<button
onClick={handlePlayClick}
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-body-2 cursor-pointer"
style={{
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
border: `2px solid ${isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.4)'}`,

파일 보기

@ -414,7 +414,10 @@ export function MapView({
longitude: lng,
latitude: lat,
content: (
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
<div
className="text-caption font-korean"
style={{ minWidth: '180px', maxWidth: '260px' }}
>
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
{String(category ?? '민감자원')}
</div>
@ -535,7 +538,7 @@ export function MapView({
longitude: d.lon,
latitude: d.lat,
content: (
<div className="text-xs">
<div className="text-caption">
<strong>
{modelKey} #{(d.particle ?? 0) + 1}
</strong>
@ -604,7 +607,7 @@ export function MapView({
longitude: info.coordinate?.[0] ?? 0,
latitude: info.coordinate?.[1] ?? 0,
content: (
<div className="text-xs" style={{ minWidth: '140px' }}>
<div className="text-caption" style={{ minWidth: '140px' }}>
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
<br />
: {PRIORITY_LABELS[d.priority] || d.priority}
@ -919,7 +922,7 @@ export function MapView({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
content: (
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
<div className="text-caption leading-relaxed" style={{ minWidth: 180 }}>
<strong className="text-color-warning">
{dispersionResult.substance}
</strong>
@ -1009,7 +1012,7 @@ export function MapView({
longitude: d.lon,
latitude: d.lat,
content: (
<div className="text-xs" style={{ minWidth: '130px' }}>
<div className="text-caption" style={{ minWidth: '130px' }}>
<div className="flex items-center gap-1 mb-1">
<span>{SENSITIVE_ICONS[d.type]}</span>
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
@ -1458,19 +1461,19 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
<div className="flex flex-col gap-1">
<button
onClick={() => map?.zoomIn()}
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
>
</button>
<button
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
>
&#x1F3AF;
</button>
@ -1575,7 +1578,7 @@ function MapLegend({
className="flex items-center gap-1.5 mt-2 rounded"
style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}
>
<div className="text-xs">🧭</div>
<div className="text-caption">🧭</div>
<span className="text-caption text-fg-disabled"> ()</span>
</div>
</div>
@ -1888,7 +1891,9 @@ function BacktrackReplayBar({
minWidth: '340px',
}}
>
<div className="text-sm text-color-tertiary font-mono font-bold">{progress.toFixed(0)}%</div>
<div className="text-body-2 text-color-tertiary font-mono font-bold">
{progress.toFixed(0)}%
</div>
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
<div
className="h-full rounded-[2px]"

파일 보기

@ -79,15 +79,15 @@
--font-size-title-1: 1.125rem;
--font-size-subtitle: 0.9375rem;
--font-size-title-2: 1rem;
--font-size-title-3: 0.875rem;
--font-size-title-4: 0.8125rem;
--font-size-title-5: 0.75rem;
--font-size-title-6: 0.6875rem;
--font-size-body-1: 0.875rem;
--font-size-body-2: 0.8125rem;
--font-size-label-1: 0.75rem;
--font-size-label-2: 0.6875rem;
--font-size-caption: 0.6875rem;
--font-size-title-3: 1rem;
--font-size-title-4: 0.875rem;
--font-size-title-5: 0.8125rem;
--font-size-title-6: 0.75rem;
--font-size-body-1: 1rem;
--font-size-body-2: 0.875rem;
--font-size-label-1: 0.8125rem;
--font-size-label-2: 0.75rem;
--font-size-caption: 0.75rem;
/* typography — font-weight */
--font-weight-thin: 300;
--font-weight-regular: 400;

파일 보기

@ -45,6 +45,22 @@
background: transparent;
}
/* ═══ Incidents 사고 팝업 ✕ 버튼 — 라이트 지도 기준 검은색 고정 ═══ */
.incident-popup .maplibregl-popup-close-button {
color: #1a1d21;
background: transparent;
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
top: 6px;
right: 6px;
}
.incident-popup .maplibregl-popup-close-button:hover {
color: #000;
background: transparent;
}
/* ═══ Scrollbar ═══ */
.scrollbar-thin {
scrollbar-width: thin;
@ -78,7 +94,7 @@
border-radius: 6px;
color: var(--fg-default);
font-family: var(--font-korean);
font-size: 11px;
font-size: 0.75rem;
outline: none;
}
@ -103,7 +119,7 @@
.prd-date-input,
.prd-time-input {
font-size: 10px;
font-size: 0.75rem;
color-scheme: dark;
}
@ -111,7 +127,7 @@
.prd-time-input::-webkit-datetime-edit {
color: var(--fg-sub);
font-family: var(--font-mono);
font-size: 10px;
font-size: 0.75rem;
letter-spacing: 0.3px;
}
@ -191,7 +207,7 @@
background: #1a1f2e;
color: var(--fg-default);
padding: 10px;
font-size: 11px;
font-size: 0.75rem;
font-family: var(--font-korean);
}
@ -278,7 +294,7 @@
.combo-item {
padding: 7px 10px;
font-size: 11px;
font-size: 0.75rem;
font-family: var(--font-korean);
color: var(--fg-sub);
cursor: pointer;
@ -309,7 +325,7 @@
gap: 4px;
padding: 5px 4px;
border-radius: 5px;
font-size: 9px;
font-size: 0.6875rem;
font-weight: 600;
font-family: var(--font-korean);
cursor: pointer;
@ -328,7 +344,7 @@
/* .prd-mc.on::before {
content: '✓ ';
font-size: 9px;
font-size: 0.6875rem;
color: var(--color-accent);
} */
@ -370,7 +386,7 @@
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 6px;
color: var(--color-accent);
font-size: 9px;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
@ -551,7 +567,7 @@
}
.tll {
font-size: 10px;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-mono);
}
@ -620,7 +636,7 @@
border: 1px solid var(--color-boom);
border-radius: 4px;
padding: 4px 8px;
font-size: 10px;
font-size: 0.75rem;
color: var(--color-boom);
white-space: nowrap;
font-family: var(--font-korean);
@ -671,7 +687,7 @@
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: 0.75rem;
}
.tlsl {
@ -699,7 +715,7 @@
border: 1px solid var(--stroke-default);
background: var(--bg-card);
color: var(--fg-sub);
font-size: 11px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
font-family: var(--font-korean);
@ -750,7 +766,7 @@
padding: 6px 8px;
cursor: pointer;
transition: background 0.15s;
font-size: 11px;
font-size: 0.75rem;
color: var(--fg-sub);
font-family: var(--font-korean);
}
@ -835,7 +851,7 @@
}
.layer-count {
font-size: 10px;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-mono);
}
@ -856,7 +872,7 @@
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
padding: 8px 16px;
font-size: 11px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-boom);
font-family: var(--font-korean);
@ -889,7 +905,7 @@
border-radius: 4px;
color: var(--color-accent);
font-family: var(--font-mono);
font-size: 11px;
font-size: 0.75rem;
font-weight: 600;
text-align: right;
outline: none;
@ -926,7 +942,7 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-size: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
@ -936,7 +952,7 @@
border: 1px solid var(--stroke-default);
background: var(--bg-card);
color: var(--fg-disabled);
font-size: 10px;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
font-family: var(--font-mono);
@ -1123,7 +1139,7 @@
.lyr-h1-cnt {
margin-left: auto;
font-size: 10px;
font-size: 0.75rem;
font-weight: 500;
color: var(--fg-disabled);
font-family: var(--font-mono);
@ -1152,7 +1168,7 @@
cursor: pointer;
border-radius: 3px;
transition: background 0.15s;
font-size: 11px;
font-size: 0.75rem;
font-weight: 600;
color: var(--fg-sub);
font-family: var(--font-korean);
@ -1176,7 +1192,7 @@
.lyr-h2-cnt {
margin-left: auto;
font-size: 10px;
font-size: 0.75rem;
font-weight: 500;
color: var(--fg-disabled);
font-family: var(--font-mono);
@ -1199,7 +1215,7 @@
gap: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
font-size: 0.75rem;
color: var(--fg-sub);
transition:
color 0.15s,
@ -1215,7 +1231,7 @@
.lyr-cnt {
margin-left: auto;
font-size: 10px;
font-size: 0.75rem;
font-weight: 400;
color: var(--fg-disabled);
font-family: var(--font-mono);
@ -1329,7 +1345,7 @@
}
.lyr-ccustom label {
font-size: 9px;
font-size: 0.6875rem;
color: var(--fg-disabled);
font-family: var(--font-korean);
}
@ -1353,7 +1369,7 @@
border-radius: var(--radius-sm);
}
.lyr-style-label {
font-size: 9px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--fg-disabled);
font-family: var(--font-korean);
@ -1370,7 +1386,7 @@
margin-top: 6px;
}
.lyr-style-name {
font-size: 10px;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-korean);
min-width: 32px;
@ -1395,7 +1411,7 @@
cursor: pointer;
}
.lyr-style-val {
font-size: 9px;
font-size: 0.6875rem;
color: var(--fg-disabled);
font-family: var(--font-mono);
min-width: 28px;

파일 보기

@ -181,7 +181,7 @@
}
.wing-tab {
@apply flex-1 py-2 px-1 text-xs font-semibold rounded-md text-center cursor-pointer font-korean;
@apply flex-1 py-2 px-1 text-caption font-semibold rounded-md text-center cursor-pointer font-korean;
transition: all 0.15s;
color: var(--fg-disabled);
background: transparent;

파일 보기

@ -148,7 +148,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{/* ── 섹션 1: 헤더 ── */}
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
className="font-mono text-body-2 uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Components
@ -244,7 +244,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
+
</span>
</div>
@ -298,7 +298,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
</span>
</div>
@ -339,7 +339,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<div key={size.label} className="flex items-center justify-between gap-8">
{/* 라벨 */}
<span
className="font-mono text-sm w-36 shrink-0"
className="font-mono text-body-2 w-36 shrink-0"
style={{ color: t.textSecondary }}
>
{size.label}
@ -349,7 +349,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<div className="flex-1 flex items-center">
<button
type="button"
className="rounded-md font-semibold text-sm"
className="rounded-md font-semibold text-body-2"
style={{
height: `${size.heightPx}px`,
paddingLeft: `${size.px}px`,
@ -380,7 +380,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<div className="flex flex-col gap-8">
{/* Flexible */}
<div className="flex flex-col gap-3">
<span className="font-mono text-sm font-bold" style={{ color: t.textPrimary }}>
<span
className="font-mono text-body-2 font-bold"
style={{ color: t.textPrimary }}
>
Flexible
</span>
<div className="flex items-center gap-4">
@ -461,7 +464,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
.
</span>
</div>
@ -469,7 +472,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{/* Fixed */}
<div className="flex flex-col gap-3">
<span className="font-mono text-sm font-bold" style={{ color: t.textPrimary }}>
<span
className="font-mono text-body-2 font-bold"
style={{ color: t.textPrimary }}
>
Fixed
</span>
<div className="flex items-center gap-4">
@ -511,7 +517,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<div style={{ height: '1px', flex: 1, backgroundColor: annotationColor }} />
</div>
</div>
<span className="font-mono text-xs ml-4" style={{ color: t.textSecondary }}>
<span
className="font-mono text-caption ml-4"
style={{ color: t.textSecondary }}
>
.
</span>
</div>
@ -533,7 +542,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{ resolution: '해상도 320', width: '248px', maxWidth: '248px', padding: 16 },
].map((item) => (
<div key={item.resolution} className="flex flex-col gap-3">
<span className="font-mono text-sm" style={{ color: t.textSecondary }}>
<span className="font-mono text-body-2" style={{ color: t.textSecondary }}>
{item.resolution}
</span>
<div className="flex items-center gap-6">
@ -597,7 +606,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{VARIANTS.map((variant) => (
<th
key={variant}
className="font-mono text-xs font-semibold text-center pb-4"
className="font-mono text-caption font-semibold text-center pb-4"
style={{
color: t.textSecondary,
padding: '8px 12px',
@ -615,7 +624,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<tr key={row.state}>
{/* 상태 라벨 */}
<td
className="font-mono text-xs font-medium"
className="font-mono text-caption font-medium"
style={{
color: t.textSecondary,
padding: rowIdx === 0 ? '8px 12px 8px 0' : '8px 12px 8px 0',

파일 보기

@ -347,7 +347,7 @@ const TransparencyRow = ({
return (
<div className="flex flex-col gap-2">
<span className="font-bold text-sm" style={{ color: isDark ? '#dfe2f3' : '#0f172a' }}>
<span className="font-bold text-body-2" style={{ color: isDark ? '#dfe2f3' : '#0f172a' }}>
{label}
</span>
<div className="rounded-xl p-4" style={{ backgroundColor: sectionCardBg }}>
@ -453,7 +453,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{/* ── 섹션 1: 헤더 ── */}
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
className="font-mono text-body-2 uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Foundations
@ -476,7 +476,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<button
key={tab}
onClick={() => setActiveColorTab(tab)}
className="px-5 py-2.5 text-sm font-semibold border-none cursor-pointer bg-transparent"
className="px-5 py-2.5 text-body-2 font-semibold border-none cursor-pointer bg-transparent"
style={{
color: isActive ? t.textAccent : t.textMuted,
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
@ -505,7 +505,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
>
--{'{property}'}-{'{role}'}[-{'{variant}'}]
</div>
<p className="text-sm leading-relaxed" style={{ color: t.textSecondary }}>
<p className="text-body-2 leading-relaxed" style={{ color: t.textSecondary }}>
{' '}
<strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3
. Property는 CSS , Role은 , Variant는
@ -551,17 +551,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
}}
>
<span
className="font-mono text-sm font-bold shrink-0 w-16"
className="font-mono text-body-2 font-bold shrink-0 w-16"
style={{ color: t.textAccent }}
>
{row.prop}
</span>
<div className="flex-1">
<div className="text-sm" style={{ color: t.textSecondary }}>
<div className="text-body-2" style={{ color: t.textSecondary }}>
{row.desc}
</div>
</div>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{row.example}
</span>
</div>
@ -581,7 +581,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
className="px-5 py-3 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -608,12 +608,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
}}
>
<span
className="font-mono text-sm font-semibold"
className="font-mono text-body-2 font-semibold"
style={{ color: t.textPrimary }}
>
{row.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
{row.desc}
</span>
</div>
@ -626,7 +626,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
className="px-5 py-3 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -650,12 +650,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
}}
>
<span
className="font-mono text-sm font-semibold"
className="font-mono text-body-2 font-semibold"
style={{ color: t.textPrimary }}
>
{row.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
{row.desc}
</span>
</div>
@ -672,7 +672,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
Semantic Tokens
</h2>
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
. .
</p>
@ -745,7 +745,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
},
].map((group) => (
<div key={group.title} className="mb-8">
<h3 className="text-sm font-bold mb-3" style={{ color: t.textAccent }}>
<h3 className="text-body-2 font-bold mb-3" style={{ color: t.textAccent }}>
{group.title}
</h3>
<div
@ -754,7 +754,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
>
{/* 헤더 */}
<div
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -776,21 +776,21 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
}}
>
<span
className="font-mono text-xs line-through"
className="font-mono text-caption line-through"
style={{ color: t.textMuted }}
>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
{tk.desc}
</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
{tk.value}
</span>
<div className="flex items-center gap-2">
@ -804,7 +804,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
flexShrink: 0,
}}
/>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{hexToRgb(tk.value)}
</span>
</div>
@ -823,7 +823,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
Palette Tokens
</h2>
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
fg · bg · stroke . Property {' '}
<code className="font-mono" style={{ color: t.textAccent }}>
--color-*
@ -836,7 +836,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -882,16 +882,19 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
<span
className="font-mono text-caption line-through"
style={{ color: t.textMuted }}
>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
{tk.value}
</span>
<div className="flex items-center gap-2">
@ -905,11 +908,11 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
flexShrink: 0,
}}
/>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{hexToRgb(tk.value)}
</span>
</div>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
{tk.desc}
</span>
</div>
@ -925,7 +928,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
Non-color Tokens
</h2>
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
, .
</p>
@ -934,7 +937,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -979,17 +982,20 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
<span
className="font-mono text-caption line-through"
style={{ color: t.textMuted }}
>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span
className="text-xs font-mono px-2 py-0.5 rounded"
className="text-caption font-mono px-2 py-0.5 rounded"
style={{
color: t.textAccent,
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
@ -997,7 +1003,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
>
{tk.category}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
{tk.desc}
</span>
</div>
@ -1015,7 +1021,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
(primary color)
</h2>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
Primary . Cyan~Blue
.
</p>
@ -1023,7 +1029,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<div className="flex flex-col gap-6">
{/* Light Mode */}
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
<p className="font-bold text-body-2 mb-4" style={{ color: '#1e293b' }}>
Light Mode
</p>
<ColorScaleBar
@ -1041,7 +1047,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{/* Dark Mode */}
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
<p className="font-bold text-body-2 mb-4" style={{ color: '#fff' }}>
Dark Mode
</p>
<ColorScaleBar
@ -1067,7 +1073,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
(secondary color)
</h2>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
Secondary UI의 . Navy
.
</p>
@ -1075,7 +1081,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<div className="flex flex-col gap-6">
{/* Light Mode */}
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
<p className="font-bold text-body-2 mb-4" style={{ color: '#1e293b' }}>
Light Mode
</p>
<ColorScaleBar
@ -1093,7 +1099,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{/* Dark Mode */}
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
<p className="font-bold text-body-2 mb-4" style={{ color: '#fff' }}>
Dark Mode
</p>
<ColorScaleBar
@ -1119,11 +1125,11 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
(gray color) / ,
</h2>
<p className="mb-2 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-2 text-body-2" style={{ color: t.textSecondary }}>
Gray , , ,
.
</p>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
.
</p>
@ -1141,7 +1147,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
Transparent
</h2>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
. 65%
.
</p>
@ -1174,12 +1180,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<div className="mb-16">
<div className="mb-6">
<p
className="font-mono text-sm uppercase tracking-widest mb-1"
className="font-mono text-body-2 uppercase tracking-widest mb-1"
style={{ color: t.textAccent }}
>
Primitive
</p>
<p className="text-sm" style={{ color: t.textMuted }}>
<p className="text-body-2" style={{ color: t.textMuted }}>
UI . .
</p>
</div>
@ -1214,17 +1220,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
/>
{/* 토큰명 */}
<span
className="font-mono text-sm font-semibold ml-4"
className="font-mono text-body-2 font-semibold ml-4"
style={{ color: t.textPrimary }}
>
{token.name}
</span>
{/* HEX + RGB */}
<div className="ml-auto text-right">
<div className="font-mono text-sm" style={{ color: t.textPrimary }}>
<div className="font-mono text-body-2" style={{ color: t.textPrimary }}>
{token.hex}
</div>
<div className="font-mono text-xs" style={{ color: t.textMuted }}>
<div className="font-mono text-caption" style={{ color: t.textMuted }}>
{hexToRgb(token.hex)}
</div>
</div>

파일 보기

@ -13,7 +13,7 @@ export const ComponentsContent = () => {
>
</h1>
<p className="text-[#bcc9cd] font-korean text-sm leading-5 font-medium max-w-2xl">
<p className="text-[#bcc9cd] font-korean text-body-2 leading-5 font-medium max-w-2xl">
WING-OPS .
.
</p>

파일 보기

@ -256,7 +256,7 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
{/* ── 헤더 영역 ── */}
<div className="flex flex-col gap-3">
<span
className="font-mono text-xs font-semibold uppercase"
className="font-mono text-caption font-semibold uppercase"
style={{ letterSpacing: '1.4px', color: t.textAccent }}
>
Components
@ -264,7 +264,7 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
Overview
</h1>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
UI .
</p>
</div>
@ -307,7 +307,10 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
{/* 카드 라벨 */}
<div className="px-5 py-4">
<span className="font-sans text-sm font-semibold" style={{ color: t.textPrimary }}>
<span
className="font-sans text-body-2 font-semibold"
style={{ color: t.textPrimary }}
>
{card.label}
</span>
</div>

파일 보기

@ -154,7 +154,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
style={{ backgroundColor: t.textAccent, boxShadow: t.systemActiveShadow }}
/>
<span
className="font-mono text-xs leading-4 uppercase"
className="font-mono text-caption leading-4 uppercase"
style={{ letterSpacing: '1.2px', color: t.textAccent }}
>
System Active
@ -192,11 +192,14 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
/>
{/* 정보 */}
<div className="flex flex-col gap-1">
<span className="font-mono text-xs leading-4" style={{ color: t.textAccent }}>
<span
className="font-mono text-caption leading-4"
style={{ color: t.textAccent }}
>
{item.token}
</span>
<span
className="font-korean text-sm leading-5 font-bold"
className="font-korean text-body-2 leading-5 font-bold"
style={{ color: t.textPrimary }}
>
{item.hex}
@ -233,7 +236,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
{item.token}
</span>
<span
className="font-korean text-sm font-bold pb-2"
className="font-korean text-body-2 font-bold pb-2"
style={{ color: t.textPrimary }}
>
{item.hex}
@ -263,7 +266,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
{item.token}
</span>
<span className={item.sampleClass}>{item.sampleText}</span>
<span className="font-korean text-xs" style={{ color: item.descColor }}>
<span className="font-korean text-caption" style={{ color: item.descColor }}>
{item.desc}
</span>
</div>
@ -297,7 +300,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
/>
<div className="flex flex-col gap-1">
<span
className="font-korean text-sm font-bold"
className="font-korean text-body-2 font-bold"
style={{ color: t.textPrimary }}
>
{item.name}
@ -435,7 +438,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
<div className="grid grid-cols-2 gap-8">
{/* radius-sm */}
<div className="flex flex-col gap-2">
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
<span className="font-mono text-caption leading-4" style={{ color: t.textMuted }}>
{t.radiusSmLabel}
</span>
<div
@ -453,7 +456,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
Small Elements
</span>
<p
className="font-korean text-xs leading-[19.5px] mt-1"
className="font-korean text-caption leading-[19.5px] mt-1"
style={{ color: t.radiusDescText }}
>
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp
@ -463,7 +466,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
</div>
{/* radius-md */}
<div className="flex flex-col gap-2">
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
<span className="font-mono text-caption leading-4" style={{ color: t.textMuted }}>
{t.radiusMdLabel}
</span>
<div
@ -481,7 +484,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
Structural Panels
</span>
<p
className="font-korean text-xs leading-[19.5px] mt-1"
className="font-korean text-caption leading-[19.5px] mt-1"
style={{ color: t.radiusDescText }}
>
Applied to telemetry cards, floating modals, and primary operational panels to

파일 보기

@ -50,7 +50,10 @@ export const FloatContent = ({ theme }: FloatContentProps) => {
<h1 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Float
</h1>
<p className="font-korean text-sm leading-5 mt-1" style={{ color: t.textSecondary }}>
<p
className="font-korean text-body-2 leading-5 mt-1"
style={{ color: t.textSecondary }}
>
UI Modal, Dropdown, Overlay, Toast
</p>
</div>
@ -70,7 +73,7 @@ export const FloatContent = ({ theme }: FloatContentProps) => {
}}
>
<span
className="font-sans text-sm font-bold leading-5"
className="font-sans text-body-2 font-bold leading-5"
style={{ color: isActive ? t.textAccent : t.textMuted }}
>
{label}

파일 보기

@ -174,7 +174,7 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
{/* ── 헤더 영역 ── */}
<div className="flex flex-col gap-3">
<span
className="font-mono text-xs font-semibold uppercase"
className="font-mono text-caption font-semibold uppercase"
style={{ letterSpacing: '1.4px', color: t.textAccent }}
>
Foundations
@ -182,7 +182,7 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
Overview
</h1>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
.
</p>
</div>
@ -225,7 +225,10 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
{/* 카드 라벨 */}
<div className="px-5 py-4">
<span className="font-sans text-sm font-semibold" style={{ color: t.textPrimary }}>
<span
className="font-sans text-body-2 font-semibold"
style={{ color: t.textPrimary }}
>
{card.label}
</span>
</div>

파일 보기

@ -324,7 +324,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
Layout
</h1>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
WING-OPS는 .
(100vh), flex . KRDS
xlarge / xxlarge .
@ -364,7 +364,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Breakpoint
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
. WING-OPS
(xl, 2xl) cyan으로 .
</p>
@ -499,7 +499,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
borderColor: isDark ? 'rgba(76,215,246,0.45)' : 'rgba(6,182,212,0.40)',
}}
/>
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
WING-OPS
</span>
</div>
@ -511,7 +511,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
borderColor: isDark ? 'rgba(140,144,159,0.25)' : 'rgba(148,163,184,0.30)',
}}
/>
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
(1280px )
</span>
</div>
@ -525,7 +525,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Grid
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
, , . xl / 2xl
.
</p>
@ -548,7 +548,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
>
<div className="flex items-center gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
Breakpoint
</span>
<span
@ -562,10 +562,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
Width
</span>
<span className="font-mono text-sm" style={{ color: t.textPrimary }}>
<span className="font-mono text-body-2" style={{ color: t.textPrimary }}>
{spec.width}
</span>
</div>
@ -683,7 +683,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<span className="font-mono text-base" style={{ color: '#f97316' }}>
</span>
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
<strong style={{ color: '#f97316' }}>1280px </strong> Mobile / Tablet
(xs / s / md / lg) .
</span>
@ -696,7 +696,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
App Shell
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
WING-OPS KRDS Sub-page .
</p>
</div>
@ -720,7 +720,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
}}
>
<div className="flex items-center gap-3">
<span className="font-mono text-sm font-bold" style={{ color: '#3b82f6' }}>
<span className="font-mono text-body-2 font-bold" style={{ color: '#3b82f6' }}>
TopBar
</span>
<span
@ -932,7 +932,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Spacing
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
UI . Tailwind
spacing , px .
</p>
@ -1000,7 +1000,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
4pt Grid
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
4point . ,
2 .
</p>
@ -1048,7 +1048,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
>
{item.label}
</span>
<span className="font-korean text-xs" style={{ color: t.textPrimary }}>
<span className="font-korean text-caption" style={{ color: t.textPrimary }}>
{item.text}
</span>
</div>
@ -1145,7 +1145,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
minWidth: '180px',
}}
>
<span className="font-korean text-xs font-bold" style={{ color: t.textPrimary }}>
<span
className="font-korean text-caption font-bold"
style={{ color: t.textPrimary }}
>
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
@ -1185,7 +1188,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
</span>
</div>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
UI . z-index가 ,
.
</p>
@ -1216,7 +1219,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: layer.color }}
/>
<span className="font-mono text-xs font-bold" style={{ color: layer.color }}>
<span className="font-mono text-caption font-bold" style={{ color: layer.color }}>
{layer.name}
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
@ -1234,7 +1237,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Reference
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
App Shell CSS KRDS Grid .
</p>
</div>

파일 보기

@ -70,17 +70,17 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
Radius
</h1>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
Radius는 .
</p>
</div>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
Radius는 UI .
Radius , ,
.
</p>
<ul
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
className="flex flex-col gap-1 list-disc list-inside font-korean text-body-2"
style={{ color: t.textSecondary }}
>
<li>
@ -203,7 +203,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
wing.css Radius .
</p>
</div>
@ -233,7 +233,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{/* 정보 */}
<div className="flex flex-col gap-1.5 flex-1">
<span className="font-mono text-xs font-bold" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption font-bold" style={{ color: t.textPrimary }}>
{item.className}
</span>
<div className="flex flex-row flex-wrap gap-2">

파일 보기

@ -207,7 +207,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* ── 섹션 1: 헤더 ── */}
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
className="font-mono text-body-2 uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Components
@ -225,7 +225,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
Input Field
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
<p className="text-body-2" style={{ color: t.textSecondary }}>
.
</p>
</div>
@ -246,7 +246,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Prefix label */}
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Prefix
@ -272,7 +272,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Input label */}
<div className="flex flex-col items-center gap-0.5">
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Input
@ -295,7 +295,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Suffix label */}
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Suffix
@ -377,7 +377,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Suffix 텍스트 */}
<span
className="shrink-0 ml-2 font-mono text-sm"
className="shrink-0 ml-2 font-mono text-body-2"
style={{ color: fieldPlaceholder }}
>
@ -402,7 +402,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
}}
>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Container
@ -437,7 +437,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Clear Button
@ -468,7 +468,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Container
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. , , .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
@ -519,13 +519,13 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
<div>
<p className="font-mono text-xs" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption" style={{ color: t.textSecondary }}>
height: 44px (Medium)
</p>
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
padding: 12px ()
</p>
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
border-radius: 6px
</p>
</div>
@ -543,14 +543,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Placeholder
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-6">
{/* 플레이스홀더 있는 필드 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
</span>
<div
@ -570,7 +570,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 빈 필드 (플레이스홀더 없음) */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
</span>
<div
@ -597,14 +597,17 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Label
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. * .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-8">
{/* 일반 라벨 */}
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
<span
className="text-body-2 font-semibold mb-1.5"
style={{ color: t.textPrimary }}
>
</span>
<div
@ -624,7 +627,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 필수 라벨 */}
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
<span
className="text-body-2 font-semibold mb-1.5"
style={{ color: t.textPrimary }}
>
<span style={{ color: '#ef4444' }}>*</span>
</span>
<div
@ -655,7 +661,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Input Text
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
@ -673,7 +679,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
</div>
<div className="flex gap-6 text-xs font-mono" style={{ color: t.textSecondary }}>
<div
className="flex gap-6 text-caption font-mono"
style={{ color: t.textSecondary }}
>
<span>font-size: 14px</span>
<span>color: textPrimary</span>
<span>font-weight: 400</span>
@ -692,14 +701,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Clear Icon
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-6">
{/* 텍스트 입력 + Clear 아이콘 표시 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
(Clear )
</span>
<div
@ -736,7 +745,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 빈 상태 (Clear 미표시) */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
(Clear )
</span>
<div
@ -767,7 +776,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Helper Text
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
@ -787,7 +796,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
</div>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
, 8
</span>
</div>
@ -807,7 +816,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
</div>
<span className="text-xs" style={{ color: '#ef4444' }}>
<span className="text-caption" style={{ color: '#ef4444' }}>
.
</span>
</div>
@ -828,7 +837,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<div key={row.state} className="flex items-center gap-8">
{/* 왼쪽: State 라벨 + 뱃지 */}
<div className="flex items-center gap-2 shrink-0" style={{ width: '200px' }}>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
State
</span>
<span
@ -891,7 +900,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
Text Area
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
<p className="text-body-2" style={{ color: t.textSecondary }}>
.
</p>
</div>
@ -911,7 +920,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Input Area label */}
<div className="flex flex-col items-center gap-0.5" style={{ width: '80px' }}>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Input Area
@ -933,7 +942,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Placeholder label */}
<div className="flex flex-col items-center gap-0.5">
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Placeholder
@ -955,7 +964,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* Character Counter label */}
<div className="flex flex-col items-center gap-0.5" style={{ width: '110px' }}>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Character Counter
@ -1042,7 +1051,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
}}
>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Container
@ -1075,7 +1084,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<span
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: annotationColor }}
>
Resize Handle
@ -1103,7 +1112,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Container
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. 112px이며
.
</p>
@ -1154,16 +1163,16 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
<div className="pt-2">
<p className="font-mono text-xs" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption" style={{ color: t.textSecondary }}>
height: 112px (default)
</p>
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
padding: 12px
</p>
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
border-radius: 6px
</p>
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
resize: vertical
</p>
</div>
@ -1181,14 +1190,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Placeholder
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
.
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-6">
{/* 플레이스홀더 있는 TextArea */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
</span>
<div
@ -1209,7 +1218,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 빈 TextArea */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
</span>
<div
@ -1236,14 +1245,17 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Label
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
.
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-8">
{/* 기본 라벨 */}
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
<span
className="text-body-2 font-semibold mb-1.5"
style={{ color: t.textPrimary }}
>
</span>
<div
@ -1264,7 +1276,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 필수(*) 라벨 */}
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
<span
className="text-body-2 font-semibold mb-1.5"
style={{ color: t.textPrimary }}
>
<span style={{ color: '#ef4444' }}>*</span>
</span>
<div
@ -1296,7 +1311,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Input Text
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
.
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
@ -1316,7 +1331,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
{'오늘 점검 내용을 기록합니다.\n상세 내용은 아래와 같습니다.'}
</div>
<div className="flex gap-6 text-xs font-mono" style={{ color: t.textSecondary }}>
<div
className="flex gap-6 text-caption font-mono"
style={{ color: t.textSecondary }}
>
<span>font-size: 14px</span>
<span>color: textPrimary</span>
<span>line-height: 1.6</span>
@ -1335,14 +1353,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Clear Icon
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
. .
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex gap-6">
{/* 텍스트 있는 상태 (Clear 표시) */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
(Clear )
</span>
<div
@ -1380,7 +1398,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* 빈 상태 (Clear 미표시) */}
<div className="flex flex-col gap-2">
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
(Clear )
</span>
<div
@ -1412,7 +1430,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Helper Text
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
.
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
@ -1434,10 +1452,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
<div className="flex items-center justify-between" style={{ width: '240px' }}>
<span className="text-xs" style={{ color: t.textMuted }}>
<span className="text-caption" style={{ color: t.textMuted }}>
</span>
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
0/500
</span>
</div>
@ -1459,7 +1477,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
</div>
<span className="text-xs" style={{ color: '#ef4444' }}>
<span className="text-caption" style={{ color: '#ef4444' }}>
.
</span>
</div>
@ -1480,7 +1498,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<div key={`ta-${row.state}`} className="flex items-start gap-8">
{/* 왼쪽: State 라벨 + 뱃지 */}
<div className="flex items-center gap-2 shrink-0 pt-3" style={{ width: '200px' }}>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
State
</span>
<span

파일 보기

@ -307,18 +307,18 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
Typography
</h1>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
WING-OPS . , ,
.
</p>
</div>
<div className="flex flex-col gap-2">
<h3 className="font-korean text-sm font-bold" style={{ color: t.textPrimary }}>
<h3 className="font-korean text-body-2 font-bold" style={{ color: t.textPrimary }}>
</h3>
<ul
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
className="flex flex-col gap-1 list-disc list-inside font-korean text-body-2"
style={{ color: t.textSecondary }}
>
<li>
@ -337,7 +337,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
, .
UI에 .
</p>
@ -352,7 +352,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
}}
>
<pre
className="font-mono text-sm leading-6"
className="font-mono text-body-2 leading-6"
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
>
<span style={{ color: t.textAccent }}>font-family</span>
@ -390,7 +390,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
</div>
<div className="px-5 py-5 flex flex-col gap-4">
<div
className="font-mono text-xs leading-5 rounded px-3 py-2"
className="font-mono text-caption leading-5 rounded px-3 py-2"
style={{
color: isDark ? '#9ba3b8' : '#64748b',
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
@ -398,7 +398,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
>
{font.stack}
</div>
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
<p className="font-korean text-caption leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
<div className="flex flex-col gap-3 pt-2">
<div className="flex flex-col gap-1">
<span className="font-mono text-caption uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
@ -421,7 +421,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
</h2>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
Display, Heading, Body, Navigation, Label의 5
.
</p>
@ -434,7 +434,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h3 className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>
{category.name}
</h3>
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
<p className="font-korean text-caption leading-5" style={{ color: t.textSecondary }}>
{category.description}
</p>
</div>
@ -445,7 +445,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
>
<div
className="grid grid-cols-[1fr_80px_80px_80px_90px_140px] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[1fr_80px_80px_80px_90px_140px] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -467,20 +467,23 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
<span
className="font-mono text-caption font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.px}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.weight}
</span>
<span
className="font-mono text-xs"
className="font-mono text-caption"
style={{ color: t.textMuted }}
>{`${(row.lineHeight * 100).toFixed(0)}%`}</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{row.letterSpacing}
</span>
<span
@ -510,7 +513,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
>
<div className="shrink-0 w-[100px]">
<div
className="font-mono text-xs font-semibold"
className="font-mono text-caption font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
@ -533,7 +536,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
{row.sample}
</span>
</div>
<div className="shrink-0 text-xs font-korean" style={{ color: t.textMuted }}>
<div className="shrink-0 text-caption font-korean" style={{ color: t.textMuted }}>
{row.role}
</div>
</div>
@ -551,7 +554,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
<p className="text-body-2" style={{ color: t.textSecondary }}>
Regular(400), Bold(700). Medium(500) , Thin(300)
.
</p>
@ -561,7 +564,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
>
<div
className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -606,17 +609,20 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
<span
className="font-mono text-caption font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
<span className="text-caption" style={{ color: t.textSecondary }}>
{row.name}
</span>
<span
className="font-korean text-sm"
className="font-korean text-body-2"
style={{ color: t.textPrimary, fontWeight: Number(row.value) }}
>
{row.preview}
@ -632,7 +638,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
<p className="text-body-2" style={{ color: t.textSecondary }}>
(1.3), (1.6).
.
</p>
@ -642,7 +648,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
>
<div
className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -677,16 +683,19 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
<span
className="font-mono text-caption font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{row.pct}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
<span className="text-caption" style={{ color: t.textSecondary }}>
{row.desc}
</span>
</div>
@ -700,7 +709,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
<p className="text-body-2" style={{ color: t.textSecondary }}>
. Display는 , Body는 .
</p>
</div>
@ -709,7 +718,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
>
<div
className="grid grid-cols-[1fr_80px_140px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
className="grid grid-cols-[1fr_80px_140px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
@ -760,10 +769,13 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
<span
className="font-mono text-caption font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span
@ -776,7 +788,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
>
{row.tw}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
<span className="text-caption" style={{ color: t.textSecondary }}>
{row.category}
</span>
</div>

파일 보기

@ -167,7 +167,7 @@ export const ButtonCatalogSection = () => {
<div className="border-b border-solid border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative">
<div className="bg-[#06b6d4] rounded-xl shrink-0 w-1 h-4 relative" />
<div
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
인터페이스: 버튼
@ -185,7 +185,7 @@ export const ButtonCatalogSection = () => {
className="pt-px pr-2 pb-[17.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative"
>
<div
className="text-[#64748b] text-left font-korean text-xs font-medium uppercase relative flex items-center justify-start"
className="text-[#64748b] text-left font-korean text-caption font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '-0.55px' }}
>
{header}
@ -207,7 +207,7 @@ export const ButtonCatalogSection = () => {
>
{/* 버튼 유형 레이블 */}
<div className="pt-[31.5px] pr-2 pb-[31.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
<div className="text-[#bcc9cd] text-left font-korean text-xs font-medium relative flex items-center justify-start">
<div className="text-[#bcc9cd] text-left font-korean text-caption font-medium relative flex items-center justify-start">
{row.label}
</div>
</div>

파일 보기

@ -129,7 +129,7 @@ export const CardSection = () => {
<div className="text-white text-left font-sans font-bold text-4xl leading-10 relative flex items-center justify-start">
24.8
</div>
<div className="text-[#64748b] text-left font-sans font-semibold text-sm leading-5 relative flex items-center justify-start">
<div className="text-[#64748b] text-left font-sans font-semibold text-body-2 leading-5 relative flex items-center justify-start">
(knots)
</div>
</div>

파일 보기

@ -61,7 +61,7 @@ export const IconBadgeSection = () => {
<div className="bg-[#e89337] rounded-xl shrink-0 w-1 h-4 relative"></div>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
컨트롤: 아이콘
@ -113,7 +113,7 @@ export const IconBadgeSection = () => {
<div className="bg-[#93000a] rounded-xl shrink-0 w-1 h-4 relative"></div>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
컨트롤: 아이콘

파일 보기

@ -36,10 +36,10 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Dropdown
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
{' '}
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
className="font-mono text-caption px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -50,7 +50,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
. 5 .
{' '}
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
className="font-mono text-caption px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -84,7 +84,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
>
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
</span>
<ComboBox
@ -98,7 +98,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
</span>
</div>
<div className="flex flex-col gap-2">
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
</span>
<ComboBox
@ -112,7 +112,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
</span>
</div>
</div>
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
<p className="font-korean text-caption" style={{ color: t.textMuted }}>
{' '}
<code className="font-mono" style={{ color: t.textAccent }}>
@common/components/ui/ComboBox
@ -373,7 +373,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{row.desc}
</span>
</div>
@ -423,10 +423,16 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
: t.cardBorder,
}}
>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
<span
className="font-korean text-body-2 font-medium"
style={{ color: t.textPrimary }}
>
{item.title}
</span>
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
<span
className="font-korean text-caption leading-5"
style={{ color: t.textSecondary }}
>
{item.desc}
</span>
</div>

파일 보기

@ -88,9 +88,9 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Modal
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
<code
className="font-mono text-xs px-1.5 py-0.5 rounded"
className="font-mono text-caption px-1.5 py-0.5 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -156,7 +156,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
</button>
))}
</div>
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{SIZE_CONFIG[activeSize].desc}
</span>
</div>
@ -166,7 +166,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
className="px-4 py-2 rounded border border-solid font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.15)' : 'rgba(6,182,212,0.12)',
borderColor: t.textAccent,
@ -178,7 +178,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsConfirmOpen(true)}
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
className="px-4 py-2 rounded border border-solid font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.10)' : 'rgba(239,68,68,0.06)',
borderColor: 'rgba(239,68,68,0.40)',
@ -200,7 +200,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
className="rounded-lg border border-solid p-5 flex flex-col gap-3"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
Confirm은 <strong>Modal의 variant</strong>. +
+ / 2 . (, ) .
</p>
@ -423,7 +423,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<span
className="font-mono text-sm rounded border border-solid px-2 py-0.5 shrink-0"
className="font-mono text-body-2 rounded border border-solid px-2 py-0.5 shrink-0"
style={{ color: t.textAccent, borderColor: t.cardBorder }}
>
{row.range}
@ -434,7 +434,10 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
>
{row.status}
</span>
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
<span
className="font-korean text-caption leading-5"
style={{ color: t.textSecondary }}
>
{row.desc}
</span>
</div>
@ -480,7 +483,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
}}
>
<div className="py-2.5 px-4">
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{item.component}
</span>
</div>
@ -496,7 +499,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{item.trigger}
</span>
</div>
@ -533,7 +536,10 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
className="flex items-center justify-between px-5 py-4 border-b border-solid shrink-0"
style={{ borderColor: modalBorder }}
>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
<span
className="font-korean text-body-2 font-medium"
style={{ color: t.textPrimary }}
>
Modal Preview {SIZE_CONFIG[activeSize].label} ({SIZE_CONFIG[activeSize].width})
</span>
<button
@ -542,16 +548,16 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
className="w-7 h-7 rounded flex items-center justify-center hover:opacity-70 transition-opacity"
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
>
<span className="font-mono text-sm" style={{ color: t.textMuted }}>
<span className="font-mono text-body-2" style={{ color: t.textMuted }}>
</span>
</button>
</div>
<div className="px-5 py-5 flex flex-col gap-3 overflow-y-auto">
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
{' '}
<code
className="font-mono text-xs px-1 rounded"
className="font-mono text-caption px-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -568,7 +574,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
className="rounded border border-solid px-3 py-2.5"
style={{ borderColor: t.cardBorder }}
>
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
{label}
</span>
</div>
@ -582,7 +588,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
className="px-4 py-2 rounded border border-solid font-korean text-body-2 transition-opacity hover:opacity-70"
style={{ borderColor: t.cardBorder, color: t.textMuted }}
>
@ -590,7 +596,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
className="px-4 py-2 rounded font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)',
color: t.textAccent,
@ -624,13 +630,16 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
>
<div className="flex items-center gap-2">
<span style={{ color: '#ef4444', fontSize: '16px' }}></span>
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
<span
className="font-korean text-body-2 font-medium"
style={{ color: t.textPrimary }}
>
?
</span>
</div>
</div>
<div className="px-5 py-4">
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
. ?
</p>
</div>
@ -641,7 +650,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsConfirmOpen(false)}
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
className="px-4 py-2 rounded border border-solid font-korean text-body-2 transition-opacity hover:opacity-70"
style={{ borderColor: t.cardBorder, color: t.textMuted }}
>
@ -649,7 +658,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
<button
type="button"
onClick={() => setIsConfirmOpen(false)}
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
className="px-4 py-2 rounded font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.12)',
color: '#ef4444',

파일 보기

@ -63,10 +63,10 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
Overlay
</h2>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -133,12 +133,12 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{row.overlay}
</span>
</div>
<div className="py-2.5 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
{row.modal}
</span>
</div>
@ -284,7 +284,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
<strong>ScatPopup</strong> . Modal(fixed
) , ·
.
@ -307,7 +307,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
{item.label}
</span>
<span className="font-mono text-xs" style={{ color: t.textAccent }}>
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
{item.value}
</span>
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
@ -323,7 +323,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
borderColor: 'rgba(234,179,8,0.25)',
}}
>
<span className="font-korean text-xs" style={{ color: '#eab308' }}>
<span className="font-korean text-caption" style={{ color: '#eab308' }}>
주의: ScatPopup은 MapLibre GL JS의 Popup/Marker React DOM으로 .
position: absolute로 .
</span>
@ -377,7 +377,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
}}
>
<div className="py-2.5 px-3">
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
{item.component}
</span>
</div>
@ -413,7 +413,10 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
</span>
</div>
<div className="py-2.5 px-3">
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
<span
className="font-korean text-caption leading-5"
style={{ color: t.textSecondary }}
>
{item.desc}
</span>
</div>

파일 보기

@ -84,10 +84,10 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
</span>
</div>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
.
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
color: t.textAccent,
@ -97,7 +97,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
</code>
. {' '}
<code
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
style={{
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
@ -129,7 +129,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
<p className="font-korean text-caption" style={{ color: t.textMuted }}>
Toast가 . 3 .
</p>
<div className="flex gap-2 flex-wrap">
@ -280,7 +280,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
<span className="font-mono text-lg" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
<span className="font-mono text-body-2 font-bold" style={{ color: cfg.color }}>
{cfg.label}
</span>
</div>
@ -311,7 +311,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
>
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
Toast는 <strong>Zustand store + useToast hook</strong>{' '}
. ToastContainer는 App.tsx .
</p>
@ -387,7 +387,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
{cfg.icon}
</span>
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
<span className="font-korean text-body-2 flex-1" style={{ color: t.textPrimary }}>
{toast.message}
</span>
<button

파일 보기

@ -6,7 +6,7 @@ interface AdminPlaceholderProps {
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-4xl opacity-20">🚧</div>
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</div>
<div className="text-body-2 font-korean text-fg-sub font-semibold">{label}</div>
<div className="text-label-2 font-korean text-fg-disabled"> .</div>
</div>
);

파일 보기

@ -107,7 +107,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
>
{/* 헤더 */}
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
<span></span>
</div>
</div>
@ -129,7 +129,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
}}
>
<span className="text-sm">{section.icon}</span>
<span className="text-body-2">{section.icon}</span>
<span className="flex-1 text-left">{section.label}</span>
<span
className="text-caption text-fg-disabled transition-transform"

파일 보기

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import AdminSidebar from './AdminSidebar';
import AdminPlaceholder from './AdminPlaceholder';
import { findMenuLabel } from './adminMenuConfig';
@ -19,9 +19,15 @@ 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, () => JSX.Element> = {
const PANEL_MAP: Record<string, () => React.JSX.Element> = {
users: () => <UsersPanel />,
permissions: () => <PermissionsPanel />,
menus: () => <MenusPanel />,
@ -42,6 +48,12 @@ const PANEL_MAP: Record<string, () => 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() {

파일 보기

@ -103,7 +103,7 @@ function AssetUploadPanel() {
{/* 헤더 */}
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
</p>
</div>
@ -115,7 +115,7 @@ function AssetUploadPanel() {
<div className="flex-1 max-w-[560px] space-y-4">
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-4">
{/* 드롭존 */}
@ -135,12 +135,12 @@ function AssetUploadPanel() {
>
<div className="text-3xl mb-2 opacity-40">📁</div>
{selectedFile ? (
<div className="text-xs font-semibold text-color-accent font-korean mb-1">
<div className="text-caption font-semibold text-color-accent font-korean mb-1">
{selectedFile.name}
</div>
) : (
<>
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">
<div className="text-caption font-semibold text-fg-sub font-korean mb-1">
</div>
<div className="text-caption text-fg-disabled font-korean mb-3">
@ -148,7 +148,7 @@ function AssetUploadPanel() {
</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-color-accent text-bg-0
className="px-4 py-1.5 text-caption font-semibold rounded-md bg-color-accent text-bg-0
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
onClick={(e) => {
e.stopPropagation();
@ -176,7 +176,7 @@ function AssetUploadPanel() {
<select
value={assetCategory}
onChange={(e) => setAssetCategory(e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
text-fg focus:border-color-accent focus:outline-none font-korean"
>
{ASSET_CATEGORIES.map((c) => (
@ -195,7 +195,7 @@ function AssetUploadPanel() {
<select
value={jurisdiction}
onChange={(e) => setJurisdiction(e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
text-fg focus:border-color-accent focus:outline-none font-korean"
>
{JURISDICTIONS.map((j) => (
@ -212,7 +212,7 @@ function AssetUploadPanel() {
</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
<input
type="radio"
checked={uploadMode === 'add'}
@ -221,7 +221,7 @@ function AssetUploadPanel() {
/>
( + )
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
<input
type="radio"
checked={uploadMode === 'replace'}
@ -238,7 +238,7 @@ function AssetUploadPanel() {
type="button"
onClick={handleUpload}
disabled={!selectedFile || uploaded}
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
className={`w-full py-2.5 text-caption font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
uploaded
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
@ -255,7 +255,7 @@ function AssetUploadPanel() {
{/* 수정 권한 체계 */}
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-2">
{PERM_ITEMS.map((p) => (
@ -264,13 +264,15 @@ function AssetUploadPanel() {
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-body-2 flex-shrink-0"
style={{ background: p.bg }}
>
{p.icon}
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className={`text-caption font-bold font-korean ${p.color}`}>
{p.role}
</div>
<div className="text-caption text-fg-disabled font-korean mt-0.5">
{p.desc}
</div>
@ -283,7 +285,7 @@ function AssetUploadPanel() {
{/* 최근 업로드 이력 */}
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-2">
{uploadHistory.length === 0 ? (
@ -297,7 +299,9 @@ function AssetUploadPanel() {
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
>
<div>
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</div>
<div className="text-caption font-semibold text-fg font-korean">
{h.fileNm}
</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}
</div>

파일 보기

@ -119,8 +119,8 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
<h2 className="text-sm font-semibold text-fg"> </h2>
<span className="text-xs text-fg-disabled"> {data?.totalCount ?? 0}</span>
<h2 className="text-body-2 font-semibold text-fg"> </h2>
<span className="text-caption text-fg-disabled"> {data?.totalCount ?? 0}</span>
</div>
{/* 카테고리 탭 + 검색 */}
@ -130,7 +130,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<button
key={tab.code}
onClick={() => handleCategoryChange(tab.code)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
className={`px-3 py-1 text-caption rounded-full transition-colors ${
activeCategory === tab.code
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
@ -146,11 +146,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="제목/작성자 검색"
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
/>
<button
type="submit"
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
>
</button>
@ -162,7 +162,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<button
onClick={handleDelete}
disabled={selected.size === 0 || deleting}
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
</button>
@ -170,7 +170,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<table className="w-full text-caption">
<thead className="sticky top-0 bg-bg-surface z-10">
<tr className="border-b border-stroke-1 text-fg-disabled">
<th className="w-8 py-2 text-center">
@ -222,7 +222,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
>
&lt;
</button>
@ -234,7 +234,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<button
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
className={`w-7 h-7 text-caption rounded ${
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
@ -247,7 +247,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
>
&gt;
</button>

파일 보기

@ -99,13 +99,15 @@ function CleanupEquipPanel() {
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean"> {filtered.length} </p>
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filtered.length}
</p>
</div>
<div className="flex items-center gap-3">
<select
value={regionFilter}
onChange={handleFilterChange(setRegionFilter)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value="전체"> </option>
<option value="남해"></option>
@ -117,7 +119,7 @@ function CleanupEquipPanel() {
<select
value={typeFilter}
onChange={handleFilterChange(setTypeFilter)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value="전체"> </option>
{typeOptions.map((t) => (
@ -129,7 +131,7 @@ function CleanupEquipPanel() {
<select
value={equipFilter}
onChange={handleFilterChange(setEquipFilter)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value="전체"> </option>
<option value="방제선"></option>
@ -146,11 +148,11 @@ function CleanupEquipPanel() {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<button
onClick={load}
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
>
</button>
@ -160,7 +162,7 @@ function CleanupEquipPanel() {
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
@ -217,7 +219,7 @@ function CleanupEquipPanel() {
<tr>
<td
colSpan={11}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
>
.
</td>

파일 보기

@ -211,7 +211,7 @@ const HEADERS = [
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{HEADERS.map((h) => (
@ -317,10 +317,10 @@ export default function CollectHrPanel() {
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -332,7 +332,7 @@ export default function CollectHrPanel() {
<button
onClick={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"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption 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' : ''}`}
@ -354,11 +354,11 @@ export default function CollectHrPanel() {
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
{completedCount}
</span>
<span className="text-xs text-t3">
<span className="text-caption text-t3">
{rows.length} (: {activeCount} / : {rows.length - activeCount})
</span>
</div>

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

파일 보기

@ -130,7 +130,9 @@ const DispersingZonePanel = () => {
onClick={() => handleToggleExpand(zone)}
>
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
<span className="flex-1 text-xs font-semibold text-fg font-korean">{info.label}</span>
<span className="flex-1 text-caption font-semibold text-fg font-korean">
{info.label}
</span>
{/* 토글 스위치 */}
<button
onClick={(e) => {
@ -209,7 +211,7 @@ const DispersingZonePanel = () => {
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
{/* 헤더 */}
<div className="px-4 py-4 border-b border-stroke shrink-0">
<h1 className="text-sm font-bold text-fg font-korean"> </h1>
<h1 className="text-body-2 font-bold text-fg font-korean"> </h1>
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean"> </p>
</div>

파일 보기

@ -186,7 +186,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
};
const inputCls =
'w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
'w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
return (
@ -194,7 +194,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
<h2 className="text-sm font-bold text-fg font-korean">
<h2 className="text-body-2 font-bold text-fg font-korean">
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
</h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
@ -214,7 +214,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
? handleParentChange(e.target.value)
: handleField('upLayerCd', e.target.value)
}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
>
<option value="">()</option>
{options
@ -311,7 +311,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<select
value={form.useYn}
onChange={(e) => handleField('useYn', e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
>
<option value="Y"></option>
<option value="N"></option>
@ -329,14 +329,14 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-xs border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-3 py-1.5 text-caption border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={saving}
className="px-3 py-1.5 text-xs bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
className="px-3 py-1.5 text-caption bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
>
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
</button>
@ -449,11 +449,11 @@ const LayerPanel = () => {
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean"> {total}</p>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
<button
onClick={() => setModal({ mode: 'create' })}
className="px-3 py-1.5 text-xs font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
className="px-3 py-1.5 text-caption font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
>
</button>
@ -465,12 +465,12 @@ const LayerPanel = () => {
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="레이어코드 / 레이어명 검색"
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<select
value={filterUseYn}
onChange={(e) => setFilterUseYn(e.target.value)}
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""></option>
<option value="Y"></option>
@ -478,7 +478,7 @@ const LayerPanel = () => {
</select>
<button
onClick={handleSearch}
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
@ -487,7 +487,7 @@ const LayerPanel = () => {
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
{error}
</div>
)}
@ -495,7 +495,7 @@ const LayerPanel = () => {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
@ -539,7 +539,7 @@ const LayerPanel = () => {
<tr>
<td
colSpan={10}
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
>
.
</td>
@ -551,15 +551,15 @@ const LayerPanel = () => {
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
{/* 레이어코드 */}
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
{/* 레이어명 */}
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
{/* 레이어전체명 */}
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
{item.layerFullNm}
</span>
@ -575,7 +575,7 @@ const LayerPanel = () => {
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
</td>
{/* 정렬순서 */}
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
{item.sortOrd}
</td>
{/* 등록일시 */}
@ -614,13 +614,13 @@ const LayerPanel = () => {
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
<button
onClick={() => setModal({ mode: 'edit', data: item })}
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
>
</button>
<button
onClick={() => handleDelete(item.layerCd)}
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
>
</button>

파일 보기

@ -78,7 +78,7 @@ function MapBaseModal({
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean">
<h2 className="text-body-2 font-bold text-fg font-korean">
{isEdit ? '지도 수정' : '지도 등록'}
</h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
@ -108,7 +108,7 @@ function MapBaseModal({
value={form.mapNm}
onChange={(e) => setField('mapNm', e.target.value)}
placeholder="지도 이름을 입력하세요"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
@ -123,7 +123,7 @@ function MapBaseModal({
onChange={(e) => setField('mapKey', e.target.value)}
placeholder="고유 식별 키 (영문/숫자)"
disabled={isEdit}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
@ -135,7 +135,7 @@ function MapBaseModal({
<select
value={form.mapLevelCd}
onChange={(e) => setField('mapLevelCd', e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""></option>
{MAP_LEVEL_OPTIONS.map((opt) => (
@ -156,7 +156,7 @@ function MapBaseModal({
value={form.mapSrc}
onChange={(e) => setField('mapSrc', e.target.value)}
placeholder="타일 URL 또는 파일 경로"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
@ -170,7 +170,7 @@ function MapBaseModal({
value={form.mapDc}
onChange={(e) => setField('mapDc', e.target.value)}
placeholder="지도에 대한 설명을 입력하세요"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
/>
</div>
@ -193,7 +193,7 @@ function MapBaseModal({
}`}
/>
</button>
<span className="text-xs text-fg-sub font-korean">
<span className="text-caption text-fg-sub font-korean">
{form.useYn === 'Y' ? '사용' : '미사용'}
</span>
</div>
@ -208,14 +208,14 @@ function MapBaseModal({
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
</button>
@ -350,11 +350,11 @@ function MapBasePanel() {
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean"> {total}</p>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
<button
onClick={() => openModal(null)}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
+
</button>
@ -375,7 +375,7 @@ function MapBasePanel() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<table className="w-full text-caption">
<thead className="sticky top-0 bg-bg-surface z-10">
<tr className="border-b border-stroke text-fg-disabled">
<th className="w-12 py-3 text-center"></th>
@ -433,7 +433,7 @@ function MapBasePanel() {
<td className="py-3 text-center">
<button
onClick={() => openModal(item)}
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
>
</button>
@ -441,7 +441,7 @@ function MapBasePanel() {
<td className="py-3 text-center">
<button
onClick={() => handleDelete(item)}
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
>
</button>
@ -452,7 +452,7 @@ function MapBasePanel() {
</tbody>
</table>
{!loading && items.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-fg-disabled font-korean">
<div className="flex items-center justify-center h-32 text-caption text-fg-disabled font-korean">
.
</div>
)}
@ -464,7 +464,7 @@ function MapBasePanel() {
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
>
&lt;
</button>
@ -476,7 +476,7 @@ function MapBasePanel() {
<button
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
className={`w-7 h-7 text-caption rounded ${
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
@ -489,7 +489,7 @@ function MapBasePanel() {
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
>
&gt;
</button>

파일 보기

@ -124,7 +124,7 @@ function MenusPanel() {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-fg-disabled text-sm font-korean"> ...</div>
<div className="text-fg-disabled text-body-2 font-korean"> ...</div>
</div>
);
}
@ -136,14 +136,14 @@ function MenusPanel() {
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
, , ,
</p>
</div>
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean ${
hasChanges && !saving
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
@ -188,7 +188,7 @@ function MenusPanel() {
<DragOverlay>
{activeMenu ? (
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
<span className="text-fg-disabled text-xs"></span>
<span className="text-fg-disabled text-caption"></span>
<span className="text-title-2">{activeMenu.icon}</span>
<span className="text-title-4 font-semibold text-fg font-korean">
{activeMenu.label}

파일 보기

@ -45,24 +45,24 @@ function formatTime(iso: string | null): string {
function StatusCell({ row }: { row: NumericalDataStatus }) {
if (row.lastStatus === 'COMPLETED') {
return <span className="text-emerald-400 text-xs"></span>;
return <span className="text-emerald-400 text-caption"></span>;
}
if (row.lastStatus === 'FAILED') {
return (
<span className="text-red-400 text-xs">
<span className="text-red-400 text-caption">
{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
</span>
);
}
if (row.lastStatus === 'STARTED') {
return (
<span className="inline-flex items-center gap-1 text-cyan-400 text-xs">
<span className="inline-flex items-center gap-1 text-cyan-400 text-caption">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
</span>
);
}
return <span className="text-t3 text-xs">-</span>;
return <span className="text-t3 text-caption">-</span>;
}
function StatusBadge({
@ -76,7 +76,7 @@ function StatusBadge({
}) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
...
</span>
@ -84,7 +84,7 @@ function StatusBadge({
}
if (errorCount === total && total > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
</span>
@ -92,14 +92,14 @@ function StatusBadge({
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</span>
@ -118,7 +118,7 @@ const TABLE_HEADERS = [
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{TABLE_HEADERS.map((h) => (
@ -193,10 +193,10 @@ export default function MonitorForecastPanel() {
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -208,7 +208,7 @@ export default function MonitorForecastPanel() {
<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"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption 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' : ''}`}
@ -234,7 +234,7 @@ export default function MonitorForecastPanel() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-t3 hover:text-t2'
@ -248,7 +248,9 @@ export default function MonitorForecastPanel() {
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
{!loading && totalCount > 0 && <span className="text-xs text-t3"> {totalCount}</span>}
{!loading && totalCount > 0 && (
<span className="text-caption text-t3"> {totalCount}</span>
)}
</div>
{/* 테이블 */}

파일 보기

@ -84,7 +84,7 @@ function StatusBadge({
}) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
...
</span>
@ -92,7 +92,7 @@ function StatusBadge({
}
if (errorCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
</span>
@ -100,14 +100,14 @@ function StatusBadge({
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</span>
@ -130,7 +130,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
@ -172,11 +172,11 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
<span className="text-red-400 text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
<span className="text-emerald-400 text-caption"></span>
) : (
<span className="text-t3 text-xs">-</span>
<span className="text-t3 text-caption">-</span>
)}
</td>
</tr>
@ -201,7 +201,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
@ -241,11 +241,11 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
<span className="text-red-400 text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
<span className="text-emerald-400 text-caption"></span>
) : (
<span className="text-t3 text-xs">-</span>
<span className="text-t3 text-caption">-</span>
)}
</td>
</tr>
@ -261,7 +261,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
@ -294,11 +294,11 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
<span className="text-red-400 text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
<span className="text-emerald-400 text-caption"></span>
) : (
<span className="text-t3 text-xs">-</span>
<span className="text-t3 text-caption">-</span>
)}
</td>
</tr>
@ -441,10 +441,10 @@ export default function MonitorRealtimePanel() {
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -456,7 +456,7 @@ export default function MonitorRealtimePanel() {
<button
onClick={handleRefresh}
disabled={isLoading}
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"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption 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 ${isLoading ? 'animate-spin' : ''}`}
@ -482,7 +482,7 @@ export default function MonitorRealtimePanel() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-t3 hover:text-t2'
@ -496,7 +496,7 @@ export default function MonitorRealtimePanel() {
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
<span className="text-xs text-t3">
<span className="text-caption text-t3">
{activeTab === 'khoa' && `관측소 ${totalCount}`}
{activeTab === 'kma-ultra' && `지점 ${totalCount}`}
{activeTab === 'kma-marine' && `해역 ${totalCount}`}

파일 보기

@ -300,7 +300,7 @@ function StatusBadge({
}) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
...
</span>
@ -309,7 +309,7 @@ function StatusBadge({
const offCount = total - onCount;
if (offCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
OFF
</span>
@ -317,14 +317,14 @@ function StatusBadge({
}
if (offCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
OFF ({offCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</span>
@ -376,7 +376,7 @@ const HEADERS = [
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{HEADERS.map((h) => (
@ -462,10 +462,10 @@ export default function MonitorVesselPanel() {
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -477,7 +477,7 @@ export default function MonitorVesselPanel() {
<button
onClick={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"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption 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' : ''}`}
@ -500,7 +500,7 @@ export default function MonitorVesselPanel() {
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
<span className="text-xs text-t3">
<span className="text-caption text-t3">
{rows.length} (ON: {onCount} / OFF: {rows.length - onCount})
</span>
</div>

파일 보기

@ -557,7 +557,7 @@ function RolePermTab({
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
@ -567,7 +567,7 @@ function RolePermTab({
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
<div className="px-5 py-4 border-b border-stroke">
<h3 className="text-sm font-bold text-fg font-korean"> </h3>
<h3 className="text-body-2 font-bold text-fg font-korean"> </h3>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
@ -581,7 +581,7 @@ function RolePermTab({
setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))
}
placeholder="CUSTOM_ROLE"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
<p className="text-caption text-fg-disabled mt-1 font-korean">
, , ( )
@ -596,7 +596,7 @@ function RolePermTab({
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
placeholder="사용자 정의 역할"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div>
@ -608,7 +608,7 @@ function RolePermTab({
value={newRoleDesc}
onChange={(e) => setNewRoleDesc(e.target.value)}
placeholder="역할에 대한 설명"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{createError && (
@ -620,14 +620,14 @@ function RolePermTab({
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-xs text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
className="px-4 py-2 text-caption text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
>
</button>
<button
onClick={handleCreateRole}
disabled={!newRoleCode || !newRoleName || creating}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
>
{creating ? '생성 중...' : '생성'}
</button>
@ -815,7 +815,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
onFocus={() => setShowDropdown(true)}
placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'}
disabled={loadingUsers}
className="w-full max-w-sm px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50"
className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50"
/>
{showDropdown && filteredUsers.length > 0 && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 overflow-auto max-h-52">
@ -826,7 +826,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
className="w-full px-3 py-2 text-left hover:bg-bg-surface-hover transition-colors flex items-center gap-2"
>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-fg font-korean truncate">
<div className="text-caption font-semibold text-fg font-korean truncate">
{user.name}
{user.rank && (
<span className="ml-1 text-caption text-fg-disabled font-korean">
@ -848,7 +848,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
</div>
)}
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-xs text-fg-disabled font-korean">
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-caption text-fg-disabled font-korean">
</div>
)}
@ -954,13 +954,13 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
@ -1180,7 +1180,7 @@ function PermissionsPanel() {
if (loading) {
return (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
);
@ -1194,7 +1194,7 @@ function PermissionsPanel() {
style={{ flexShrink: 0 }}
>
<div>
<h1 className="text-sm font-bold text-fg font-korean"> </h1>
<h1 className="text-body-2 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
× CRUD
</p>
@ -1203,7 +1203,7 @@ function PermissionsPanel() {
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
<button
onClick={() => setActiveTab('role')}
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
activeTab === 'role'
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-fg-disabled hover:text-fg-sub'
@ -1213,7 +1213,7 @@ function PermissionsPanel() {
</button>
<button
onClick={() => setActiveTab('user')}
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
activeTab === 'user'
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-fg-disabled hover:text-fg-sub'

파일 보기

@ -0,0 +1,638 @@
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>
);
}

파일 보기

@ -0,0 +1,638 @@
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>
);
}

파일 보기

@ -0,0 +1,665 @@
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>
);
}

파일 보기

@ -0,0 +1,638 @@
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>
);
}

파일 보기

@ -136,7 +136,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
<p className="text-xs text-fg-disabled mt-1 font-korean"> {total}</p>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
</div>
<div className="flex items-center gap-2">
@ -146,12 +146,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="레이어코드 / 레이어명 검색"
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<select
value={filterUseYn}
onChange={(e) => setFilterUseYn(e.target.value)}
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""></option>
<option value="Y"></option>
@ -159,7 +159,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
</select>
<button
onClick={handleSearch}
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
@ -168,7 +168,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
{error}
</div>
)}
@ -176,7 +176,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
@ -217,7 +217,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<tr>
<td
colSpan={9}
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
>
.
</td>
@ -228,12 +228,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
key={item.layerCd}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
{item.layerFullNm}
</span>
@ -246,7 +246,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
</td>
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
{item.sortOrd}
</td>
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">

파일 보기

@ -54,7 +54,7 @@ function SettingsPanel() {
if (loading) {
return (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
);
@ -64,7 +64,7 @@ function SettingsPanel() {
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-stroke">
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
</p>
</div>
@ -74,7 +74,7 @@ function SettingsPanel() {
{/* 사용자 등록 설정 */}
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
</p>
@ -140,7 +140,7 @@ function SettingsPanel() {
{/* OAuth 설정 */}
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth </h2>
<h2 className="text-body-2 font-bold text-fg font-korean">Google OAuth </h2>
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
Google
</p>
@ -162,7 +162,7 @@ function SettingsPanel() {
value={oauthDomainInput}
onChange={(e) => setOauthDomainInput(e.target.value)}
placeholder="gcsc.co.kr, example.com"
className="flex-1 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="flex-1 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
<button
onClick={async () => {
@ -183,7 +183,7 @@ function SettingsPanel() {
savingOAuth ||
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
@ -220,7 +220,7 @@ function SettingsPanel() {
{/* 현재 설정 상태 요약 */}
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
<div className="px-5 py-3 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
</div>
<div className="px-5 py-4">
<div className="flex flex-col gap-3 text-label-1 font-korean">

파일 보기

@ -71,7 +71,7 @@ function SortableMenuItem({
<circle cx="9" cy="14" r="1.5" />
</svg>
</button>
<span className="text-fg-disabled text-xs font-mono w-6 text-center shrink-0">
<span className="text-fg-disabled text-caption font-mono w-6 text-center shrink-0">
{idx + 1}
</span>
{isEditing ? (
@ -152,14 +152,14 @@ function SortableMenuItem({
<button
onClick={() => onMove(idx, -1)}
disabled={idx === 0}
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => onMove(idx, 1)}
disabled={idx === totalCount - 1}
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>

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

파일 보기

@ -87,7 +87,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
@ -115,7 +115,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder="로그인 계정 ID"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
@ -129,7 +129,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="초기 비밀번호"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
@ -143,7 +143,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="실명"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
@ -157,7 +157,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장, 주임 등"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
@ -169,7 +169,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
@ -191,7 +191,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 주소"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
@ -217,7 +217,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
onChange={() => toggleRole(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>
<span className="text-caption font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
@ -237,14 +237,14 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{submitting ? '등록 중...' : '등록'}
</button>
@ -332,7 +332,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
</div>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
@ -364,7 +364,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div className="grid grid-cols-2 gap-3">
@ -377,7 +377,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장"
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div>
@ -387,7 +387,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
@ -427,7 +427,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호 입력"
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
<button
@ -535,7 +535,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
<button
onClick={onClose}
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
@ -680,7 +680,7 @@ function UsersPanel() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filteredUsers.length}
</p>
</div>
@ -698,7 +698,7 @@ function UsersPanel() {
setOrgFilter(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
@ -711,7 +711,7 @@ function UsersPanel() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
@ -726,11 +726,11 @@ function UsersPanel() {
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<button
onClick={() => setShowRegisterModal(true)}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
+
</button>
@ -740,7 +740,7 @@ function UsersPanel() {
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
@ -781,7 +781,7 @@ function UsersPanel() {
<tr>
<td
colSpan={9}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
>
.
</td>
@ -897,7 +897,7 @@ function UsersPanel() {
onChange={() => toggleRoleSelection(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>
<span className="text-caption font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-caption text-fg-disabled font-mono">

파일 보기

@ -90,7 +90,7 @@ function VesselMaterialsPanel() {
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filtered.length} ( )
</p>
</div>
@ -98,7 +98,7 @@ function VesselMaterialsPanel() {
<select
value={regionFilter}
onChange={handleFilterChange(setRegionFilter)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value="전체"> </option>
<option value="남해"></option>
@ -110,7 +110,7 @@ function VesselMaterialsPanel() {
<select
value={typeFilter}
onChange={handleFilterChange(setTypeFilter)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value="전체"> </option>
{typeOptions.map((t) => (
@ -127,11 +127,11 @@ function VesselMaterialsPanel() {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<button
onClick={load}
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
>
</button>
@ -141,7 +141,7 @@ function VesselMaterialsPanel() {
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
@ -188,7 +188,7 @@ function VesselMaterialsPanel() {
<tr>
<td
colSpan={11}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
>
.
</td>

파일 보기

@ -142,17 +142,17 @@ export default function VesselSignalPanel() {
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
<h2 className="text-sm font-semibold text-fg"> </h2>
<h2 className="text-body-2 font-semibold text-fg"> </h2>
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg"
/>
<button
onClick={load}
className="px-3 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
>
</button>
@ -163,7 +163,7 @@ export default function VesselSignalPanel() {
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center h-full">
<span className="text-xs text-fg-disabled"> ...</span>
<span className="text-caption text-fg-disabled"> ...</span>
</div>
) : (
<div className="flex gap-2">

파일 보기

@ -15,6 +15,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
{ id: 'system-arch', label: '시스템구조' },
],
},
{
@ -91,6 +92,17 @@ 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: '비식별화조치' },
],
},
];

파일 보기

@ -589,7 +589,7 @@ export function CctvView() {
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
<span
className="w-[7px] h-[7px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-danger)' }}
@ -776,7 +776,7 @@ export function CctvView() {
{/* 뷰어 툴바 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
</div>
{selectedCamera?.sttsCd === 'LIVE' && (
@ -966,7 +966,7 @@ export function CctvView() {
<div key={srcKey} className="mb-5">
{/* 출처 헤더 */}
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-stroke">
<span className="text-sm">{group.icon}</span>
<span className="text-body-2">{group.icon}</span>
<span className="text-label-1 font-bold text-fg font-korean">
{group.label}
</span>

파일 보기

@ -421,7 +421,7 @@ export function MediaManagement() {
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
<div className="text-2xl mb-3">📥</div>
<div className="text-sm font-bold font-korean mb-3"> </div>
<div className="text-body-2 font-bold font-korean mb-3"> </div>
<div className="text-title-4 font-korean text-fg-sub mb-1">
<span className="text-color-accent font-bold">{downloadResult.total}</span>
</div>
@ -441,7 +441,7 @@ export function MediaManagement() {
</div>
<button
onClick={() => setDownloadResult(null)}
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
className="px-6 py-2 text-body-2 font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
>
</button>
@ -475,7 +475,7 @@ export function MediaManagement() {
</div>
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
@ -489,7 +489,7 @@ export function MediaManagement() {
</select>
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
@ -499,7 +499,7 @@ export function MediaManagement() {
</select>
</div>
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<textarea
@ -508,7 +508,7 @@ export function MediaManagement() {
/>
</div>
<button
className="w-full py-3 rounded-sm text-sm font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',

파일 보기

@ -239,7 +239,7 @@ export function OilAreaAnalysis() {
<div className="flex gap-5 h-full overflow-hidden">
{/* ── Left Panel ── */}
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-body-2 font-bold mb-1 font-korean">🧩 </div>
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
.
</div>
@ -256,7 +256,7 @@ export function OilAreaAnalysis() {
<button
onClick={() => fileInputRef.current?.click()}
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
className="w-full py-2 mb-3 border border-dashed border-stroke rounded-sm text-xs font-korean text-fg-sub
className="w-full py-2 mb-3 border border-dashed border-stroke rounded-sm text-caption font-korean text-fg-sub
hover:border-color-accent hover:text-color-accent transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed"
>

파일 보기

@ -177,7 +177,7 @@ export function RealtimeDrone() {
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
<span
className="w-[7px] h-[7px] rounded-full inline-block"
style={{
@ -225,7 +225,7 @@ export function RealtimeDrone() {
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div className="text-body-2">🚁</div>
<div>
<div className="text-label-2 font-normal text-fg font-korean">
{stream.shipName}{' '}
@ -300,7 +300,7 @@ export function RealtimeDrone() {
{/* 툴바 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
</div>
{selectedStream?.status === 'streaming' && (
@ -566,7 +566,7 @@ export function RealtimeDrone() {
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">🚁</span>
<span className="text-body-2">🚁</span>
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-caption text-fg-disabled mb-0.5">
@ -813,7 +813,7 @@ export function RealtimeDrone() {
].map((item, i) => (
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
<div className="text-caption text-fg-disabled font-korean">{item.label}</div>
<div className="text-sm font-mono text-fg">{item.value}</div>
<div className="text-body-2 font-mono text-fg">{item.value}</div>
</div>
))}
</div>

파일 보기

@ -485,7 +485,7 @@ export function SatelliteRequest() {
<div className="flex items-center gap-3 mb-2 h-9">
<div className="flex items-center gap-2 shrink-0">
<div
className="w-7 h-7 rounded-md flex items-center justify-center text-sm"
className="w-7 h-7 rounded-md flex items-center justify-center text-body-2"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
@ -624,7 +624,7 @@ export function SatelliteRequest() {
>
<div className="text-label-2 font-mono text-fg-sub">{r.id}</div>
<div>
<div className="text-xs font-semibold text-fg font-korean">{r.zone}</div>
<div className="text-caption font-semibold text-fg font-korean">{r.zone}</div>
<div className="text-caption text-fg-disabled font-mono mt-0.5">
{r.zoneCoord} · {r.zoneArea}
</div>
@ -776,7 +776,9 @@ export function SatelliteRequest() {
<div className="grid grid-cols-2 gap-3.5">
{/* 가용 위성 현황 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
<div className="text-xs font-bold text-fg font-korean mb-3">🛰 </div>
<div className="text-caption font-bold text-fg font-korean mb-3">
🛰
</div>
<div className="flex flex-col gap-2">
{satellites.map((sat, i) => (
<div
@ -807,7 +809,7 @@ export function SatelliteRequest() {
{/* 오늘 촬영 가능 시간 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
<div className="text-xs font-bold text-fg font-korean mb-3">
<div className="text-caption font-bold text-fg font-korean mb-3">
(KST)
</div>
<div className="flex flex-col gap-1.5">
@ -1133,7 +1135,7 @@ export function SatelliteRequest() {
)}
<button
onClick={() => setMapSelectedItem(null)}
className="text-fg-disabled bg-transparent border-none cursor-pointer text-sm"
className="text-fg-disabled bg-transparent border-none cursor-pointer text-body-2"
>
</button>
@ -1213,7 +1215,7 @@ export function SatelliteRequest() {
</span>
</div>
<div>
<div className="text-sm font-bold text-fg font-korean">BlackSky</div>
<div className="text-body-2 font-bold text-fg font-korean">BlackSky</div>
<div className="text-caption text-fg-disabled font-korean mt-px">
Maxar Electro-Optical API
</div>
@ -1274,7 +1276,7 @@ export function SatelliteRequest() {
</span>
</div>
<div>
<div className="text-sm font-bold text-fg font-korean">
<div className="text-body-2 font-bold text-fg font-korean">
UP42 EO + SAR
</div>
<div className="text-caption text-fg-disabled font-korean mt-px">
@ -1703,13 +1705,13 @@ export function SatelliteRequest() {
</div>
<button
onClick={() => setModalPhase('provider')}
className="px-5 py-2.5 rounded-lg border border-stroke text-xs font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated"
className="px-5 py-2.5 rounded-lg border border-stroke text-caption font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated"
>
</button>
<button
onClick={() => setModalPhase('none')}
className="px-7 py-2.5 rounded-lg text-xs font-bold cursor-pointer font-korean text-color-accent"
className="px-7 py-2.5 rounded-lg text-caption font-bold cursor-pointer font-korean text-color-accent"
style={{
background: 'rgba(6,182,212,0.08)',
}}
@ -2098,7 +2100,7 @@ export function SatelliteRequest() {
{urgency}
</span>
{up42SelPass === pass.id && (
<span className="text-xs text-fg"></span>
<span className="text-caption text-fg"></span>
)}
</div>
);

파일 보기

@ -391,7 +391,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-color-accent/40 text-xs font-mono animate-pulse">
<div className="text-color-accent/40 text-caption font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
@ -712,7 +712,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-color-danger/40 text-xs font-mono animate-pulse">
<div className="text-color-danger/40 text-caption font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
@ -818,7 +818,7 @@ export function SensorAnalysis() {
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'var(--bg-base)' }}
>
<div className="text-fg-disabled/10 text-xs font-mono">
<div className="text-fg-disabled/10 text-caption font-mono">
{src.label.split(' ')[0]}
</div>
</div>
@ -916,7 +916,7 @@ export function SensorAnalysis() {
{ value: '0.023m', label: 'RMS오차' },
].map((s, i) => (
<div key={i} className="text-center">
<div className="font-mono font-bold text-sm text-color-accent">{s.value}</div>
<div className="font-mono font-bold text-body-2 text-color-accent">{s.value}</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
</div>
))}

파일 보기

@ -47,7 +47,7 @@ export function WingAI() {
<div className="flex items-center gap-3 mb-4 h-9">
<div className="flex items-center gap-2 shrink-0">
<div
className="w-7 h-7 rounded-md flex items-center justify-center text-sm"
className="w-7 h-7 rounded-md flex items-center justify-center text-body-2"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
@ -1757,8 +1757,8 @@ function AoiPanel() {
}
}
>
<span className="text-sm shrink-0">{active ? '◉' : '○'}</span>
<span className="text-sm shrink-0">{src.icon}</span>
<span className="text-body-2 shrink-0">{active ? '◉' : '○'}</span>
<span className="text-body-2 shrink-0">{src.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-caption font-bold font-korean">
{src.label}

파일 보기

@ -83,7 +83,9 @@ function AssetManagement() {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-fg-disabled text-sm font-korean"> ...</div>
<div className="text-fg-disabled text-body-2 font-korean">
...
</div>
</div>
);
}
@ -415,7 +417,7 @@ function AssetManagement() {
<aside className="w-[340px] min-w-[340px] bg-bg-surface border-l border-stroke flex flex-col">
{/* Header */}
<div className="p-4 border-b border-stroke">
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
<div className="text-body-2 font-bold mb-1 font-korean">{selectedOrg.name}</div>
<div className="text-label-2 text-fg-sub font-semibold font-korean mb-1">
{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}
</div>
@ -505,7 +507,7 @@ function AssetManagement() {
);
})
) : (
<div className="text-center text-fg-disabled text-xs py-8 font-korean">
<div className="text-center text-fg-disabled text-caption py-8 font-korean">
.
</div>
)}
@ -601,10 +603,10 @@ function AssetManagement() {
{/* Bottom Actions */}
{/* <div className="p-3.5 border-t border-stroke flex gap-2">
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
<button className="flex-1 py-2.5 rounded-sm text-caption font-semibold font-korean text-white bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
📥
</button>
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
<button className="flex-1 py-2.5 rounded-sm text-caption font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
</button>
</div> */}

파일 보기

@ -27,7 +27,7 @@ function AssetUpload() {
{/* Drop Zone */}
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
<div className="text-4xl mb-2.5 opacity-50">📁</div>
<div className="text-sm font-semibold mb-1.5 font-korean">
<div className="text-body-2 font-semibold mb-1.5 font-korean">
</div>
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
@ -43,7 +43,7 @@ function AssetUpload() {
{/* Asset Classification */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
@ -58,7 +58,7 @@ function AssetUpload() {
{/* Jurisdiction */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
@ -74,10 +74,10 @@ function AssetUpload() {
{/* Upload Mode */}
<div className="mb-5">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<div className="flex gap-4 text-xs text-fg-sub font-korean">
<div className="flex gap-4 text-caption text-fg-sub font-korean">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
@ -102,7 +102,7 @@ function AssetUpload() {
{/* Upload Button */}
<button
onClick={handleUpload}
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
className={`w-full py-3.5 rounded-sm text-body-2 font-bold font-korean border-none cursor-pointer transition-all ${
uploaded
? 'bg-[rgba(34,197,94,0.2)] text-color-success border border-status-green'
: 'text-white'
@ -163,7 +163,7 @@ function AssetUpload() {
{p.icon}
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className={`text-caption font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-caption text-fg-disabled font-korean">{p.desc}</div>
</div>
</div>
@ -179,7 +179,7 @@ function AssetUpload() {
className="flex justify-between items-center p-3.5 px-4 bg-bg-card border border-stroke rounded-sm"
>
<div>
<div className="text-xs font-semibold font-korean">{h.fileNm}</div>
<div className="text-caption font-semibold font-korean">{h.fileNm}</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}
</div>

파일 보기

@ -27,10 +27,8 @@ export function AssetsView() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3.5 text-xs font-semibold transition-all font-korean border-b-2 ${
activeTab === tab.id
? 'text-color-accent border-color-accent'
: 'text-fg-disabled border-transparent hover:text-fg-sub'
className={`px-4 py-2.5 text-title-4 font-medium transition-all duration-200 font-korean tracking-navigation ${
activeTab === tab.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'
}`}
>
{tab.label}

파일 보기

@ -167,7 +167,7 @@ function ShipInsurance() {
{total > 0 ? `${total.toLocaleString()}` : '데이터 없음'}
</div>
</div>
<div className="text-xs text-fg-disabled">
<div className="text-caption text-fg-disabled">
</div>
</div>
@ -200,7 +200,7 @@ function ShipInsurance() {
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="선박명, 호출부호, IMO, 선주명"
className="w-full px-3.5 py-2 bg-bg-base border border-stroke rounded-sm text-xs outline-none box-border"
className="w-full px-3.5 py-2 bg-bg-base border border-stroke rounded-sm text-caption outline-none box-border"
/>
</div>
<div>
@ -243,20 +243,20 @@ function ShipInsurance() {
</div>
<button
onClick={handleSearch}
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-caption cursor-pointer"
>
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-caption cursor-pointer"
>
</button>
<button
onClick={handleDownload}
disabled={total === 0}
className="px-4 py-2 text-xs cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
className="px-4 py-2 text-caption cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
>
</button>
@ -281,8 +281,8 @@ function ShipInsurance() {
{/* 에러 */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-card border border-stroke rounded-md">
<div className="text-sm font-bold text-color-danger mb-2"> </div>
<div className="text-xs text-fg-disabled">{error}</div>
<div className="text-body-2 font-bold text-color-danger mb-2"> </div>
<div className="text-caption text-fg-disabled">{error}</div>
</div>
)}
@ -291,7 +291,7 @@ function ShipInsurance() {
<>
<div className="border border-stroke rounded-md overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
<div className="text-xs font-bold">
<div className="text-caption font-bold">
<span className="text-color-accent">{total.toLocaleString()}</span>
{totalPages > 1 && (
<span className="text-fg-disabled font-normal ml-2">

파일 보기

@ -219,7 +219,7 @@ export function HNSLeftPanel({
};
return (
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
<div className="w-full min-w-0 flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
{/* Scrollable Content */}
<div
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-base"
@ -799,15 +799,15 @@ export function HNSLeftPanel({
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
<span className="text-label-2 text-fg-disabled"> </span>
<span className="text-sm font-bold text-color-accent font-mono">8</span>
<span className="text-body-2 font-bold text-color-accent font-mono">8</span>
</div>
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
<span className="text-label-2 text-fg-disabled"> (AEGL-3)</span>
<span className="text-sm font-bold text-color-caution font-mono">3</span>
<span className="text-body-2 font-bold text-color-caution font-mono">3</span>
</div>
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
<span className="text-label-2 text-fg-disabled"> (AEGL-2)</span>
<span className="text-sm font-bold text-color-accent font-mono">5</span>
<span className="text-body-2 font-bold text-color-accent font-mono">5</span>
</div>
</div>
</div>

파일 보기

@ -35,8 +35,8 @@ export function HNSRightPanel({
}: HNSRightPanelProps) {
if (!dispersionResult) {
return (
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto">
<div className="flex flex-col gap-3 items-center justify-center h-full text-fg-disabled text-label-1">
<div className="w-full h-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex items-center justify-center">
<div className="flex flex-col gap-3 items-center text-fg-disabled text-label-1">
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
<div> </div>
</div>
@ -58,7 +58,7 @@ export function HNSRightPanel({
: 'ALOHA';
return (
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
<div className="w-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
{/* Header */}
<div>
<div className="flex items-center gap-1.5 mb-2">

파일 보기

@ -792,7 +792,7 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">
<span className="text-body-2 font-mono font-extrabold text-color-accent">
NH
</span>{' '}
<span className="text-label-1 font-bold"></span>
@ -891,7 +891,7 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">
<span className="text-body-2 font-mono font-extrabold text-color-accent">
CHOH
</span>{' '}
<span className="text-label-1 font-bold"></span>
@ -990,7 +990,9 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">H</span>{' '}
<span className="text-body-2 font-mono font-extrabold text-color-accent">
H
</span>{' '}
<span className="text-label-1 font-bold"></span>
</div>
<div className="flex gap-1">
@ -1048,7 +1050,7 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">
<span className="text-body-2 font-mono font-extrabold text-color-accent">
CH
</span>{' '}
<span className="text-label-1 font-bold">LNG ()</span>
@ -1106,7 +1108,7 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">
<span className="text-body-2 font-mono font-extrabold text-color-accent">
CHOH
</span>{' '}
<span className="text-label-1 font-bold"></span>
@ -1164,7 +1166,7 @@ ${styles}
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
<div className="flex items-center justify-between mb-[10px]">
<div>
<span className="text-sm font-mono font-extrabold text-color-accent">
<span className="text-body-2 font-mono font-extrabold text-color-accent">
CH
</span>{' '}
<span className="text-label-1 font-bold"></span>

파일 보기

@ -262,6 +262,8 @@ function DispersionTimeSlider({
export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
const { user } = useAuthStore();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
@ -890,6 +892,7 @@ export function HNSView() {
<div className="flex flex-1 overflow-hidden">
{/* Left Panel - 분석 목록일 때는 숨김 */}
{activeSubTab === 'analysis' && (
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
<HNSLeftPanel
activeSubTab={activeSubTab}
onSubTabChange={setActiveSubTab}
@ -902,10 +905,53 @@ export function HNSView() {
onReset={handleReset}
loadedParams={loadedParams}
/>
</div>
)}
{/* Center - Map/Content Area */}
<div className="flex-1 relative overflow-hidden">
{/* Left panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setLeftCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
left: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{leftCollapsed ? '▶' : '◀'}
</button>
)}
{/* Right panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setRightCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
right: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRight: 'none',
borderRadius: '6px 0 0 6px',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{rightCollapsed ? '◀' : '▶'}
</button>
)}
{activeSubTab === 'list' ? (
<HNSAnalysisListTable
onTabChange={(v) =>
@ -942,6 +988,7 @@ export function HNSView() {
{/* Right Panel - 분석 목록일 때는 숨김 */}
{activeSubTab === 'analysis' && (
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
<HNSRightPanel
dispersionResult={dispersionResult}
computedResult={computedResult}
@ -950,6 +997,7 @@ export function HNSView() {
onOpenReport={handleOpenReport}
onSave={handleSave}
/>
</div>
)}
{/* HNS 재계산 모달 */}

파일 보기

@ -1,4 +1,7 @@
import { useState } from 'react';
import { DndContext } from '@dnd-kit/core';
import { useDraggable } from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
/**
* 22
@ -100,33 +103,30 @@ const RULES: DischargeRule[] = [
];
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b'];
const ZONE_COLORS = [
'var(--color-danger)',
'var(--color-warning)',
'var(--color-caution)',
'var(--color-success)',
'var(--fg-disabled)',
];
function StatusBadge({ status }: { status: Status }) {
if (status === 'forbidden')
return (
<span
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
className="text-caption px-1.5 py-0.5 rounded"
style={{
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
color: 'var(--color-danger)',
}}
>
</span>
);
if (status === 'allowed')
return (
<span
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
>
</span>
);
return (
<span
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
>
<span className="text-caption px-1.5 py-0.5 rounded text-fg-sub">
{status === 'allowed' ? '배출가능' : '조건부'}
</span>
);
}
@ -139,15 +139,36 @@ interface DischargeZonePanelProps {
onClose: () => void;
}
export function DischargeZonePanel({
export function DischargeZonePanel(props: DischargeZonePanelProps) {
const [offset, setOffset] = useState({ x: 0, y: 0 });
function handleDragEnd(event: DragEndEvent) {
setOffset((prev) => ({ x: prev.x + event.delta.x, y: prev.y + event.delta.y }));
}
return (
<DndContext onDragEnd={handleDragEnd}>
<DraggablePanel {...props} offset={offset} />
</DndContext>
);
}
function DraggablePanel({
lat,
lon,
distanceNm,
zoneIndex,
onClose,
}: DischargeZonePanelProps) {
offset,
}: DischargeZonePanelProps & { offset: { x: number; y: number } }) {
const zoneIdx = zoneIndex;
const [expandedCat, setExpandedCat] = useState<string | null>(null);
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: 'discharge-panel',
});
const tx = offset.x + (transform?.x ?? 0);
const ty = offset.y + (transform?.y ?? 0);
const categories = [...new Set(RULES.map((r) => r.category))];
@ -161,22 +182,33 @@ export function DischargeZonePanel({
border: '1px solid var(--stroke-default)',
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
backdropFilter: 'blur(12px)',
transform: `translate(${tx}px, ${ty}px)`,
}}
>
{/* Header */}
{/* Header — drag handle */}
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className="shrink-0 flex items-center justify-between"
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--stroke-default)',
background: 'var(--bg-elevated)',
cursor: 'grab',
userSelect: 'none',
}}
>
<div>
<div className="text-label-2 font-bold text-fg font-korean">🚢 </div>
<div className="text-label-2 text-fg font-korean">🚢 </div>
<div className="text-caption text-fg-sub font-korean"> 22</div>
</div>
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-sub hover:text-fg">
<span
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
className="text-title-3 cursor-pointer text-fg-sub hover:text-fg"
style={{ pointerEvents: 'all' }}
>
</span>
</div>
@ -194,10 +226,7 @@ export function DischargeZonePanel({
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-caption text-fg-sub font-korean"> </span>
<span
className="text-label-2 font-bold font-mono"
style={{ color: ZONE_COLORS[zoneIdx] }}
>
<span className="text-label-2 font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
{distanceNm.toFixed(1)} NM
</span>
</div>
@ -206,12 +235,10 @@ export function DischargeZonePanel({
{ZONE_LABELS.map((label, i) => (
<div
key={label}
className="flex-1 text-center rounded-sm"
className="flex-1 text-center rounded-sm text-[10px]"
style={{
padding: '3px 0',
fontSize: 8,
fontWeight: i === zoneIdx ? 700 : 400,
color: i === zoneIdx ? 'var(--fg-default)' : 'var(--fg-sub)',
padding: '2px 0',
color: i === zoneIdx ? '#000' : 'var(--fg-sub)',
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
}}
@ -231,8 +258,7 @@ export function DischargeZonePanel({
const catRules = RULES.filter((r) => r.category === cat);
const isExpanded = expandedCat === cat;
const allForbidden = catRules.every((r) => r.zones[zoneIdx] === 'forbidden');
const allAllowed = catRules.every((r) => r.zones[zoneIdx] === 'allowed');
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308';
const summaryColor = allForbidden ? 'var(--color-danger)' : 'var(--fg-sub)';
return (
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
@ -245,11 +271,11 @@ export function DischargeZonePanel({
<div
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
/>
<span className="text-caption font-bold text-fg font-korean">{cat}</span>
<span className="text-caption text-fg font-korean">{cat}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-caption font-semibold" style={{ color: summaryColor }}>
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
<span className="text-caption" style={{ color: summaryColor }}>
{allForbidden ? '전체 불가' : '허용'}
</span>
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
</div>
@ -268,7 +294,7 @@ export function DischargeZonePanel({
borderRadius: 4,
}}
>
<span className="text-caption text-fg font-korean">{rule.item}</span>
<span className="text-caption text-fg-sub font-korean">{rule.item}</span>
<StatusBadge status={rule.zones[zoneIdx]} />
</div>
))}

파일 보기

@ -23,10 +23,10 @@ export function IncidentTable() {
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
<div>
<h1 className="text-xl font-bold text-fg"> </h1>
<p className="text-sm text-fg-disabled mt-1"> {filteredIncidents.length}</p>
<p className="text-body-2 text-fg-disabled mt-1"> {filteredIncidents.length}</p>
</div>
<div className="flex items-center gap-3">
<button className="px-4 py-2 text-sm font-semibold border border-stroke rounded-md bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all">
<button className="px-4 py-2 text-body-2 font-semibold border border-stroke rounded-md bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all">
</button>
<div className="relative">
@ -35,10 +35,10 @@ export function IncidentTable() {
placeholder="검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
className="w-64 px-4 py-2 text-body-2 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
/>
</div>
<button className="px-4 py-2 text-sm font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
<button className="px-4 py-2 text-body-2 font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
+
</button>
</div>
@ -49,31 +49,31 @@ export function IncidentTable() {
<table className="w-full">
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-right text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-right text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
</th>
</tr>
@ -84,28 +84,28 @@ export function IncidentTable() {
key={incident.acdntSn}
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
>
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.acdntSn}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">{incident.acdntSn}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
<span className="text-sm font-semibold text-fg group-hover:text-color-accent transition-colors">
<span className="text-body-2 font-semibold text-fg group-hover:text-color-accent transition-colors">
{incident.acdntNm}
</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.occrnDtm}</td>
<td className="px-4 py-3 text-sm text-fg-sub">{incident.vesselTp ?? '—'}</td>
<td className="px-4 py-3 text-sm text-fg-sub">{incident.oilTpCd ?? '—'}</td>
<td className="px-4 py-3 text-sm text-fg font-mono text-right font-semibold">
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">{incident.occrnDtm}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.vesselTp ?? '—'}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.oilTpCd ?? '—'}</td>
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-semibold">
{incident.spilQty != null ? incident.spilQty.toFixed(2) : '—'}
</td>
<td className="px-4 py-3 text-sm text-fg-sub">{incident.acdntTpCd}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.acdntTpCd}</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
<span className="px-2 py-1 text-caption font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
{incident.phaseCd}
</span>
</td>
<td className="px-4 py-3 text-sm text-fg-sub">{incident.analystNm ?? '—'}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.analystNm ?? '—'}</td>
</tr>
))}
</tbody>

파일 보기

@ -215,7 +215,7 @@ export function IncidentsLeftPanel({
{/* Search */}
<div className="px-4 py-3 border-b border-stroke shrink-0">
<div className="relative">
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-xs">🔍</span>
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-caption">🔍</span>
<input
type="text"
placeholder="사고명, 선박명 검색..."
@ -224,7 +224,7 @@ export function IncidentsLeftPanel({
setSearchTerm(e.target.value);
resetPage();
}}
className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-xs outline-none"
className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-caption outline-none"
style={{ borderRadius: 'var(--radius-sm)' }}
/>
</div>
@ -257,11 +257,11 @@ export function IncidentsLeftPanel({
/>
<button
onClick={resetPage}
className="text-label-2 font-semibold cursor-pointer whitespace-nowrap text-white border-none"
className="rounded-sm text-label-2 font-semibold cursor-pointer whitespace-nowrap text-color-accent"
style={{
padding: '5px 12px',
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
@ -356,7 +356,7 @@ export function IncidentsLeftPanel({
setSelectedStatus(s.id);
resetPage();
}}
className="flex items-center gap-1 text-caption font-semibold cursor-pointer"
className="flex items-center gap-1 text-caption cursor-pointer"
style={{
padding: '4px 10px',
borderRadius: '12px',
@ -442,7 +442,7 @@ export function IncidentsLeftPanel({
>
{/* Row 1: name + status */}
<div className="flex items-center justify-between mb-[5px]">
<div className="flex items-center gap-1.5 text-xs font-bold">
<div className="flex items-center gap-1.5 text-caption">
<span
className="shrink-0"
style={{
@ -456,7 +456,7 @@ export function IncidentsLeftPanel({
{inc.name}
</div>
<span
className="shrink-0 text-caption font-semibold"
className="shrink-0 text-caption"
style={{
padding: '2px 10px',
borderRadius: '10px',
@ -470,9 +470,9 @@ export function IncidentsLeftPanel({
{/* Row 2: meta */}
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
<span>
📅 {inc.date} {inc.time}
{inc.date} {inc.time}
</span>
<span>🏛 {inc.office}</span>
<span> {inc.office}</span>
</div>
{/* Row 3: tags + buttons */}
<div className="flex items-center justify-between">
@ -492,12 +492,12 @@ export function IncidentsLeftPanel({
)}
{inc.oilType && (
<span
className="text-caption font-medium text-color-warning"
className="text-caption font-medium text-fg-sub"
style={{
padding: '2px 8px',
borderRadius: '3px',
background: 'rgba(249,115,22,0.08)',
border: '1px solid rgba(249,115,22,0.2)',
background: 'rgba(100,116,139,0.08)',
border: '1px solid rgba(100,116,139,0.2)',
}}
>
{inc.oilType}
@ -505,12 +505,12 @@ export function IncidentsLeftPanel({
)}
{inc.prediction && (
<span
className="text-caption font-medium text-color-success"
className="text-caption font-medium text-fg-sub"
style={{
padding: '2px 8px',
borderRadius: '3px',
background: 'rgba(34,197,94,0.08)',
border: '1px solid rgba(34,197,94,0.2)',
background: 'rgba(100,116,139,0.08)',
border: '1px solid rgba(100,116,139,0.2)',
}}
>
{inc.prediction}
@ -535,7 +535,7 @@ export function IncidentsLeftPanel({
transition: '0.15s',
}}
>
🌤
</button>
{(inc.mediaCount ?? 0) > 0 && (
<button
@ -555,7 +555,7 @@ export function IncidentsLeftPanel({
transition: '0.15s',
}}
>
📹 <span className="text-caption">{inc.mediaCount}</span>
<span className="text-caption">{inc.mediaCount}</span>
</button>
)}
{inc.hasImgAnalysis && (
@ -720,13 +720,13 @@ const WeatherPopup = forwardRef<
}}
>
<div className="flex items-center gap-1.5">
<span className="text-sm">🌤</span>
<span className="text-body-2">🌤</span>
<div>
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
</div>
</div>
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-body-2 p-0.5">
</span>
</div>
@ -761,7 +761,7 @@ const WeatherPopup = forwardRef<
border: '1px solid rgba(59,130,246,0.1)',
}}
>
<span className="text-xs"></span>
<span className="text-caption"></span>
<div>
<div className="text-fg-disabled text-caption"> ()</div>
<div className="font-bold font-mono text-caption text-color-info">
@ -773,7 +773,7 @@ const WeatherPopup = forwardRef<
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}
>
<span className="text-xs"></span>
<span className="text-caption"></span>
<div>
<div className="text-fg-disabled text-caption"> ()</div>
<div className="text-color-accent font-bold font-mono text-caption">
@ -791,7 +791,7 @@ const WeatherPopup = forwardRef<
{forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="text-caption my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}

파일 보기

@ -50,31 +50,8 @@ interface AnalysisItem {
checked: boolean;
}
/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */
const CATEGORY_PALETTE: [number, number, number][] = [
[239, 68, 68], // red
[249, 115, 22], // orange
[234, 179, 8], // yellow
[132, 204, 22], // lime
[20, 184, 166], // teal
[6, 182, 212], // cyan
[59, 130, 246], // blue
[99, 102, 241], // indigo
[168, 85, 247], // purple
[236, 72, 153], // pink
[244, 63, 94], // rose
[16, 185, 129], // emerald
[14, 165, 233], // sky
[139, 92, 246], // violet
[217, 119, 6], // amber
[45, 212, 191], // turquoise
];
function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length];
}
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반, 미사용 보존) ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const CATEGORY_ICON: Record<string, string> = {
: '🐟',
: '🦪',
@ -140,8 +117,20 @@ function getActiveModels(p: PredictionAnalysis): string {
/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */
const STATIC_SECTIONS = [
{ key: 'hns', icon: '🧪', title: 'HNS 대기확산', color: '#a855f7', colorRgb: '168,85,247' },
{ key: 'rsc', icon: '🚨', title: '긴급구난', color: '#06b6d4', colorRgb: '6,182,212' },
{
key: 'hns',
icon: '🧪',
title: 'HNS 대기확산',
color: 'var(--color-accent)',
colorRgb: '6,182,212',
},
{
key: 'rsc',
icon: '🚨',
title: '긴급구난',
color: 'var(--color-accent)',
colorRgb: '6,182,212',
},
];
/* ── Component ───────────────────────────────────── */
@ -292,7 +281,7 @@ export function IncidentsRightPanel({
key: 'oil',
icon: '🛢',
title: '유출유 확산예측',
color: '#f97316',
color: 'var(--color-accent)',
colorRgb: '249,115,22',
totalLabel: `전체 ${predItems.length}`,
items: predItems.map((p) => {
@ -310,7 +299,7 @@ export function IncidentsRightPanel({
if (!incident) {
return (
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
<div className="text-center text-fg-disabled text-label-2">
<div className="text-[32px] mb-2 opacity-30">📊</div>
@ -325,9 +314,9 @@ export function IncidentsRightPanel({
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
{/* Header */}
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
<div className="text-xs font-bold mb-0.5">🔬 </div>
<div className="text-caption font-bold mb-0.5">🔬 </div>
<div className="text-caption text-fg-disabled">
: <b className="text-color-accent">{incident.name}</b>
: <span className="text-fg-disabled">{incident.name}</span>
</div>
</div>
@ -344,22 +333,19 @@ export function IncidentsRightPanel({
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">{sec.icon}</span>
<span className="text-xs font-bold" style={{ color: sec.color }}>
{sec.title}
</span>
{/* <span className="text-body-2">{sec.icon}</span> */}
<span className="text-caption">{sec.title}</span>
</div>
<button
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
background: `rgba(${sec.colorRgb},0.1)`,
border: `1px solid rgba(${sec.colorRgb},0.25)`,
border: '1px solid var(--stroke-default)',
color: sec.color,
}}
>
📋
</button>
</div>
<div className="flex flex-col gap-1">
@ -374,8 +360,7 @@ export function IncidentsRightPanel({
className="flex items-center gap-1.5"
style={{
padding: '5px 8px',
background: `rgba(${sec.colorRgb},0.06)`,
border: `1px solid rgba(${sec.colorRgb},0.15)`,
border: '1px solid var(--stroke-default)',
borderRadius: '4px',
}}
>
@ -415,22 +400,19 @@ export function IncidentsRightPanel({
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">{sec.icon}</span>
<span className="text-xs font-bold" style={{ color: sec.color }}>
{sec.title}
</span>
{/* <span className="text-body-2">{sec.icon}</span> */}
<span className="text-caption">{sec.title}</span>
</div>
<button
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
background: `rgba(${sec.colorRgb},0.1)`,
border: `1px solid rgba(${sec.colorRgb},0.25)`,
border: '1px solid var(--stroke-default)',
color: sec.color,
}}
>
📋
</button>
</div>
<div className="text-caption text-fg-disabled text-center py-1.5"> </div>
@ -443,8 +425,8 @@ export function IncidentsRightPanel({
{/* 민감자원 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">🐟</span>
<span className="text-xs font-bold text-color-success"></span>
{/* <span className="text-body-2">🐟</span> */}
<span className="text-caption"></span>
</div>
<div className="flex flex-col gap-[3px]">
{sensCategories.length === 0 ? (
@ -452,38 +434,33 @@ export function IncidentsRightPanel({
</div>
) : (
sensCategories.map((cat, i) => {
const icon = CATEGORY_ICON[cat.category] ?? '🌊';
sensCategories.map((cat) => {
const areaLabel =
cat.totalArea != null
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
: `${cat.count}개소`;
const [r, g, b] = getCategoryColor(i);
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return (
<label
key={cat.category}
className="flex items-center cursor-pointer text-caption gap-[5px] rounded-[3px]"
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
className="flex items-center cursor-pointer text-caption gap-[5px]"
style={{ padding: '3px 0' }}
>
<input
type="checkbox"
checked={checkedSensCategories.has(cat.category)}
onChange={() => toggleSensCategory(cat.category)}
style={{ accentColor: hex }}
style={{ accentColor: 'var(--color-accent)' }}
/>
<span
style={{
width: 8,
height: 8,
width: 6,
height: 6,
borderRadius: '50%',
background: hex,
background: 'var(--color-accent)',
flexShrink: 0,
display: 'inline-block',
border: `1px solid rgba(${r},${g},${b},0.45)`,
}}
/>
<span>{icon}</span>
<span className="flex-1">{cat.category}</span>
<span className="text-fg-disabled font-mono shrink-0">({areaLabel})</span>
</label>
@ -496,10 +473,9 @@ export function IncidentsRightPanel({
{/* 근처 방제자원 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">🛡</span>
<span className="text-xs font-bold text-color-boom"> </span>
<span className="text-caption font-bold text-color-accent"> </span>
{nearbyOrgs.length > 0 && (
<span className="ml-auto text-caption font-mono text-color-boom">
<span className="ml-auto text-caption font-mono text-color-accent">
{nearbyOrgs.length}
</span>
)}
@ -519,21 +495,18 @@ export function IncidentsRightPanel({
</div>
) : (
<div className="flex flex-col gap-[3px] max-h-[200px] overflow-y-auto">
<div className="flex flex-col max-h-[200px] overflow-y-auto">
{nearbyOrgs.map((org) => (
<div
key={org.orgSn}
className="flex items-start gap-1.5 rounded-[3px] px-[6px] py-[5px]"
style={{
background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.08)',
}}
className="flex items-start gap-1.5 px-[2px] py-[5px]"
style={{ borderBottom: '1px solid var(--stroke-default)' }}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 mb-[2px]">
<span
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0 text-color-accent"
style={{ background: 'rgba(6,182,212,0.1)' }}
>
{org.orgTp}
</span>
@ -544,7 +517,7 @@ export function IncidentsRightPanel({
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}` : ''}
</div>
</div>
<span className="text-caption font-mono text-color-boom shrink-0">
<span className="text-caption font-mono text-color-accent shrink-0">
{org.distanceNm.toFixed(1)} nm
</span>
</div>
@ -553,10 +526,10 @@ export function IncidentsRightPanel({
)}
{/* Radius slider */}
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--stroke-default)' }}>
<div className="flex items-center justify-between mb-[5px]">
<span className="text-caption text-fg-disabled"> </span>
<span className="text-caption font-bold font-mono text-color-boom">
<span className="text-caption font-bold font-mono text-color-accent">
{nearbyRadius} nm
</span>
</div>
@ -572,7 +545,7 @@ export function IncidentsRightPanel({
height: '4px',
background: 'var(--stroke-default)',
borderRadius: '2px',
accentColor: '#f59e0b',
accentColor: 'var(--color-accent)',
}}
/>
</div>
@ -605,7 +578,8 @@ export function IncidentsRightPanel({
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
{v.icon} {v.label}
{/* {v.icon} */}
{v.label}
</button>
);
})}
@ -627,9 +601,7 @@ export function IncidentsRightPanel({
className="w-full text-label-2 font-bold cursor-pointer"
style={{
padding: '8px',
background: analysisActive
? 'linear-gradient(135deg,rgba(239,68,68,0.15),rgba(239,68,68,0.1))'
: 'linear-gradient(135deg,rgba(6,182,212,0.15),rgba(59,130,246,0.1))',
background: analysisActive ? 'rgba(239,68,68,0.1)' : 'rgba(6,182,212,0.1)',
border: analysisActive
? '1px solid rgba(239,68,68,0.3)'
: '1px solid rgba(6,182,212,0.3)',

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

파일 보기

@ -112,7 +112,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
<button
key={page}
onClick={() => setCurrentPage(page as number)}
className={`px-3 py-1 text-title-3 font-medium rounded transition-colors ${
className={`px-3 py-1 text-body-2 font-medium rounded transition-colors ${
currentPage === page ? 'bg-color-accent text-bg-0' : 'text-fg-sub hover:bg-bg-elevated'
}`}
>
@ -127,8 +127,8 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
<div>
<h1 className="text-heading-3 font-bold text-fg"> </h1>
<p className="text-title-3 text-fg-disabled mt-1"> {analyses.length}</p>
<h1 className="text-heading-3 text-fg"> </h1>
<p className="text-body-2 text-fg-disabled mt-1"> {analyses.length}</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
@ -137,12 +137,12 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
placeholder="검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 px-4 py-2 text-title-3 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
className="w-64 px-4 py-2 text-body-2 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
/>
</div>
<button
onClick={() => onTabChange('analysis')}
className="px-4 py-2 text-title-3 font-semibold rounded-sm cursor-pointer text-color-accent"
className="px-4 py-2 text-body-2 font-semibold rounded-sm cursor-pointer text-color-accent"
style={{
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
@ -156,7 +156,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center py-20 text-fg-disabled text-title-3"> ...</div>
<div className="text-center py-20 text-fg-disabled text-body-2"> ...</div>
) : (
<table className="w-full">
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
@ -208,14 +208,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
key={analysis.predRunSn ?? analysis.acdntSn}
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
>
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
{analysis.acdntSn}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
<span
className="text-title-3 font-medium text-color-accent hover:underline transition-all cursor-pointer"
className="text-body-2 font-medium text-color-accent hover:underline transition-all cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onSelectAnalysis) {
@ -227,7 +227,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
</span>
</div>
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
{analysis.occurredAt
? new Date(analysis.occurredAt).toLocaleString('ko-KR', {
month: '2-digit',
@ -237,7 +237,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
})
: '—'}
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
{analysis.runDtm
? new Date(analysis.runDtm).toLocaleString('ko-KR', {
month: '2-digit',
@ -247,11 +247,11 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
})
: '—'}
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
{analysis.duration}
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.oilType}</td>
<td className="px-4 py-3 text-title-3 text-fg font-mono text-right font-medium">
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.oilType}</td>
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-medium">
{analysis.volume != null
? analysis.volume >= 0.01
? analysis.volume.toFixed(2)
@ -268,8 +268,8 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
<td className="px-4 py-3 text-center">
{getStatusBadge(analysis.backtrackStatus)}
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.analyst}</td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.officeName}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.analyst}</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.officeName}</td>
</tr>
))}
</tbody>
@ -277,7 +277,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
)}
{!loading && analyses.length === 0 && (
<div className="text-center py-20 text-fg-disabled text-title-3">
<div className="text-center py-20 text-fg-disabled text-body-2">
.
</div>
)}
@ -288,14 +288,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
@ -305,14 +305,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
<button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>

파일 보기

@ -68,7 +68,7 @@ export function BacktrackModal({
border: '1px solid var(--bd)',
borderRadius: '6px',
color: 'var(--t1)',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
fontFamily: 'var(--fM)',
outline: 'none',
opacity: inputDisabled ? 0.6 : 1,
@ -108,9 +108,8 @@ export function BacktrackModal({
borderRadius: '10px',
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
border: '1px solid rgba(168,85,247,0.3)',
fontSize: '18px',
}}
className="flex items-center justify-center"
className="flex items-center justify-center text-title-1"
>
🔍
</div>
@ -127,7 +126,7 @@ export function BacktrackModal({
height: '32px',
borderRadius: '8px',
background: 'var(--bg-card)',
fontSize: '14px',
fontSize: 'var(--font-size-body-2)',
}}
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
>
@ -245,7 +244,7 @@ export function BacktrackModal({
}}
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="text-sm font-bold text-color-tertiary font-mono">
<div className="text-body-2 font-bold text-color-tertiary font-mono">
{conditions.totalVessels}{' '}
<span className="text-caption font-medium text-fg-disabled">(AIS )</span>
</div>
@ -371,7 +370,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
borderRadius: '50%',
background: `${vessel.color}20`,
border: `2px solid ${vessel.color}`,
fontSize: '12px',
fontSize: 'var(--font-size-caption)',
fontWeight: 800,
color: vessel.color,
}}

파일 보기

@ -1222,7 +1222,7 @@ function FieldApplicationPanel() {
style={{ background: step.bg, border: `1px solid ${step.bd}` }}
>
<div
className="min-w-[36px] h-[36px] rounded-[9px] flex items-center justify-center font-extrabold text-sm flex-shrink-0"
className="min-w-[36px] h-[36px] rounded-[9px] flex items-center justify-center font-extrabold text-body-2 flex-shrink-0"
style={{
background: step.numBg,
border: `1px solid ${step.numBd}`,

파일 보기

@ -130,7 +130,7 @@ export function LeftPanel({
};
return (
<div className="w-80 min-w-[320px] bg-bg-surface border-r border-stroke flex flex-col">
<div className="w-full min-w-0 h-full bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
{/* Scrollable Content */}
<div
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent"

파일 보기

@ -166,6 +166,8 @@ export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift'];
export function OilSpillView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction');
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
@ -1129,6 +1131,7 @@ export function OilSpillView() {
<div className="relative flex flex-1 overflow-hidden">
{/* Left Sidebar */}
{activeSubTab === 'analysis' && (
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
<LeftPanel
selectedAnalysis={selectedAnalysis}
enabledLayers={enabledLayers}
@ -1207,10 +1210,53 @@ export function OilSpillView() {
onImageAnalysisResult={handleImageAnalysisResult}
validationErrors={validationErrors}
/>
</div>
)}
{/* Center - Map/Content Area */}
<div className="flex-1 relative overflow-hidden">
{/* Left panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setLeftCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
left: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{leftCollapsed ? '▶' : '◀'}
</button>
)}
{/* Right panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setRightCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
right: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRight: 'none',
borderRadius: '6px 0 0 6px',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{rightCollapsed ? '◀' : '▶'}
</button>
)}
{activeSubTab === 'list' ? (
<AnalysisListTable
onTabChange={setActiveSubTab}
@ -1332,7 +1378,7 @@ export function OilSpillView() {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '14px',
fontSize: 'var(--font-size-body-2)',
transition: '0.2s',
}}
>
@ -1358,7 +1404,7 @@ export function OilSpillView() {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '14px',
fontSize: 'var(--font-size-body-2)',
transition: '0.2s',
}}
>
@ -1394,7 +1440,7 @@ export function OilSpillView() {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '12px',
fontSize: 'var(--font-size-caption)',
transition: '0.2s',
}}
>
@ -1415,7 +1461,7 @@ export function OilSpillView() {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
fontWeight: 600,
fontFamily: 'var(--font-mono)',
transition: '0.2s',
@ -1439,7 +1485,7 @@ export function OilSpillView() {
position: 'absolute',
left: `${pos}%`,
transform: 'translateX(-50%)',
fontSize: '10px',
fontSize: 'var(--font-size-caption)',
fontFamily: 'var(--font-mono)',
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
fontWeight: isActive ? 600 : 400,
@ -1526,7 +1572,7 @@ export function OilSpillView() {
top: '-18px',
left: `${bm.pos}%`,
transform: 'translateX(-50%)',
fontSize: '12px',
fontSize: 'var(--font-size-caption)',
cursor: 'pointer',
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))',
}}
@ -1569,7 +1615,7 @@ export function OilSpillView() {
>
<div
style={{
fontSize: '14px',
fontSize: 'var(--font-size-body-2)',
fontWeight: 600,
color: 'var(--color-accent)',
fontFamily: 'var(--font-mono)',
@ -1610,7 +1656,7 @@ export function OilSpillView() {
display: 'flex',
alignItems: 'center',
gap: '5px',
fontSize: '11px',
fontSize: 'var(--font-size-caption)',
}}
>
<span className="text-fg-disabled">{s.label}</span>
@ -1655,6 +1701,7 @@ export function OilSpillView() {
{/* Right Panel */}
{activeSubTab === 'analysis' && (
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
<RightPanel
onOpenBacktrack={handleOpenBacktrack}
onOpenRecalc={() => {
@ -1693,6 +1740,7 @@ export function OilSpillView() {
onCancelAnalysis={handleCancelAnalysis}
onClearAnalysis={handleClearAnalysis}
/>
</div>
)}
{/* 확산 예측 실행 중 로딩 오버레이 */}

파일 보기

@ -159,7 +159,7 @@ export function RecalcModal({
borderRadius: '10px',
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(6,182,212,0.2))',
border: '1px solid rgba(249,115,22,0.3)',
fontSize: '16px',
fontSize: 'var(--font-size-body-1)',
}}
className="flex items-center justify-center"
>
@ -178,7 +178,7 @@ export function RecalcModal({
height: '28px',
borderRadius: '6px',
background: 'var(--bg-card)',
fontSize: '12px',
fontSize: 'var(--font-size-caption)',
}}
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
>
@ -321,7 +321,7 @@ export function RecalcModal({
}
toggleModel(model);
}}
style={{ fontSize: '11px', padding: '5px 10px' }}
style={{ fontSize: 'var(--font-size-caption)', padding: '5px 10px' }}
>
<span className="prd-md" style={{ background: color }} />
{model}

파일 보기

@ -117,7 +117,7 @@ export function RightPanel({
}, [incidentCoord, centerPoints, summary, predictionTime]);
return (
<div className="w-[300px] min-w-[300px] bg-bg-surface border-l border-stroke flex flex-col">
<div className="w-full min-w-0 h-full bg-bg-surface border-l border-stroke flex flex-col overflow-hidden">
{/* Tab Header */}
<div className="flex border-b border-stroke">
<button className="flex-1 py-3 text-center text-label-1 font-medium text-color-accent border-b-2 border-color-accent transition-all font-korean">
@ -580,7 +580,7 @@ export function RightPanel({
{/* Bottom Action Buttons */}
<div className="flex gap-1.5 p-3 border-t border-stroke">
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-color-accent text-color-accent font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
</button>
<button
@ -597,7 +597,7 @@ export function RightPanel({
</button>
<button
onClick={onOpenBacktrack}
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-[var(--color-tertiary)] text-[var(--color-tertiary)] font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
>
</button>

파일 보기

@ -1120,7 +1120,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{activeSections.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
<div className="text-4xl mb-4">📋</div>
<p className="text-sm font-korean"> </p>
<p className="text-body-2 font-korean">
</p>
</div>
)}
</div>

파일 보기

@ -171,13 +171,13 @@ export function ReportsView() {
{filteredReports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
<div className="text-4xl mb-4">📄</div>
<p className="text-sm font-korean mb-2"> </p>
<p className="text-body-2 font-korean mb-2"> </p>
<button
onClick={() => {
setView({ screen: 'templates' });
setActiveSubTab('template');
}}
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
className="px-4 py-2 text-caption font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
>
릿
</button>
@ -376,7 +376,7 @@ export function ReportsView() {
<td className="px-3 py-3 text-center">
<button
onClick={() => handleDelete(report.id)}
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-sm"
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-body-2"
>
🗑
</button>

파일 보기

@ -1,4 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
@ -103,6 +106,295 @@ 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
*/
@ -116,14 +408,15 @@ 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);
} catch (err) {
console.error('[rescue] 시나리오 조회 실패:', err);
setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS);
} catch {
setApiScenarios(MOCK_SCENARIOS);
} finally {
setLoading(false);
}
@ -132,14 +425,17 @@ 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 (err) {
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
} catch {
setOps(MOCK_OPS);
setApiScenarios(MOCK_SCENARIOS);
setLoading(false);
}
}, [loadScenarios]);
@ -229,9 +525,35 @@ 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: 시나리오 목록 ═══ */}
@ -376,7 +698,7 @@ export function RescueScenarioView() {
</div>
{/* View content */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
{/* ─── VIEW 0: 시나리오 상세 ─── */}
{detailView === 0 && selected && (
<div className="p-5">
@ -536,37 +858,14 @@ export function RescueScenarioView() {
{/* ─── VIEW 2: 지도 오버레이 ─── */}
{detailView === 2 && (
<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>
<ScenarioMapOverlay
ops={ops}
selectedIncident={selectedIncident}
scenarios={scenarios}
selectedId={selectedId}
checked={checked}
onSelectScenario={setSelectedId}
/>
)}
</div>
</div>
@ -578,6 +877,310 @@ 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);
@ -1048,14 +1651,14 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
</div>
<button
onClick={onClose}
className="px-5 py-2.5 rounded-md border border-stroke bg-bg-card text-fg-sub text-xs font-semibold cursor-pointer"
className="px-5 py-2.5 rounded-md border border-stroke bg-bg-card text-fg-sub text-caption font-semibold cursor-pointer"
>
</button>
{done ? (
<button
onClick={onClose}
className="px-7 py-2.5 rounded-md border-none text-static-white text-xs font-bold cursor-pointer bg-color-success"
className="px-7 py-2.5 rounded-md border-none text-static-white text-caption font-bold cursor-pointer bg-color-success"
>
</button>
@ -1063,7 +1666,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
<button
onClick={handleSubmit}
disabled={submitting}
className={`px-7 py-2.5 rounded-md border-none text-xs font-bold ${
className={`px-7 py-2.5 rounded-md border-none text-caption font-bold ${
submitting
? 'bg-bg-card text-fg-disabled cursor-wait'
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'

파일 보기

@ -1,9 +1,12 @@
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 =
@ -221,12 +224,145 @@ 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)
@ -290,191 +426,6 @@ 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,
@ -1572,6 +1523,44 @@ 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 (
@ -1596,8 +1585,23 @@ export function RescueView() {
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
<div className="flex flex-1 overflow-hidden">
<LeftPanel activeType={activeType} onTypeChange={setActiveType} />
<CenterMap activeType={activeType} />
<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>
<RightPanel
activeAnalysis={activeAnalysis}
onAnalysisChange={setActiveAnalysis}

파일 보기

@ -3,8 +3,8 @@ function DistributionView() {
<div className="flex w-full h-full bg-bg-base items-center justify-center">
<div className="text-center">
<div className="text-3xl opacity-20 mb-3">🗺</div>
<div className="text-sm font-bold text-fg-sub font-korean mb-1"></div>
<div className="text-xs text-fg-disabled font-korean">
<div className="text-body-2 font-bold text-fg-sub font-korean mb-1"></div>
<div className="text-caption text-fg-disabled font-korean">
.
</div>
</div>

파일 보기

@ -16,6 +16,8 @@ import ScatRightPanel from './ScatRightPanel';
// ═══ Main PreScatView ═══
export function PreScatView() {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [segments, setSegments] = useState<ScatSegment[]>([]);
const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
@ -175,13 +177,13 @@ export function PreScatView() {
if (error) {
return (
<div className="flex w-full h-full bg-bg-base items-center justify-center flex-col gap-3">
<div className="text-color-danger text-sm font-korean">{error}</div>
<div className="text-color-danger text-body-2 font-korean">{error}</div>
<button
onClick={() => {
setError(null);
setLoading(true);
}}
className="px-4 py-1.5 bg-color-accent text-white text-xs rounded font-korean"
className="px-4 py-1.5 bg-color-accent text-white text-caption rounded font-korean"
>
</button>
@ -192,13 +194,15 @@ export function PreScatView() {
if (loading || !selectedSeg) {
return (
<div className="flex w-full h-full bg-bg-base items-center justify-center">
<div className="text-fg-sub text-sm font-korean">SCAT ...</div>
<div className="text-fg-sub text-body-2 font-korean">SCAT ...</div>
</div>
);
}
return (
<div className="flex w-full h-full bg-bg-base overflow-hidden">
{/* Left Panel */}
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 340 }}>
<ScatLeftPanel
segments={segments}
zones={zones}
@ -220,8 +224,47 @@ export function PreScatView() {
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
</div>
<div className="flex-1 relative overflow-hidden">
{/* Left panel toggle button */}
<button
onClick={() => setLeftCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
left: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{leftCollapsed ? '▶' : '◀'}
</button>
{/* Right panel toggle button */}
<button
onClick={() => setRightCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
right: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRight: 'none',
borderRadius: '6px 0 0 6px',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{rightCollapsed ? '◀' : '▶'}
</button>
<div className="flex-1 relative">
<ScatMap
segments={segments}
zones={zones}
@ -237,7 +280,10 @@ export function PreScatView() {
/> */}
</div>
{/* Right Panel */}
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 280 }}>
<ScatRightPanel detail={panelDetail} loading={panelLoading} />
</div>
{popupData && (
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />

파일 보기

@ -156,7 +156,7 @@ function ScatLeftPanel({
}, []);
return (
<div className="w-[340px] min-w-[340px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
<div className="w-full h-full min-w-0 bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
{/* Filters */}
<div className="p-3.5 border-b border-stroke">
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
@ -269,7 +269,11 @@ function ScatLeftPanel({
rowCount={filtered.length}
rowHeight={88}
overscanCount={5}
style={{ height: listHeight }}
style={{
height: listHeight,
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
rowComponent={SegRow}
rowProps={{
filtered,

파일 보기

@ -310,7 +310,7 @@ function ScatMap({
</div>
{/* Right info cards */}
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
<div className="absolute top-3.5 right-8 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">

파일 보기

@ -182,7 +182,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
<button
key={i}
onClick={() => setPopTab(i)}
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
className={`px-5 py-3 text-caption font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
popTab === i
? 'text-color-success border-status-green'
: 'text-fg-disabled border-transparent hover:text-fg-sub'
@ -204,7 +204,9 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{/* Skeleton */}
{!imgLoaded && !imgError && (
<div className="w-full aspect-video bg-bg-card animate-pulse flex items-center justify-center">
<span className="text-fg-disabled text-xs font-korean"> ...</span>
<span className="text-fg-disabled text-caption font-korean">
...
</span>
</div>
)}
<img
@ -216,7 +218,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
onError={() => setImgError(true)}
/>
{imgError && (
<div className="w-full aspect-video flex flex-col items-center justify-center text-fg-disabled text-xs font-korean">
<div className="w-full aspect-video flex flex-col items-center justify-center text-fg-disabled text-caption font-korean">
{/* <span className="text-[40px]">📷</span> */}
<span> </span>
</div>
@ -266,7 +268,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v, cls], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
>
<span className="text-fg-sub font-korean">{k}</span>
<span className={`text-fg font-korean ${cls}`}>{v}</span>
@ -282,7 +284,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
{data.sensitive.map((s, i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
>
<span className="text-fg-sub font-korean">{s.t}</span>
<span className="text-fg font-korean">{s.v}</span>
@ -344,7 +346,9 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
<div className="w-full aspect-[4/3] bg-bg-base border border-stroke rounded-md mb-4 overflow-hidden relative">
{!mapLoaded && (
<div className="absolute inset-0 bg-bg-card animate-pulse flex items-center justify-center z-10">
<span className="text-fg-disabled text-xs font-korean"> ...</span>
<span className="text-fg-disabled text-caption font-korean">
...
</span>
</div>
)}
<PopupMap
@ -390,7 +394,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="text-color-success font-mono font-medium">{v}</span>
@ -413,7 +417,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="text-fg font-medium font-korean">{v}</span>
@ -441,7 +445,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
].map((h, i) => (
<div key={i} className="bg-bg-card border border-stroke rounded-md p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold font-mono">{h.date}</span>
<span className="text-caption font-bold font-mono">{h.date}</span>
<span
className={`text-caption font-bold px-2 py-0.5 rounded-lg ${
h.type === 'Pre-SCAT'

파일 보기

@ -23,7 +23,7 @@ export default function ScatRightPanel({
if (!detail && !loading) {
return (
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px] h-full">
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
<div className="text-3xl mb-2">🏖</div>
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
@ -37,7 +37,7 @@ export default function ScatRightPanel({
}
return (
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-full">
{/* 헤더 */}
<div className="px-3.5 py-2.5 border-b border-stroke shrink-0">
{detail ? (
@ -49,12 +49,12 @@ export default function ScatRightPanel({
{detail.esi}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold truncate">{detail.name}</div>
<div className="text-caption font-bold truncate">{detail.name}</div>
<div className="text-caption text-fg-disabled">{detail.code}</div>
</div>
</div>
) : (
<div className="text-xs text-fg-disabled"> ...</div>
<div className="text-caption text-fg-disabled"> ...</div>
)}
</div>

파일 보기

@ -67,38 +67,38 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => onSeek(0)}
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
>
</button>
<button
onClick={() => onSeek(Math.max(0, currentIdx - 1))}
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
>
</button>
<button
onClick={togglePlay}
className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-color-success text-black border-status-green' : 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover'}`}
className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-body-2 ${playing ? 'bg-color-success text-black border-status-green' : 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover'}`}
>
{playing ? '⏸' : '▶'}
</button>
<button
onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))}
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
>
</button>
<button
onClick={() => onSeek(total - 1)}
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
>
</button>
<div className="w-2" />
<button
onClick={cycleSpeed}
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-xs font-mono font-bold"
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-caption font-mono font-bold"
>
{speed}×
</button>
@ -162,7 +162,7 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
{/* Info */}
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
<span className="text-sm font-semibold text-color-success font-mono">
<span className="text-body-2 font-semibold text-color-success font-mono">
{displaySegs[currentIdx]?.code || 'S-001'} / {total}
</span>
<div className="flex gap-3.5">

파일 보기

@ -3,8 +3,8 @@ function SurveyView() {
<div className="flex w-full h-full bg-bg-base items-center justify-center">
<div className="text-center">
<div className="text-3xl opacity-20 mb-3">📋</div>
<div className="text-sm font-bold text-fg-sub font-korean mb-1"> </div>
<div className="text-xs text-fg-disabled font-korean">
<div className="text-body-2 font-bold text-fg-sub font-korean mb-1"> </div>
<div className="text-caption text-fg-disabled font-korean">
.
</div>
</div>

파일 보기

@ -33,11 +33,11 @@ export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
<div key={tooltip} className="relative group">
<button
onClick={onClick}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
className="w-[38px] h-[38px] bg-bg-surface border border-stroke rounded-sm shadow-md text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
>
{label}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-base text-fg border border-stroke rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-caption bg-bg-base text-fg border border-stroke rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
{tooltip}
</div>
</div>

파일 보기

@ -33,26 +33,26 @@ interface WeatherMapOverlayProps {
selectedStationId: string | null;
}
// 풍속에 따른 hex 색상 반환
// 풍속에 따른 색상 반환
function getWindHexColor(speed: number, isSelected: boolean): string {
if (isSelected) return '#06b6d4';
if (speed > 10) return '#ef4444';
if (speed > 7) return '#f59e0b';
return '#3b82f6';
if (isSelected) return 'var(--color-accent)';
if (speed > 10) return 'var(--color-danger)';
if (speed > 7) return 'var(--color-caution)';
return 'var(--color-info)';
}
// 파고에 따른 hex 색상 반환
// 파고에 따른 색상 반환
function getWaveHexColor(height: number): string {
if (height > 2.5) return '#ef4444';
if (height > 1.5) return '#f59e0b';
return '#3b82f6';
if (height > 2.5) return 'var(--color-danger)';
if (height > 1.5) return 'var(--color-caution)';
return 'var(--color-info)';
}
// 수온에 따른 hex 색상 반환
// 수온에 따른 색상 반환
function getTempHexColor(temp: number): string {
if (temp > 8) return '#ef4444';
if (temp > 6) return '#f59e0b';
return '#3b82f6';
if (temp > 8) return 'var(--color-danger)';
if (temp > 6) return 'var(--color-caution)';
return 'var(--color-info)';
}
/**
@ -91,15 +91,17 @@ export function WeatherMapOverlay({
width={24}
height={24}
viewBox="0 0 24 24"
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
style={{ filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.5))' }}
>
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
<polygon points="12,2 4,22 12,16 20,22" fill={color} opacity="0.9" />
{/* 흰 외곽선 레이어 */}
<polygon points="12,2 4,22 12,16 20,22" fill="white" opacity="0.9" />
{/* 색상 레이어 */}
<polygon points="12,3 5,21 12,15.5 19,21" fill={color} opacity="0.95" />
</svg>
</div>
<span
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
className="text-xs font-bold leading-none"
className="text-caption font-bold leading-none px-1 py-px rounded-sm bg-bg-base/80"
style={{ color }}
>
{station.wind.speed.toFixed(1)}
</span>
@ -138,7 +140,7 @@ export function WeatherMapOverlay({
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
}}
className="text-center text-xs font-bold pb-1 mb-0.5"
className="text-center text-caption font-bold pb-1 mb-0.5"
>
{station.name}
</div>
@ -150,7 +152,7 @@ export function WeatherMapOverlay({
🌡
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.temperature.current.toFixed(1)}
</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
@ -164,7 +166,7 @@ export function WeatherMapOverlay({
🌊
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wave.height.toFixed(1)}
</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
@ -178,7 +180,7 @@ export function WeatherMapOverlay({
💨
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wind.speed.toFixed(1)}
</span>
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
@ -205,7 +207,6 @@ export function useWeatherDeckLayers(
onStationClick: (station: WeatherStation) => void,
): Layer[] {
return useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Layer[] = [];
// 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km)

파일 보기

@ -1,3 +1,5 @@
import { useState } from 'react';
interface WeatherData {
stationName: string;
location: { lat: number; lon: number };
@ -46,20 +48,14 @@ interface WeatherRightPanelProps {
weatherData: WeatherData | null;
}
/** 풍속 등급 색상 */
function windColor(speed: number): string {
if (speed >= 14) return '#ef4444';
if (speed >= 10) return '#f97316';
if (speed >= 6) return '#eab308';
return '#22c55e';
/** 풍속 텍스트 색상 (2단계 — danger | accent) */
function windTextColor(speed: number): string {
return speed >= 10 ? 'var(--color-danger)' : 'var(--color-accent)';
}
/** 파고 등급 색상 */
function waveColor(height: number): string {
if (height >= 3) return '#ef4444';
if (height >= 2) return '#f97316';
if (height >= 1) return '#eab308';
return '#22c55e';
/** 파고 텍스트 색상 (2단계 — danger | accent) */
function waveTextColor(height: number): string {
return height >= 2 ? 'var(--color-danger)' : 'var(--color-accent)';
}
/** 풍향 텍스트 */
@ -86,13 +82,38 @@ function windDirText(deg: number): string {
}
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
const [collapsed, setCollapsed] = useState(false);
if (collapsed) {
return (
<div className="flex flex-col bg-bg-surface border-l border-stroke w-8 shrink-0">
<button
onClick={() => setCollapsed(false)}
className="flex-1 flex flex-col items-center justify-start pt-3 gap-1 text-fg-sub hover:text-fg transition-colors"
title="패널 펼치기"
>
<span className="text-heading-2 leading-none"></span>
<span className="text-subtitle text-fg-disabled [writing-mode:vertical-rl]"></span>
</button>
</div>
);
}
if (!weatherData) {
return (
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
<div className="p-6 text-center">
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke bg-bg-elevated">
<p className="text-fg-disabled text-title-4 font-korean">
</p>
<button
onClick={() => setCollapsed(true)}
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2"
title="패널 접기"
>
<span className="text-subtitle text-fg-disabled"></span>
<span className="text-heading-2 leading-none"></span>
</button>
</div>
</div>
);
@ -109,11 +130,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-title-4 font-bold text-color-accent font-korean">
<span className="text-title-4 font-bold text-color-accent font-korean truncate">
📍 {weatherData.stationName}
</span>
<span className="px-1.5 py-px text-label-2 rounded bg-[rgba(6,182,212,0.15)] text-color-accent font-bold">
<span className="px-1.5 py-px text-label-2 rounded bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent font-bold shrink-0">
</span>
</div>
@ -122,6 +145,16 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{weatherData.currentTime}
</p>
</div>
<button
onClick={() => setCollapsed(true)}
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2 mt-0.5"
title="패널 접기"
>
<span className="text-subtitle text-fg-disabled"></span>
<span className="text-heading-2 leading-none"></span>
</button>
</div>
</div>
{/* 스크롤 콘텐츠 */}
<div
@ -131,13 +164,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 핵심 지표 3칸 카드 ── */}
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
<div className="text-[20px] font-bold font-mono" style={{ color: windTextColor(wSpd) }}>
{wSpd.toFixed(1)}
</div>
<div className="text-label-2 text-fg-disabled font-korean"> (m/s)</div>
</div>
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
<div className="text-[20px] font-bold font-mono" style={{ color: waveTextColor(wHgt) }}>
{wHgt.toFixed(1)}
</div>
<div className="text-label-2 text-fg-disabled font-korean"> (m)</div>
@ -152,9 +185,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 바람 상세 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🌬
</div>
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌬 </div>
<div className="flex items-center gap-3 mb-2">
{/* 풍향 컴파스 */}
<div className="relative w-[50px] h-[50px] shrink-0">
@ -202,11 +233,11 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
y1="25"
x2={25 + 14 * Math.sin((wind.direction * Math.PI) / 180)}
y2={25 - 14 * Math.cos((wind.direction * Math.PI) / 180)}
stroke={windColor(wSpd)}
stroke="var(--color-accent)"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
<circle cx="25" cy="25" r="3" fill="var(--color-accent)" />
</svg>
</div>
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
@ -222,19 +253,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
</div>
<div className="flex justify-between">
<span className="text-fg-disabled">1k </span>
<span
className="font-mono text-title-4"
style={{ color: windColor(wind.speed_1k) }}
>
<span className="font-mono text-title-4 text-fg">
{Number(wind.speed_1k).toFixed(1)}
</span>
</div>
<div className="flex justify-between">
<span className="text-fg-disabled">3k </span>
<span
className="font-mono text-title-4"
style={{ color: windColor(wind.speed_3k) }}
>
<span className="font-mono text-title-4 text-fg">
{Number(wind.speed_3k).toFixed(1)}
</span>
</div>
@ -248,11 +273,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<div className="flex items-center gap-2">
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min((wSpd / 20) * 100, 100)}%`,
background: windColor(wSpd),
}}
className="h-full rounded-full transition-all bg-color-accent"
style={{ width: `${Math.min((wSpd / 20) * 100, 100)}%` }}
/>
</div>
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
@ -263,24 +285,20 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 파도 상세 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">🌊 </div>
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌊 </div>
<div className="grid grid-cols-4 gap-1">
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-title-3 font-bold font-mono" style={{ color: waveColor(wHgt) }}>
{wHgt.toFixed(1)}m
</div>
<div className="text-title-3 font-bold font-mono text-fg">{wHgt.toFixed(1)}m</div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-title-3 font-bold font-mono text-color-danger">
<div className="text-title-3 font-bold font-mono text-fg">
{wave.maxHeight.toFixed(1)}m
</div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-title-3 font-bold font-mono text-color-accent">
{wave.period}s
</div>
<div className="text-title-3 font-bold font-mono text-fg">{wave.period}s</div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
@ -295,7 +313,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
className="h-full rounded-full transition-all"
style={{
width: `${Math.min((wHgt / 5) * 100, 100)}%`,
background: waveColor(wHgt),
background: 'var(--color-accent)',
}}
/>
</div>
@ -307,14 +325,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 수온/공기 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🌡 ·
</div>
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌡 · </div>
<div className="grid grid-cols-3 gap-1">
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
<div className="text-title-3 font-bold font-mono text-color-accent">
{wTemp.toFixed(1)}°
</div>
<div className="text-title-3 font-bold font-mono text-fg">{wTemp.toFixed(1)}°</div>
<div className="text-caption text-fg-disabled"></div>
</div>
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
@ -332,9 +346,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 시간별 예보 ── */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
</div>
<div className="text-label-2 text-fg-disabled font-korean mb-2"> </div>
<div className="grid grid-cols-5 gap-1">
{forecast.map((f, i) => (
<div
@ -353,9 +365,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 천문/조석 ── */}
{astronomy && (
<div className="px-3 py-2 border-b border-stroke">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
·
</div>
<div className="text-label-2 text-fg-disabled font-korean mb-2"> · </div>
<div className="grid grid-cols-4 gap-1">
{[
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
@ -371,7 +381,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
))}
</div>
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-label-2">
<span className="text-sm">🌓</span>
<span className="text-body-2">🌓</span>
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
<span className="ml-auto text-fg font-mono"> {astronomy.tidalRange}m</span>
</div>
@ -381,17 +391,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* ── 날씨 특보 ── */}
{alert && (
<div className="px-3 py-2">
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
🚨
</div>
<div className="text-label-2 text-fg-disabled font-korean mb-2">🚨 </div>
<div
className="px-2.5 py-2 rounded border"
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
style={{
background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
}}
>
<div className="flex items-center gap-2 text-label-2">
<span
className="px-1.5 py-px rounded text-label-2 font-bold"
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
style={{
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
color: 'var(--color-danger)',
}}
>
</span>

파일 보기

@ -90,7 +90,6 @@ const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
const WEATHER_MAP_ZOOM = 7;
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
@ -178,7 +177,7 @@ function WeatherMapInner({
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-color-accent" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
<div className="px-2 py-1 bg-bg-base border border-color-accent rounded text-caption text-color-accent whitespace-nowrap shadow-md">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{clickedLocation.lon.toFixed(3)}°E
</div>
</div>
@ -295,13 +294,13 @@ export function WeatherView() {
{/* Main Map Area */}
<div className="flex-1 relative flex flex-col overflow-hidden">
{/* Tab Navigation */}
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0">
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0 pt-2 pb-2">
<div className="flex items-center gap-2 px-6">
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
<button
key={offset}
onClick={() => setTimeOffset(offset)}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
className={`px-3 py-2 text-caption font-semibold rounded transition-all ${
timeOffset === offset
? 'bg-color-accent text-bg-0'
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
@ -312,7 +311,7 @@ export function WeatherView() {
))}
<div className="flex items-center gap-2 ml-4">
<span className="text-xs text-fg-disabled">
<span className="text-caption text-fg-disabled">
{lastUpdate
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -323,7 +322,7 @@ export function WeatherView() {
{loading && (
<div className="w-4 h-4 border-2 border-color-accent border-t-transparent rounded-full animate-spin" />
)}
{error && <span className="text-xs text-color-danger"> {error}</span>}
{error && <span className="text-caption text-color-danger"> {error}</span>}
</div>
</div>
@ -356,10 +355,7 @@ export function WeatherView() {
</Map>
{/* 레이어 컨트롤 */}
<div
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px' }}
>
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
<div className="text-caption font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1.5 cursor-pointer">
@ -420,17 +416,12 @@ export function WeatherView() {
</div>
{/* 범례 */}
<div
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px', maxWidth: 180 }}
>
<div className="text-caption font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
<div className="text-caption text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5 text-[8px]">
{/* 바람 */}
<div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="text-fg-sub mb-0.5"> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
@ -441,7 +432,7 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<div className="flex justify-between text-fg-disabled text-[7px]">
<span>3</span>
<span>5</span>
<span>7</span>
@ -453,16 +444,14 @@ export function WeatherView() {
</div>
{/* 해류 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="text-fg-sub mb-0.5"> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<div className="flex justify-between text-fg-disabled text-[7px]">
<span>0.2</span>
<span>0.4</span>
<span>0.6</span>
@ -471,23 +460,18 @@ export function WeatherView() {
</div>
{/* 파고 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m)
</div>
<div className="text-fg-sub mb-0.5"> (m)</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<div className="w-2 h-2 rounded-full bg-color-info" />
<span className="text-fg-disabled">&lt;1.5 </span>
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
<div className="w-2 h-2 rounded-full bg-color-warning ml-1" />
<span className="text-fg-disabled">~2.5</span>
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
<div className="w-2 h-2 rounded-full bg-color-danger ml-1" />
<span className="text-fg-disabled">&gt;2.5</span>
</div>
</div>
</div>
<div
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
style={{ fontSize: 7 }}
>
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled text-[7px] font-korean">
💡
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More