release: 2026-04-02 (229건 커밋) #159

병합
dnlee develop 에서 main 로 7 commits 를 머지했습니다 2026-04-02 16:54:57 +09:00
199개의 변경된 파일269201개의 추가작업 그리고 879234개의 파일을 삭제
Showing only changes of commit a0be19d060 - Show all commits

파일 보기

@ -3,120 +3,179 @@
## 개요
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
시맨틱 토큰 기반 다크/라이트 테마 전환을 지원한다.
---
## 테마 (Theme)
### 테마 전환 메커니즘
다크(기본)/라이트 2벌 테마를 CSS 변수 오버라이드 방식으로 지원한다.
```
[플래시 방지] index.html 인라인 스크립트
↓ localStorage → <html data-theme="dark|light">
[상태 관리] themeStore.ts (Zustand)
↓ toggleTheme() / setTheme()
[CSS 적용] base.css :root (dark) / [data-theme="light"] (light)
↓ CSS 변수 오버라이드
[UI 반영] 모든 컴포넌트가 var(--*) 참조 → 즉시 전환
```
#### 1단계 — FOUC 방지 (`index.html`)
```html
<script>
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('wing-theme') || 'dark'
);
</script>
```
HTML 파싱 즉시 `data-theme` 속성을 설정하여 테마 깜빡임을 방지한다.
#### 2단계 — Zustand 스토어 (`themeStore.ts`)
```ts
type ThemeMode = 'dark' | 'light';
interface ThemeState {
theme: ThemeMode;
toggleTheme: () => void; // dark ↔ light 토글
setTheme: (mode: ThemeMode) => void; // 직접 지정
}
```
- 초기값: `localStorage.getItem('wing-theme') || 'dark'`
- `toggleTheme()`: localStorage 갱신 → DOM 속성 변경 → Zustand 상태 갱신
#### 3단계 — CSS 변수 오버라이드 (`base.css`)
- `:root` — 다크 테마 (기본값)
- `[data-theme="light"]` — 라이트 테마 오버라이드
시맨틱 토큰(`--bg-*`, `--fg-*`, `--stroke-*`)만 테마별 오버라이드하고, 프리미티브 토큰(`--gray-*`, `--blue-*` 등)과 액센트 컬러(`--color-*`)는 테마 간 동일 값을 유지한다.
#### UI 진입점
TopBar 퀵메뉴에서 `toggleTheme()` 호출로 전환.
---
## Foundations
### 색상 (Color Palette)
#### 토큰 아키텍처
```
Primitive Tokens (정적) Semantic Tokens (테마 반응형)
────────────────────── ────────────────────────────
--gray-100 ~ --gray-1000 --bg-base, --bg-surface, ...
--blue-100 ~ --blue-1000 --fg-default, --fg-sub, ...
--red-100 ~ --red-1000 --stroke-default, --stroke-light
--green-100 ~ --green-1000 --hover-overlay, --dropdown-bg
--yellow-100 ~ --yellow-1000
--purple-100 ~ --purple-1000
Accent Tokens (테마 불변)
─────────────────────────
--color-accent, --color-info, ...
```
#### Semantic Colors — Background
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--bg-base` | `bg-bg-base` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
| `--bg-surface` | `bg-bg-surface` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
| `--bg-elevated` | `bg-bg-elevated` | `#121929` | `#f1f5f9` | 테이블 헤더, 상위 요소 |
| `--bg-card` | `bg-bg-card` | `#1a2236` | `#ffffff` | 카드 배경 |
| `--bg-surface-hover` | `bg-bg-surface-hover` | `#1e2844` | `#e2e8f0` | 호버 상태 |
#### Semantic Colors — Foreground (Text)
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--fg-default` | `text-fg` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 |
| `--fg-sub` | `text-fg-sub` | `#c0c8dc` | `#475569` | 보조 텍스트 |
| `--fg-disabled` | `text-fg-disabled` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
#### Semantic Colors — Border (Stroke)
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--stroke-default` | `border-stroke` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
| `--stroke-light` | `border-stroke-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
#### Semantic Colors — Overlay
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--hover-overlay` | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.04)` | 호버 오버레이 |
| `--dropdown-bg` | `rgba(18,25,41,0.97)` | `rgba(255,255,255,0.97)` | 드롭다운 배경 |
#### Accent Colors (테마 불변)
| CSS 변수 | Tailwind 클래스 | Hex | 용도 |
|----------|----------------|-----|------|
| `--color-accent` | `text-color-accent` | `#06b6d4` | 주요 강조 (Cyan) |
| `--color-info` | `text-color-info` | `#3b82f6` | 정보, 링크 (Blue) |
| `--color-tertiary` | `text-color-tertiary` | `#a855f7` | 3차 강조 (Purple) |
| `--color-danger` | `text-color-danger` | `#ef4444` | 위험, 삭제 (Red) |
| `--color-warning` | `text-color-warning` | `#f97316` | 주의 (Orange) |
| `--color-caution` | `text-color-caution` | `#eab308` | 경고 (Yellow) |
| `--color-success` | `text-color-success` | `#22c55e` | 성공, 정상 (Green) |
| `--color-boom` | `text-color-boom` | `#f59e0b` | 오일붐 전용 (Amber) |
| `--color-boom-hover` | — | `#fbbf24` | 오일붐 호버 |
#### Static Colors
| CSS 변수 | Hex | 용도 |
|----------|-----|------|
| `--static-black` | `#131415` | 테마 무관 고정 검정 |
| `--static-white` | `#ffffff` | 테마 무관 고정 흰색 |
#### Primitive Colors
UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단계, 나머지는 00~100의 11단계 스케일.
UI 전반에서 직접 참조하거나 시맨틱 토큰의 원천으로 사용하는 기본 팔레트. 100~1000 (10단계).
**Navy** (배경 전용)
**Gray**
| Step | Hex |
|------|-----|
| 0 | `#0a0e1a` |
| 1 | `#0f1524` |
| 2 | `#121929` |
| 3 | `#1a2236` |
| hover | `#1e2844` |
**Cyan**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#ecfeff` | `#cffafe` | `#a5f3fc` | `#67e8f9` | `#22d3ee` | `#06b6d4` | `#0891b2` | `#0e7490` | `#155e75` | `#164e63` | `#083344` |
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#f1f5f9` | `#e2e8f0` | `#cbd5e1` | `#94a3b8` | `#64748b` | `#475569` | `#334155` | `#1e293b` | `#0f172a` | `#020617` |
**Blue**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#eff6ff` | `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
**Red**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#fef2f2` | `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
**Green**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#f0fdf4` | `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
**Orange**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#fff7ed` | `#ffedd5` | `#fed7aa` | `#fdba74` | `#fb923c` | `#f97316` | `#ea580c` | `#c2410c` | `#9a3412` | `#7c2d12` | `#431407` |
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
**Yellow**
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|----|----|----|----|----|----|----|----|----|----|----|
| `#fefce8` | `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
#### Semantic Colors
**Red**
컨텍스트에 따라 의미를 부여한 토큰. Dark/Light 두 테마 값 병기.
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
**Text**
**Purple**
| 토큰 | Dark | Light | 용도 |
|------|------|-------|------|
| `text-1` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 기본 |
| `text-2` | `#c0c8dc` | `#475569` | 보조 텍스트 |
| `text-3` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
> **TODO: 텍스트 토큰 시맨틱 리네이밍**
>
> Tailwind config의 `colors.text` 키를 `colors.color`로 변경하여 클래스명을 직관적으로 개선한다.
>
> | 현재 | 변경 후 | 용도 |
> |------|---------|------|
> | `text-text-1` | `text-color` (DEFAULT) | 기본 텍스트 |
> | `text-text-2` | `text-color-sub` | 보조 텍스트 |
> | `text-text-3` | `text-color-disabled` | 비활성, 플레이스홀더 |
>
> - 영향 범위: 96개 파일, 2,685회 참조
> - CSS 변수(`--t1/2/3`)도 `--color-default/sub/disabled`로 동기화
> - 기계적 find-and-replace로 처리 가능
**Background**
| 토큰 | Dark | Light | 용도 |
|------|------|-------|------|
| `bg-0` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
| `bg-1` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
| `bg-2` | `#121929` | `#f1f5f9` | 테이블 헤더 |
| `bg-3` | `#1a2236` | `#e2e8f0` | 카드 배경 |
| `bg-hover` | `#1e2844` | `#cbd5e1` | 호버 상태 |
**Border**
| 토큰 | Dark | Light | 용도 |
|------|------|-------|------|
| `border` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
| `border-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
**Accent**
| 토큰 | Dark | Light | 용도 |
|------|------|-------|------|
| `primary-cyan` | `#06b6d4` | `#06b6d4` | 주요 강조, 활성 상태 |
| `primary-blue` | `#3b82f6` | `#0891b2` | 보조 강조 |
| `primary-purple` | `#a855f7` | `#6366f1` | 3차 강조 |
**Status**
| 토큰 | Dark | Light | 용도 |
|------|------|-------|------|
| `status-red` | `#ef4444` | `#dc2626` | 위험, 삭제 |
| `status-orange` | `#f97316` | `#c2410c` | 주의 |
| `status-yellow` | `#eab308` | `#b45309` | 경고 |
| `status-green` | `#22c55e` | `#047857` | 정상, 성공 |
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#f3e8ff` | `#e9d5ff` | `#d8b4fe` | `#c084fc` | `#a855f7` | `#9333ea` | `#7e22ce` | `#6b21a8` | `#581c87` | `#3b0764` |
---
@ -124,13 +183,104 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
#### Font Family
| 이름 | className | Font Stack | 용도 |
|------|-----------|------------|------|
| PretendardGOV | `font-korean` | `'PretendardGOV', sans-serif` | 기본 UI 텍스트, 한국어/영문 콘텐츠 전반 |
| PretendardGOV | `font-mono` | `'PretendardGOV', sans-serif` | 좌표, 수치, 데이터 값 |
| PretendardGOV | `font-sans` | `'PretendardGOV', sans-serif` | body 기본 폰트 |
| CSS 변수 | Tailwind 클래스 | 용도 |
|----------|----------------|------|
| `--font-korean` | `font-korean` | 기본 UI 텍스트, 한국어/영문 콘텐츠 |
| `--font-mono` | `font-mono` | 좌표, 수치, 데이터 값 |
| — | `font-sans` | body 기본 (PretendardGOV) |
> Body 기본 스택: `font-family: 'PretendardGOV', sans-serif`
> 모든 폰트 패밀리가 `PretendardGOV` 우선 스택으로 통일.
> `@font-face`: Regular(400), Medium(500), SemiBold(600), Bold(700) — `/fonts/PretendardGOV-*.otf`
#### Typography Categories
5가지 용도 카테고리로 타이포그래피를 구성합니다.
| 카테고리 | 토큰 | 설명 |
|----------|------|------|
| **Display** | Display 1, Display 2, Display 3 | 배너, 마케팅 등 최대 크기 텍스트 |
| **Heading** | Heading 1, Heading 2, Heading 3 | 페이지/모듈 단위 제목, 계층 설정 |
| **Body** | Body 1, Body 2, Caption | 본문/콘텐츠 텍스트 |
| **Navigation** | Title 1~6 | 사이트 내 이정표 역할 (패널 제목, 탭 버튼, 메뉴 항목 등) |
| **Label** | Label 1, Label 2 | 컴포넌트 label, placeholder, 버튼 텍스트 |
> Navigation 카테고리의 토큰은 CSS 변수명 `--font-size-title-*` / Tailwind `text-title-*`을 유지합니다.
#### Font Size Tokens
CSS 변수와 Tailwind 유틸리티가 1:1 매핑된 타이포그래피 스케일. `text-*` 클래스 사용 시 font-size, line-height, letter-spacing이 함께 적용됩니다.
**Display** — 배너, 마케팅, 랜딩 영역
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|----------|---------|-----|--------|--------|---------|
| `--font-size-display-1` | `text-display-1` | 60px | 700 | 1.3 | 0.06em |
| `--font-size-display-2` | `text-display-2` | 40px | 700 | 1.3 | 0.06em |
| `--font-size-display-3` | `text-display-3` | 36px | 500 | 1.4 | 0.06em |
**Heading** — 페이지/모듈 제목
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|----------|---------|-----|--------|--------|---------|
| `--font-size-heading-1` | `text-heading-1` | 32px | 700 | 1.4 | 0.02em |
| `--font-size-heading-2` | `text-heading-2` | 24px | 700 | 1.4 | 0.02em |
| `--font-size-heading-3` | `text-heading-3` | 22px | 500 | 1.4 | 0.02em |
**Body** — 본문/콘텐츠
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|----------|---------|-----|--------|--------|---------|
| `--font-size-body-1` | `text-body-1` | 14px | 400 | 1.6 | 0em |
| `--font-size-body-2` | `text-body-2` | 13px | 400 | 1.6 | 0em |
| `--font-size-caption` | `text-caption` | 11px | 400 | 1.5 | 0em |
**Navigation** — 패널 제목, 탭 버튼, 메뉴 항목, 소형 네비게이션
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|----------|---------|-----|--------|--------|---------|
| `--font-size-title-1` | `text-title-1` | 18px | 700 | 1.5 | 0.02em |
| `--font-size-title-2` | `text-title-2` | 16px | 500 | 1.5 | 0.02em |
| `--font-size-title-3` | `text-title-3` | 14px | 500 | 1.5 | 0.02em |
| `--font-size-title-4` | `text-title-4` | 13px | 500 | 1.5 | 0.02em |
| `--font-size-title-5` | `text-title-5` | 12px | 500 | 1.5 | 0.02em |
| `--font-size-title-6` | `text-title-6` | 11px | 500 | 1.5 | 0.02em |
**Label** — 레이블, 플레이스홀더, 버튼
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|----------|---------|-----|--------|--------|---------|
| `--font-size-label-1` | `text-label-1` | 12px | 500 | 1.5 | 0.04em |
| `--font-size-label-2` | `text-label-2` | 11px | 500 | 1.5 | 0.04em |
#### Font Weight Tokens
| CSS 변수 | 값 | 용도 |
|----------|-----|------|
| `--font-weight-thin` | 300 | 얇은 텍스트 |
| `--font-weight-regular` | 400 | 본문 기본 |
| `--font-weight-medium` | 500 | 중간 강조 |
| `--font-weight-bold` | 700 | 제목, 강조 |
#### Line Height Tokens
| CSS 변수 | 값 | 용도 |
|----------|-----|------|
| `--line-height-tight` | 1.3 | Display |
| `--line-height-snug` | 1.4 | Heading |
| `--line-height-normal` | 1.5 | Navigation, Label, Caption |
| `--line-height-relaxed` | 1.6 | Body |
#### Letter Spacing Tokens
카테고리별 자간 토큰. `text-*` 클래스에 자동 포함되며, `tracking-*` 클래스로 개별 사용도 가능합니다.
| CSS 변수 | Tailwind | 값 | 카테고리 |
|----------|---------|-----|----------|
| `--letter-spacing-display` | `tracking-display` | 0.06em | Display |
| `--letter-spacing-heading` | `tracking-heading` | 0.02em | Heading |
| `--letter-spacing-body` | `tracking-body` | 0em | Body |
| `--letter-spacing-navigation` | `tracking-navigation` | 0.02em | Navigation |
| `--letter-spacing-label` | `tracking-label` | 0.04em | Label |
#### Typography Tokens (`.wing-*` 클래스)
@ -231,3 +381,30 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
| `.wing-panel-scroll` | 패널 내 스크롤 영역 | `flex-1 overflow-y-auto` |
| `.wing-header-bar` | 패널 헤더 | `flex items-center justify-between shrink-0 px-5 border-b` |
| `.wing-sidebar` | 사이드바 | `flex flex-col border-r border-border` |
---
## CSS 레이어 아키텍처
```
index.css
├── @import base.css → @layer base (CSS 변수, reset, body, @font-face)
├── @import components.css → @layer components (MapLibre, scrollbar, prd-*, combo-*)
├── @import wing.css → @layer components (wing-* 디자인 시스템 클래스)
├── @tailwind base
├── @tailwind components
└── @tailwind utilities
```
---
## Tailwind 시맨틱 토큰 매핑 요약
| 카테고리 | CSS 변수 | Tailwind 클래스 예시 |
|---------|----------|---------------------|
| Background | `--bg-base` ~ `--bg-surface-hover` | `bg-bg-base`, `bg-bg-surface`, ... |
| Foreground | `--fg-default`, `--fg-sub`, `--fg-disabled` | `text-fg`, `text-fg-sub`, `text-fg-disabled` |
| Border | `--stroke-default`, `--stroke-light` | `border-stroke`, `border-stroke-light` |
| Accent | `--color-accent` ~ `--color-success` | `text-color-accent`, `bg-color-info`, ... |
| Font Size | `--font-size-display-1` ~ `--font-size-caption` | `text-display-1`, `text-body-1`, ... |
| Font Family | `--font-korean`, `--font-mono` | `font-korean`, `font-mono`, `font-sans` |

파일 보기

@ -40,15 +40,15 @@ export default defineConfig([
// other options...
},
},
])
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
@ -69,5 +69,5 @@ export default defineConfig([
// other options...
},
},
])
]);
```

파일 보기

@ -1,9 +1,9 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
@ -20,4 +20,4 @@ export default defineConfig([
globals: globals.browser,
},
},
])
]);

파일 보기

@ -4,14 +4,17 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>frontend</title>
<script>
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('wing-theme') || 'dark'
localStorage.getItem('wing-theme') || 'dark',
);
</script>
</head>

파일 보기

@ -51,6 +51,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
@ -5379,6 +5380,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",

파일 보기

@ -53,6 +53,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",

파일 보기

@ -1,6 +1,6 @@
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [tailwindcss, autoprefixer],
}
};

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

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

파일 보기

@ -558,4 +558,4 @@
}
]
}
]
]

파일 보기

@ -7,15 +7,8 @@
"p001_img05.jpg",
"p001_img06.jpg"
],
"2": [
"p002_img02.jpg",
"p002_img03.jpg"
],
"5": [
"p005_img01.jpg",
"p005_img02.jpg",
"p005_img03.jpg"
],
"2": ["p002_img02.jpg", "p002_img03.jpg"],
"5": ["p005_img01.jpg", "p005_img02.jpg", "p005_img03.jpg"],
"17": [
"p017_img01.jpg",
"p017_img02.jpg",
@ -28,13 +21,8 @@
"p017_img13.jpg",
"p017_img14.jpg"
],
"19": [
"p019_img01.jpg",
"p019_img02.jpg"
],
"21": [
"p021_img01.jpg"
],
"19": ["p019_img01.jpg", "p019_img02.jpg"],
"21": ["p021_img01.jpg"],
"22": [
"p022_img02.jpg",
"p022_img03.jpg",
@ -48,12 +36,8 @@
"p022_img16.jpg",
"p022_img17.jpg"
],
"23": [
"p023_img01.jpg"
],
"24": [
"p024_img01.jpg"
],
"23": ["p023_img01.jpg"],
"24": ["p024_img01.jpg"],
"25": [
"p025_img01.jpg",
"p025_img04.jpg",
@ -63,9 +47,7 @@
"p025_img12.jpg",
"p025_img14.jpg"
],
"26": [
"p026_img01.jpg"
],
"26": ["p026_img01.jpg"],
"27": [
"p027_img01.jpg",
"p027_img03.jpg",
@ -82,20 +64,10 @@
"p027_img18.jpg",
"p027_img19.jpg"
],
"28": [
"p028_img01.jpg"
],
"30": [
"p030_img01.jpg",
"p030_img02.jpg"
],
"32": [
"p032_img01.jpg",
"p032_img02.jpg"
],
"33": [
"p033_img01.jpg"
],
"28": ["p028_img01.jpg"],
"30": ["p030_img01.jpg", "p030_img02.jpg"],
"32": ["p032_img01.jpg", "p032_img02.jpg"],
"33": ["p033_img01.jpg"],
"34": [
"p034_img01.jpg",
"p034_img02.jpg",
@ -106,152 +78,50 @@
"p034_img16.jpg",
"p034_img17.jpg"
],
"35": [
"p035_img02.jpg",
"p035_img03.jpg",
"p035_img04.jpg"
],
"36": [
"p036_img03.jpg"
],
"37": [
"p037_img01.jpg",
"p037_img02.jpg"
],
"41": [
"p041_img01.jpg"
],
"43": [
"p043_img01.jpg"
],
"45": [
"p045_img01.jpg",
"p045_img02.jpg",
"p045_img03.jpg"
],
"46": [
"p046_img01.jpg"
],
"47": [
"p047_img01.jpg"
],
"48": [
"p048_img01.jpg"
],
"49": [
"p049_img01.jpg"
],
"51": [
"p051_img01.jpg"
],
"53": [
"p053_img01.jpg"
],
"56": [
"p056_img01.jpg"
],
"60": [
"p060_img01.jpg"
],
"61": [
"p061_img01.jpg"
],
"62": [
"p062_img01.jpg"
],
"63": [
"p063_img01.jpg"
],
"65": [
"p065_img01.jpg"
],
"66": [
"p066_img01.jpg",
"p066_img02.jpg"
],
"69": [
"p069_img01.jpg",
"p069_img02.jpg"
],
"74": [
"p074_img01.jpg",
"p074_img02.jpg",
"p074_img05.jpg"
],
"78": [
"p078_img01.jpg"
],
"79": [
"p079_img01.jpg"
],
"80": [
"p080_img01.jpg"
],
"82": [
"p082_img01.jpg",
"p082_img02.jpg"
],
"84": [
"p084_img01.jpg"
],
"85": [
"p085_img01.jpg"
],
"88": [
"p088_img01.jpg",
"p088_img02.jpg"
],
"90": [
"p090_img01.jpg"
],
"92": [
"p092_img01.jpg"
],
"93": [
"p093_img01.jpg"
],
"99": [
"p099_img01.jpg"
],
"100": [
"p100_img01.jpg"
],
"102": [
"p102_img01.jpg"
],
"108": [
"p108_img03.jpg",
"p108_img07.jpg",
"p108_img09.jpg"
],
"110": [
"p110_img01.jpg"
],
"111": [
"p111_img01.jpg"
],
"114": [
"p114_img01.jpg"
],
"117": [
"p117_img01.jpg"
],
"121": [
"p121_img01.jpg",
"p121_img02.jpg"
],
"122": [
"p122_img01.jpg"
],
"127": [
"p127_img01.jpg"
],
"129": [
"p129_img01.jpg"
],
"130": [
"p130_img01.jpg"
],
"35": ["p035_img02.jpg", "p035_img03.jpg", "p035_img04.jpg"],
"36": ["p036_img03.jpg"],
"37": ["p037_img01.jpg", "p037_img02.jpg"],
"41": ["p041_img01.jpg"],
"43": ["p043_img01.jpg"],
"45": ["p045_img01.jpg", "p045_img02.jpg", "p045_img03.jpg"],
"46": ["p046_img01.jpg"],
"47": ["p047_img01.jpg"],
"48": ["p048_img01.jpg"],
"49": ["p049_img01.jpg"],
"51": ["p051_img01.jpg"],
"53": ["p053_img01.jpg"],
"56": ["p056_img01.jpg"],
"60": ["p060_img01.jpg"],
"61": ["p061_img01.jpg"],
"62": ["p062_img01.jpg"],
"63": ["p063_img01.jpg"],
"65": ["p065_img01.jpg"],
"66": ["p066_img01.jpg", "p066_img02.jpg"],
"69": ["p069_img01.jpg", "p069_img02.jpg"],
"74": ["p074_img01.jpg", "p074_img02.jpg", "p074_img05.jpg"],
"78": ["p078_img01.jpg"],
"79": ["p079_img01.jpg"],
"80": ["p080_img01.jpg"],
"82": ["p082_img01.jpg", "p082_img02.jpg"],
"84": ["p084_img01.jpg"],
"85": ["p085_img01.jpg"],
"88": ["p088_img01.jpg", "p088_img02.jpg"],
"90": ["p090_img01.jpg"],
"92": ["p092_img01.jpg"],
"93": ["p093_img01.jpg"],
"99": ["p099_img01.jpg"],
"100": ["p100_img01.jpg"],
"102": ["p102_img01.jpg"],
"108": ["p108_img03.jpg", "p108_img07.jpg", "p108_img09.jpg"],
"110": ["p110_img01.jpg"],
"111": ["p111_img01.jpg"],
"114": ["p114_img01.jpg"],
"117": ["p117_img01.jpg"],
"121": ["p121_img01.jpg", "p121_img02.jpg"],
"122": ["p122_img01.jpg"],
"127": ["p127_img01.jpg"],
"129": ["p129_img01.jpg"],
"130": ["p130_img01.jpg"],
"133": [
"p133_img01.jpg",
"p133_img02.jpg",
@ -268,157 +138,48 @@
"p134_img05.jpg",
"p134_img06.jpg"
],
"135": [
"p135_img01.jpg"
],
"136": [
"p136_img01.jpg"
],
"140": [
"p140_img02.jpg"
],
"141": [
"p141_img01.jpg"
],
"143": [
"p143_img03.jpg",
"p143_img04.jpg",
"p143_img07.jpg"
],
"144": [
"p144_img01.jpg"
],
"150": [
"p150_img01.jpg"
],
"151": [
"p151_img01.jpg"
],
"155": [
"p155_img01.jpg"
],
"156": [
"p156_img01.jpg",
"p156_img02.jpg",
"p156_img03.jpg",
"p156_img04.jpg",
"p156_img05.jpg"
],
"158": [
"p158_img01.jpg"
],
"160": [
"p160_img01.jpg",
"p160_img02.jpg"
],
"161": [
"p161_img01.jpg",
"p161_img02.jpg"
],
"170": [
"p170_img05.jpg"
],
"180": [
"p180_img01.jpg",
"p180_img03.jpg"
],
"181": [
"p181_img05.jpg",
"p181_img06.jpg"
],
"190": [
"p190_img01.jpg"
],
"192": [
"p192_img01.jpg",
"p192_img02.jpg"
],
"195": [
"p195_img01.jpg"
],
"196": [
"p196_img01.jpg",
"p196_img02.jpg",
"p196_img03.jpg"
],
"198": [
"p198_img01.jpg"
],
"200": [
"p200_img01.jpg"
],
"202": [
"p202_img01.jpg"
],
"204": [
"p204_img01.jpg"
],
"206": [
"p206_img01.jpg"
],
"207": [
"p207_img01.jpg"
],
"208": [
"p208_img01.jpg"
],
"209": [
"p209_img01.jpg",
"p209_img02.jpg"
],
"210": [
"p210_img01.jpg"
],
"212": [
"p212_img01.jpg",
"p212_img02.jpg",
"p212_img03.jpg",
"p212_img04.jpg"
],
"213": [
"p213_img01.jpg"
],
"214": [
"p214_img01.jpg",
"p214_img02.jpg",
"p214_img03.jpg",
"p214_img04.jpg"
],
"217": [
"p217_img01.jpg"
],
"219": [
"p219_img01.jpg",
"p219_img02.jpg",
"p219_img03.jpg",
"p219_img04.jpg"
],
"226": [
"p226_img01.jpg",
"p226_img02.jpg"
],
"227": [
"p227_img01.jpg"
],
"228": [
"p228_img02.jpg"
],
"229": [
"p229_img01.jpg"
],
"230": [
"p230_img01.jpg"
],
"231": [
"p231_img09.jpg"
],
"236": [
"p236_img03.jpg"
],
"237": [
"p237_img01.jpg",
"p237_img02.jpg"
],
"135": ["p135_img01.jpg"],
"136": ["p136_img01.jpg"],
"140": ["p140_img02.jpg"],
"141": ["p141_img01.jpg"],
"143": ["p143_img03.jpg", "p143_img04.jpg", "p143_img07.jpg"],
"144": ["p144_img01.jpg"],
"150": ["p150_img01.jpg"],
"151": ["p151_img01.jpg"],
"155": ["p155_img01.jpg"],
"156": ["p156_img01.jpg", "p156_img02.jpg", "p156_img03.jpg", "p156_img04.jpg", "p156_img05.jpg"],
"158": ["p158_img01.jpg"],
"160": ["p160_img01.jpg", "p160_img02.jpg"],
"161": ["p161_img01.jpg", "p161_img02.jpg"],
"170": ["p170_img05.jpg"],
"180": ["p180_img01.jpg", "p180_img03.jpg"],
"181": ["p181_img05.jpg", "p181_img06.jpg"],
"190": ["p190_img01.jpg"],
"192": ["p192_img01.jpg", "p192_img02.jpg"],
"195": ["p195_img01.jpg"],
"196": ["p196_img01.jpg", "p196_img02.jpg", "p196_img03.jpg"],
"198": ["p198_img01.jpg"],
"200": ["p200_img01.jpg"],
"202": ["p202_img01.jpg"],
"204": ["p204_img01.jpg"],
"206": ["p206_img01.jpg"],
"207": ["p207_img01.jpg"],
"208": ["p208_img01.jpg"],
"209": ["p209_img01.jpg", "p209_img02.jpg"],
"210": ["p210_img01.jpg"],
"212": ["p212_img01.jpg", "p212_img02.jpg", "p212_img03.jpg", "p212_img04.jpg"],
"213": ["p213_img01.jpg"],
"214": ["p214_img01.jpg", "p214_img02.jpg", "p214_img03.jpg", "p214_img04.jpg"],
"217": ["p217_img01.jpg"],
"219": ["p219_img01.jpg", "p219_img02.jpg", "p219_img03.jpg", "p219_img04.jpg"],
"226": ["p226_img01.jpg", "p226_img02.jpg"],
"227": ["p227_img01.jpg"],
"228": ["p228_img02.jpg"],
"229": ["p229_img01.jpg"],
"230": ["p230_img01.jpg"],
"231": ["p231_img09.jpg"],
"236": ["p236_img03.jpg"],
"237": ["p237_img01.jpg", "p237_img02.jpg"],
"238": [
"p238_img01.jpg",
"p238_img02.jpg",
@ -427,22 +188,10 @@
"p238_img05.jpg",
"p238_img06.jpg"
],
"239": [
"p239_img01.jpg",
"p239_img02.jpg",
"p239_img03.jpg"
],
"242": [
"p242_img02.jpg",
"p242_img03.jpg",
"p242_img04.jpg"
],
"244": [
"p244_img01.jpg"
],
"245": [
"p245_img01.jpg"
],
"239": ["p239_img01.jpg", "p239_img02.jpg", "p239_img03.jpg"],
"242": ["p242_img02.jpg", "p242_img03.jpg", "p242_img04.jpg"],
"244": ["p244_img01.jpg"],
"245": ["p245_img01.jpg"],
"248": [
"p248_img01.jpg",
"p248_img02.jpg",
@ -457,140 +206,39 @@
"p248_img13.jpg",
"p248_img14.jpg"
],
"249": [
"p249_img01.jpg"
],
"250": [
"p250_img01.jpg"
],
"254": [
"p254_img01.jpg"
],
"257": [
"p257_img01.jpg",
"p257_img02.jpg",
"p257_img03.jpg",
"p257_img04.jpg"
],
"259": [
"p259_img01.jpg"
],
"262": [
"p262_img01.jpg"
],
"263": [
"p263_img04.jpg"
],
"264": [
"p264_img01.jpg",
"p264_img02.jpg"
],
"266": [
"p266_img01.jpg",
"p266_img02.jpg"
],
"267": [
"p267_img03.jpg",
"p267_img04.jpg",
"p267_img05.jpg"
],
"268": [
"p268_img01.jpg"
],
"272": [
"p272_img01.jpg",
"p272_img02.jpg",
"p272_img03.jpg",
"p272_img04.jpg"
],
"273": [
"p273_img01.jpg"
],
"274": [
"p274_img01.jpg"
],
"275": [
"p275_img01.jpg",
"p275_img02.jpg"
],
"276": [
"p276_img01.jpg",
"p276_img02.jpg"
],
"278": [
"p278_img01.jpg",
"p278_img02.jpg",
"p278_img03.jpg"
],
"279": [
"p279_img01.jpg"
],
"280": [
"p280_img01.jpg"
],
"281": [
"p281_img01.jpg"
],
"283": [
"p283_img01.jpg",
"p283_img02.jpg",
"p283_img03.jpg",
"p283_img04.jpg",
"p283_img05.jpg"
],
"286": [
"p286_img01.jpg",
"p286_img02.jpg"
],
"287": [
"p287_img01.jpg",
"p287_img02.jpg",
"p287_img03.jpg",
"p287_img04.jpg"
],
"290": [
"p290_img01.jpg",
"p290_img02.jpg",
"p290_img03.jpg"
],
"293": [
"p293_img03.jpg"
],
"294": [
"p294_img01.jpg",
"p294_img02.jpg",
"p294_img03.jpg",
"p294_img04.jpg"
],
"298": [
"p298_img01.jpg",
"p298_img02.jpg"
],
"306": [
"p306_img01.jpg",
"p306_img02.jpg"
],
"307": [
"p307_img03.jpg"
],
"309": [
"p309_img01.jpg",
"p309_img02.jpg"
],
"312": [
"p312_img01.jpg"
],
"314": [
"p314_img01.jpg"
],
"315": [
"p315_img01.jpg"
],
"316": [
"p316_img01.jpg"
],
"337": [
"p337_img01.jpg",
"p337_img02.jpg"
]
}
"249": ["p249_img01.jpg"],
"250": ["p250_img01.jpg"],
"254": ["p254_img01.jpg"],
"257": ["p257_img01.jpg", "p257_img02.jpg", "p257_img03.jpg", "p257_img04.jpg"],
"259": ["p259_img01.jpg"],
"262": ["p262_img01.jpg"],
"263": ["p263_img04.jpg"],
"264": ["p264_img01.jpg", "p264_img02.jpg"],
"266": ["p266_img01.jpg", "p266_img02.jpg"],
"267": ["p267_img03.jpg", "p267_img04.jpg", "p267_img05.jpg"],
"268": ["p268_img01.jpg"],
"272": ["p272_img01.jpg", "p272_img02.jpg", "p272_img03.jpg", "p272_img04.jpg"],
"273": ["p273_img01.jpg"],
"274": ["p274_img01.jpg"],
"275": ["p275_img01.jpg", "p275_img02.jpg"],
"276": ["p276_img01.jpg", "p276_img02.jpg"],
"278": ["p278_img01.jpg", "p278_img02.jpg", "p278_img03.jpg"],
"279": ["p279_img01.jpg"],
"280": ["p280_img01.jpg"],
"281": ["p281_img01.jpg"],
"283": ["p283_img01.jpg", "p283_img02.jpg", "p283_img03.jpg", "p283_img04.jpg", "p283_img05.jpg"],
"286": ["p286_img01.jpg", "p286_img02.jpg"],
"287": ["p287_img01.jpg", "p287_img02.jpg", "p287_img03.jpg", "p287_img04.jpg"],
"290": ["p290_img01.jpg", "p290_img02.jpg", "p290_img03.jpg"],
"293": ["p293_img03.jpg"],
"294": ["p294_img01.jpg", "p294_img02.jpg", "p294_img03.jpg", "p294_img04.jpg"],
"298": ["p298_img01.jpg", "p298_img02.jpg"],
"306": ["p306_img01.jpg", "p306_img02.jpg"],
"307": ["p307_img03.jpg"],
"309": ["p309_img01.jpg", "p309_img02.jpg"],
"312": ["p312_img01.jpg"],
"314": ["p314_img01.jpg"],
"315": ["p315_img01.jpg"],
"316": ["p316_img01.jpg"],
"337": ["p337_img01.jpg", "p337_img02.jpg"]
}

파일 보기

@ -1,135 +1,158 @@
import { useState, useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import { GoogleOAuthProvider } from '@react-oauth/google'
import type { MainTab } from '@common/types/navigation'
import { MainLayout } from '@common/components/layout/MainLayout'
import { LoginPage } from '@common/components/auth/LoginPage'
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore'
import { useMenuStore } from '@common/store/menuStore'
import { useMapStore } from '@common/store/mapStore'
import { API_BASE_URL } from '@common/services/api'
import { OilSpillView } from '@tabs/prediction'
import { ReportsView } from '@tabs/reports'
import { HNSView } from '@tabs/hns'
import { AerialView } from '@tabs/aerial'
import { AssetsView } from '@tabs/assets'
import { BoardView } from '@tabs/board'
import { WeatherView } from '@tabs/weather'
import { IncidentsView } from '@tabs/incidents'
import { AdminView } from '@tabs/admin'
import { ScatView } from '@tabs/scat'
import { RescueView } from '@tabs/rescue'
import { DesignPage } from '@/pages/design/DesignPage'
import { useState, useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { GoogleOAuthProvider } from '@react-oauth/google';
import type { MainTab } from '@common/types/navigation';
import { MainLayout } from '@common/components/layout/MainLayout';
import { LoginPage } from '@common/components/auth/LoginPage';
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
import { useAuthStore } from '@common/store/authStore';
import { useMenuStore } from '@common/store/menuStore';
import { useMapStore } from '@common/store/mapStore';
import { API_BASE_URL } from '@common/services/api';
import { OilSpillView } from '@tabs/prediction';
import { ReportsView } from '@tabs/reports';
import { HNSView } from '@tabs/hns';
import { AerialView } from '@tabs/aerial';
import { AssetsView } from '@tabs/assets';
import { BoardView } from '@tabs/board';
import { WeatherView } from '@tabs/weather';
import { IncidentsView } from '@tabs/incidents';
import { AdminView } from '@tabs/admin';
import { ScatView } from '@tabs/scat';
import { RescueView } from '@tabs/rescue';
import { DesignPage } from '@/pages/design/DesignPage';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
function App() {
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
const { loadMenuConfig } = useMenuStore()
const { loadMapTypes } = useMapStore()
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction');
const { isAuthenticated, isLoading, checkSession } = useAuthStore();
const { loadMenuConfig } = useMenuStore();
const { loadMapTypes } = useMapStore();
useEffect(() => {
checkSession()
}, [checkSession])
checkSession();
}, [checkSession]);
useEffect(() => {
if (isAuthenticated) {
loadMenuConfig()
loadMapTypes()
loadMenuConfig();
loadMapTypes();
}
}, [isAuthenticated, loadMenuConfig, loadMapTypes])
}, [isAuthenticated, loadMenuConfig, loadMapTypes]);
useEffect(() => {
registerMainTabSwitcher(setActiveMainTab)
}, [])
registerMainTabSwitcher(setActiveMainTab);
}, []);
// 감사 로그: 탭 이동 기록
useEffect(() => {
if (!isAuthenticated) return
const blob = new Blob(
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' }
)
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
}, [activeMainTab, isAuthenticated])
if (!isAuthenticated) return;
const blob = new Blob([JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], {
type: 'text/plain',
});
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
}, [activeMainTab, isAuthenticated]);
// 세션 확인 중 스플래시
if (isLoading) {
return (
<div style={{
width: '100vw', height: '100vh', display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'var(--bg-base)', gap: 16,
}}>
<img src="/wing_logo_text_white.svg" alt="WING" className="wing-logo" style={{ height: 28, opacity: 0.8 }} />
<div style={{
width: 32, height: 32, border: '3px solid rgba(6,182,212,0.2)',
borderTop: '3px solid rgba(6,182,212,0.8)', borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite',
}} />
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-base)',
gap: 16,
}}
>
<img
src="/wing_logo_text_white.svg"
alt="WING"
className="wing-logo"
style={{ height: 28, opacity: 0.8 }}
/>
<div
style={{
width: 32,
height: 32,
border: '3px solid rgba(6,182,212,0.2)',
borderTop: '3px solid rgba(6,182,212,0.8)',
borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite',
}}
/>
</div>
)
);
}
// 미인증 → 로그인 페이지
if (!isAuthenticated) {
return <LoginPage />
return <LoginPage />;
}
const renderView = () => {
switch (activeMainTab) {
case 'prediction':
return <OilSpillView />
return <OilSpillView />;
case 'reports':
return <ReportsView />
return <ReportsView />;
case 'hns':
return <HNSView />
return <HNSView />;
case 'aerial':
return <AerialView />
return <AerialView />;
case 'assets':
return <AssetsView />
return <AssetsView />;
case 'board':
return <BoardView />
return <BoardView />;
case 'weather':
return <WeatherView />
return <WeatherView />;
case 'incidents':
return <IncidentsView />
return <IncidentsView />;
case 'scat':
return <ScatView />
return <ScatView />;
case 'admin':
return <AdminView />
return <AdminView />;
case 'rescue':
return <RescueView />
return <RescueView />;
case 'monitor':
return null
return null;
default:
return <div className="flex items-center justify-center h-full text-fg-disabled"> ...</div>
return (
<div className="flex items-center justify-center h-full text-fg-disabled">
...
</div>
);
}
}
};
return (
<Routes>
<Route path="/design" element={<DesignPage />} />
<Route path="*" element={
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
{renderView()}
</MainLayout>
} />
<Route
path="*"
element={
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
{renderView()}
</MainLayout>
}
/>
</Routes>
)
);
}
function AppWithProviders() {
if (!GOOGLE_CLIENT_ID) {
return <App />
return <App />;
}
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<App />
</GoogleOAuthProvider>
)
);
}
export default AppWithProviders
export default AppWithProviders;

파일 보기

@ -1,81 +1,93 @@
import { useState, useEffect } from 'react'
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google'
import { useAuthStore } from '../../store/authStore'
import { useState, useEffect } from 'react';
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google';
import { useAuthStore } from '../../store/authStore';
/* Demo accounts (개발 모드 전용) */
const DEMO_ACCOUNTS = [
{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' },
]
const DEMO_ACCOUNTS = [{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' }];
export function LoginPage() {
const [userId, setUserId] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const { login, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore()
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(false);
const { login, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore();
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID;
useEffect(() => {
const saved = localStorage.getItem('wing_remember')
const saved = localStorage.getItem('wing_remember');
if (saved) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserId(saved)
setRemember(true)
setUserId(saved);
setRemember(true);
}
}, [])
}, []);
const handleGoogleSuccess = async (response: CredentialResponse) => {
if (response.credential) {
clearError()
clearError();
try {
await googleLogin(response.credential)
await googleLogin(response.credential);
} catch {
// 에러는 authStore에서 관리
}
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
clearError()
e.preventDefault();
clearError();
if (!userId.trim() || !password.trim()) {
return
return;
}
try {
await login(userId.trim(), password)
await login(userId.trim(), password);
if (remember) {
localStorage.setItem('wing_remember', userId.trim())
localStorage.setItem('wing_remember', userId.trim());
} else {
localStorage.removeItem('wing_remember')
localStorage.removeItem('wing_remember');
}
} catch {
// 에러는 authStore에서 관리
}
}
};
return (
<div className="w-screen h-screen flex overflow-hidden relative bg-bg-base">
{/* Background image */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: 'url(/24.png)',
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
}} />
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'url(/24.png)',
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
}}
/>
{/* Overlay */}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(90deg, rgba(0,8,20,0.4) 0%, rgba(0,8,20,0.2) 25%, rgba(0,8,20,0.05) 50%, rgba(0,8,20,0.15) 75%, rgba(0,8,20,0.5) 100%)',
}} />
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(0,6,18,0.15) 0%, transparent 30%, transparent 70%, rgba(0,6,18,0.4) 100%)',
}} />
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(90deg, rgba(0,8,20,0.4) 0%, rgba(0,8,20,0.2) 25%, rgba(0,8,20,0.05) 50%, rgba(0,8,20,0.15) 75%, rgba(0,8,20,0.5) 100%)',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, rgba(0,6,18,0.15) 0%, transparent 30%, transparent 70%, rgba(0,6,18,0.4) 100%)',
}}
/>
{/* Center: Login Form */}
<div className="w-full flex flex-col items-start justify-center relative z-[1] px-[120px] py-[40px]" style={{ paddingLeft: 120, paddingRight: 50 }}>
<div
className="w-full flex flex-col items-start justify-center relative z-[1] px-[120px] py-[40px]"
style={{ paddingLeft: 120, paddingRight: 50 }}
>
<div style={{ width: '100%', maxWidth: 360 }}>
{/* Logo */}
<div className="text-center mb-9">
@ -87,229 +99,345 @@ export function LoginPage() {
</div>
{/* Form card */}
<div style={{
padding: '32px 28px', borderRadius: 12,
background: 'linear-gradient(180deg, rgba(4,16,36,0.88) 0%, rgba(2,10,26,0.92) 100%)',
border: '1px solid rgba(60,120,180,0.12)',
backdropFilter: 'blur(20px)',
boxShadow: '0 8px 48px rgba(0,0,0,0.5)',
}}>
<form onSubmit={handleSubmit}>
{/* User ID */}
<div className="mb-4">
<label className="block text-[10px] font-semibold text-fg-disabled mb-1.5" style={{ letterSpacing: '0.3px' }}>
</label>
<div className="relative">
<span className="absolute text-sm text-fg-disabled pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<input
type="text"
value={userId}
onChange={(e) => { setUserId(e.target.value); clearError() }}
placeholder="사용자 아이디 입력"
autoComplete="username"
autoFocus
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--stroke-default)'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<div
style={{
padding: '32px 28px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(4,16,36,0.88) 0%, rgba(2,10,26,0.92) 100%)',
border: '1px solid rgba(60,120,180,0.12)',
backdropFilter: 'blur(20px)',
boxShadow: '0 8px 48px rgba(0,0,0,0.5)',
}}
>
<form onSubmit={handleSubmit}>
{/* User ID */}
<div className="mb-4">
<label
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<input
type="text"
value={userId}
onChange={(e) => {
setUserId(e.target.value);
clearError();
}}
placeholder="사용자 아이디 입력"
autoComplete="username"
autoFocus
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--stroke-default)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
</div>
</div>
{/* Password */}
<div className="mb-5">
<label className="block text-[10px] font-semibold text-fg-disabled mb-1.5" style={{ letterSpacing: '0.3px' }}>
</label>
<div className="relative">
<span className="absolute text-sm text-fg-disabled pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); clearError() }}
placeholder="비밀번호 입력"
autoComplete="current-password"
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--stroke-default)'
e.currentTarget.style.boxShadow = 'none'
}}
/>
{/* Password */}
<div className="mb-5">
<label
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
style={{ letterSpacing: '0.3px' }}
>
</label>
<div className="relative">
<span
className="absolute text-sm text-fg-disabled pointer-events-none"
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError();
}}
placeholder="비밀번호 입력"
autoComplete="current-password"
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
style={{
padding: '11px 14px 11px 38px',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--stroke-default)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
</div>
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between mb-5">
<label className="flex items-center gap-1.5 text-[11px] text-fg-disabled cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
</label>
<button type="button" className="text-[11px] text-color-accent cursor-pointer bg-transparent border-none"
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
{/* Remember + Forgot */}
<div className="flex items-center justify-between mb-5">
<label className="flex items-center gap-1.5 text-[11px] text-fg-disabled cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
</label>
<button
type="button"
className="text-[11px] text-color-accent cursor-pointer bg-transparent border-none"
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
</button>
</div>
{/* Pending approval */}
{pendingMessage && (
<div
className="flex items-start gap-2 text-[11px] rounded-sm mb-4"
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.08)',
border: '1px solid rgba(6,182,212,0.2)',
color: '#67e8f9',
}}
>
<span className="text-sm shrink-0 mt-px">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</span>
<span>{pendingMessage}</span>
</div>
)}
{/* Error */}
{error && (
<div
className="flex items-center gap-1.5 text-[11px] rounded-sm mb-4"
style={{
padding: '8px 12px',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171',
}}
>
<span className="text-[13px]">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
</span>
{error}
</div>
)}
{/* Login button */}
<button
type="submit"
disabled={isLoading}
className="w-full text-color-accent text-sm font-bold rounded-md border"
style={{
padding: '12px',
background: isLoading
? 'rgba(6,182,212,0.15)'
: 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))',
borderColor: 'rgba(6,182,212,0.3)',
cursor: isLoading ? 'wait' : 'pointer',
transition: 'all 0.2s',
boxShadow: '0 4px 16px rgba(6,182,212,0.1)',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(6,182,212,0.3), rgba(59,130,246,0.2))';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(6,182,212,0.15)';
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(6,182,212,0.1)';
}
}}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span
style={{
width: 14,
height: 14,
border: '2px solid rgba(6,182,212,0.3)',
borderTop: '2px solid var(--color-accent)',
borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite',
display: 'inline-block',
}}
/>
...
</span>
) : (
'로그인'
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 bg-border h-px" />
<span className="text-[9px] text-fg-disabled"></span>
<div className="flex-1 bg-border h-px" />
</div>
{/* Google / Certificate */}
<div className="flex flex-col gap-2">
{GOOGLE_ENABLED && (
<div className="flex justify-center rounded-md overflow-hidden">
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => {
/* 팝업 닫힘 등 — 별도 처리 불필요 */
}}
theme="filled_black"
size="large"
shape="rectangular"
width={304}
/>
</div>
)}
<button
type="button"
className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
style={{ transition: 'background 0.15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</button>
</div>
{/* Pending approval */}
{pendingMessage && (
<div className="flex items-start gap-2 text-[11px] rounded-sm mb-4" style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)',
color: '#67e8f9',
}}>
<span className="text-sm shrink-0 mt-px">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</span>
<span>{pendingMessage}</span>
{/* Demo accounts info (DEV only) */}
{import.meta.env.DEV && (
<div
className="rounded-md mt-6"
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.04)',
border: '1px solid rgba(6,182,212,0.08)',
}}
>
<div className="text-[9px] font-bold text-color-accent mb-1.5"> </div>
<div className="flex flex-col gap-[3px]">
{DEMO_ACCOUNTS.map((acc) => (
<div
key={acc.id}
onClick={() => {
setUserId(acc.id);
setPassword(acc.password);
clearError();
}}
className="flex justify-between items-center cursor-pointer rounded"
style={{
padding: '4px 6px',
transition: 'background 0.15s',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = 'rgba(6,182,212,0.06)')
}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span className="text-[9px] text-fg-sub font-mono">
{acc.id} / {acc.password}
</span>
<span className="text-[8px] text-fg-disabled">{acc.label}</span>
</div>
))}
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-1.5 text-[11px] rounded-sm mb-4" style={{
padding: '8px 12px',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
color: '#f87171',
}}>
<span className="text-[13px]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
</span>
{error}
</div>
)}
{/* Login button */}
<button type="submit" disabled={isLoading} className="w-full text-color-accent text-sm font-bold rounded-md border"
style={{
padding: '12px',
background: isLoading
? 'rgba(6,182,212,0.15)'
: 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))',
borderColor: 'rgba(6,182,212,0.3)',
cursor: isLoading ? 'wait' : 'pointer',
transition: 'all 0.2s',
boxShadow: '0 4px 16px rgba(6,182,212,0.1)',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.3), rgba(59,130,246,0.2))'
e.currentTarget.style.boxShadow = '0 6px 24px rgba(6,182,212,0.15)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))'
e.currentTarget.style.boxShadow = '0 4px 16px rgba(6,182,212,0.1)'
}
}}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span style={{
width: 14, height: 14, border: '2px solid rgba(6,182,212,0.3)',
borderTop: '2px solid var(--color-accent)', borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite', display: 'inline-block',
}} />
...
</span>
) : '로그인'}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 bg-border h-px" />
<span className="text-[9px] text-fg-disabled"></span>
<div className="flex-1 bg-border h-px" />
</div>
{/* Google / Certificate */}
<div className="flex flex-col gap-2">
{GOOGLE_ENABLED && (
<div className="flex justify-center rounded-md overflow-hidden">
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => { /* 팝업 닫힘 등 — 별도 처리 불필요 */ }}
theme="filled_black"
size="large"
shape="rectangular"
width={304}
/>
</div>
)}
<button type="button" className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
style={{ transition: 'background 0.15s' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-surface-hover)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg-card)'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
</button>
</div>
{/* Demo accounts info (DEV only) */}
{import.meta.env.DEV && (
<div className="rounded-md mt-6" style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)',
}}>
<div className="text-[9px] font-bold text-color-accent mb-1.5">
</div>
<div className="flex flex-col gap-[3px]">
{DEMO_ACCOUNTS.map((acc) => (
<div key={acc.id}
onClick={() => { setUserId(acc.id); setPassword(acc.password); clearError() }}
className="flex justify-between items-center cursor-pointer rounded"
style={{
padding: '4px 6px',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(6,182,212,0.06)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<span className="text-[9px] text-fg-sub font-mono">
{acc.id} / {acc.password}
</span>
<span className="text-[8px] text-fg-disabled">
{acc.label}
</span>
</div>
))}
</div>
</div>
)}
</div>{/* end form card */}
{/* end form card */}
{/* Footer */}
<div className="text-center text-[9px] text-fg-disabled mt-6 leading-[1.6]">
@ -321,5 +449,5 @@ export function LoginPage() {
</div>
</div>
</div>
)
);
}

파일 보기

@ -1,42 +1,55 @@
import { useState, useRef, useEffect } from 'react'
import type { Layer } from '@common/services/layerService'
import { useState, useRef, useEffect } from 'react';
import type { Layer } from '@common/services/layerService';
const PRESET_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
'#3b82f6','#8b5cf6','#a855f7','#ec4899','#f43f5e',
'#64748b','#ffffff',
]
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#a855f7',
'#ec4899',
'#f43f5e',
'#64748b',
'#ffffff',
];
interface LayerTreeProps {
layers: (Layer & { children?: Layer[] })[]
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
layerColors?: Record<string, string>
onColorChange?: (layerId: string, color: string) => void
layers: (Layer & { children?: Layer[] })[];
enabledLayers: Set<string>;
onToggleLayer: (layerId: string, enabled: boolean) => void;
layerColors?: Record<string, string>;
onColorChange?: (layerId: string, color: string) => void;
}
export function LayerTree({ layers, enabledLayers, onToggleLayer, layerColors = {}, onColorChange }: LayerTreeProps) {
const allLeafIds = getAllLeafIds(layers)
const allEnabled = allLeafIds.length > 0 && allLeafIds.every(id => enabledLayers.has(id))
export function LayerTree({
layers,
enabledLayers,
onToggleLayer,
layerColors = {},
onColorChange,
}: LayerTreeProps) {
const allLeafIds = getAllLeafIds(layers);
const allEnabled = allLeafIds.length > 0 && allLeafIds.every((id) => enabledLayers.has(id));
const handleToggleAll = () => {
const newState = !allEnabled
getAllNodeIds(layers).forEach(id => onToggleLayer(id, newState))
}
const newState = !allEnabled;
getAllNodeIds(layers).forEach((id) => onToggleLayer(id, newState));
};
return (
<div className="px-1">
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-stroke">
<span className="text-[10px] font-semibold text-fg-disabled">
</span>
<span className="text-[10px] font-semibold text-fg-disabled"> </span>
<div
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
onClick={handleToggleAll}
/>
</div>
{layers.map(layer => (
{layers.map((layer) => (
<LayerNode
key={layer.id}
layer={layer}
@ -48,69 +61,76 @@ export function LayerTree({ layers, enabledLayers, onToggleLayer, layerColors =
/>
))}
</div>
)
);
}
function getAllLeafIds(layers: (Layer & { children?: Layer[] })[]): string[] {
const ids: string[] = []
const ids: string[] = [];
for (const l of layers) {
if (l.children && l.children.length > 0) {
ids.push(...getAllLeafIds(l.children))
ids.push(...getAllLeafIds(l.children));
} else {
ids.push(l.id)
ids.push(l.id);
}
}
return ids
return ids;
}
function getAllNodeIds(layers: (Layer & { children?: Layer[] })[]): string[] {
const ids: string[] = []
const ids: string[] = [];
for (const l of layers) {
ids.push(l.id)
ids.push(l.id);
if (l.children && l.children.length > 0) {
ids.push(...getAllNodeIds(l.children))
ids.push(...getAllNodeIds(l.children));
}
}
return ids
return ids;
}
interface LayerNodeProps {
layer: Layer & { children?: Layer[] }
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
layerColors: Record<string, string>
onColorChange?: (layerId: string, color: string) => void
depth: number
layer: Layer & { children?: Layer[] };
enabledLayers: Set<string>;
onToggleLayer: (layerId: string, enabled: boolean) => void;
layerColors: Record<string, string>;
onColorChange?: (layerId: string, color: string) => void;
depth: number;
}
function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorChange, depth }: LayerNodeProps) {
const [expanded, setExpanded] = useState(depth < 1)
const hasChildren = layer.children && layer.children.length > 0
const isEnabled = enabledLayers.has(layer.id)
function LayerNode({
layer,
enabledLayers,
onToggleLayer,
layerColors,
onColorChange,
depth,
}: LayerNodeProps) {
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = layer.children && layer.children.length > 0;
const isEnabled = enabledLayers.has(layer.id);
const getAllDescendantIds = (node: Layer & { children?: Layer[] }): string[] => {
const ids: string[] = []
const ids: string[] = [];
if (node.children) {
for (const child of node.children) {
ids.push(child.id)
ids.push(...getAllDescendantIds(child))
ids.push(child.id);
ids.push(...getAllDescendantIds(child));
}
}
return ids
}
return ids;
};
const handleSwitchClick = (e: React.MouseEvent) => {
e.stopPropagation()
const newState = !isEnabled
onToggleLayer(layer.id, newState)
e.stopPropagation();
const newState = !isEnabled;
onToggleLayer(layer.id, newState);
if (hasChildren) {
getAllDescendantIds(layer).forEach(id => onToggleLayer(id, newState))
getAllDescendantIds(layer).forEach((id) => onToggleLayer(id, newState));
}
}
};
const handleHeaderClick = () => {
if (hasChildren) setExpanded(!expanded)
}
if (hasChildren) setExpanded(!expanded);
};
// depth 0 — 대분류 (lyr-g1 / lyr-h1)
if (depth === 0) {
@ -130,14 +150,25 @@ function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorCh
)}
</div>
{hasChildren && (
<div className={`lyr-c1 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '800px' : '0' }}>
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
<div
className={`lyr-c1 ${expanded ? '' : 'collapsed'}`}
style={{ maxHeight: expanded ? '800px' : '0' }}
>
{layer.children!.map((child) => (
<LayerNode
key={child.id}
layer={child}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onColorChange}
depth={depth + 1}
/>
))}
</div>
)}
</div>
)
);
}
// depth 1 — 중분류 (lyr-g2 / lyr-h2) 또는 leaf
@ -145,16 +176,26 @@ function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorCh
// 자식 없는 depth 1 → leaf로 렌더링 (해양관측·기상 하위 등)
if (!hasChildren) {
return (
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
<div
className="lyr-t"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e);
}}
>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
{layer.icon && <span>{layer.icon}</span>}
<span className="flex-1">{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
{layer.count !== undefined && (
<span className="lyr-cnt">{layer.count.toLocaleString()}</span>
)}
{onColorChange && (
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
<ColorSwatch
color={layerColors[layer.id]}
onChange={(c) => onColorChange(layer.id, c)}
/>
)}
</div>
)
);
}
return (
@ -172,81 +213,122 @@ function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorCh
<span className="lyr-h2-cnt">{layer.count.toLocaleString()}</span>
)}
</div>
<div className={`lyr-c2 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '600px' : '0' }}>
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
<div
className={`lyr-c2 ${expanded ? '' : 'collapsed'}`}
style={{ maxHeight: expanded ? '600px' : '0' }}
>
{layer.children!.map((child) => (
<LayerNode
key={child.id}
layer={child}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onColorChange}
depth={depth + 1}
/>
))}
</div>
</div>
)
);
}
// depth 2+ leaf — 색상 스와치 포함
if (!hasChildren) {
return (
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
<div
className="lyr-t"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e);
}}
>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
{layer.icon && <span>{layer.icon}</span>}
<span className="flex-1">{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
{layer.count !== undefined && (
<span className="lyr-cnt">{layer.count.toLocaleString()}</span>
)}
{onColorChange && (
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
)}
</div>
)
);
}
// depth 2+ with children
return (
<div>
<div className="lyr-t gap-1.5">
<span className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-[7px] w-[10px] text-center`} onClick={handleHeaderClick}></span>
<span
className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-[7px] w-[10px] text-center`}
onClick={handleHeaderClick}
>
</span>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
{layer.icon && <span>{layer.icon}</span>}
<span onClick={handleHeaderClick} className="cursor-pointer flex-1">{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
<span onClick={handleHeaderClick} className="cursor-pointer flex-1">
{layer.name}
</span>
{layer.count !== undefined && (
<span className="lyr-cnt">{layer.count.toLocaleString()}</span>
)}
</div>
{expanded && (
<div className="pl-4">
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
{layer.children!.map((child) => (
<LayerNode
key={child.id}
layer={child}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onColorChange}
depth={depth + 1}
/>
))}
</div>
)}
</div>
)
);
}
// 색상 스와치 + 피커
function ColorSwatch({ color, onChange }: { color?: string; onChange: (c: string) => void }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} className="relative shrink-0">
<div
className={`lyr-csw ${color ? 'has-color' : ''}`}
style={color ? { borderColor: color, background: color } : {}}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
onClick={(e) => {
e.stopPropagation();
setOpen(!open);
}}
/>
{open && (
<div className="lyr-cpop show" onClick={(e) => e.stopPropagation()}>
<div className="lyr-cpr">
{PRESET_COLORS.map(pc => (
{PRESET_COLORS.map((pc) => (
<div
key={pc}
className={`lyr-cpr-i ${color === pc ? 'sel' : ''}`}
style={{ background: pc }}
onClick={() => { onChange(pc); setOpen(false) }}
onClick={() => {
onChange(pc);
setOpen(false);
}}
/>
))}
</div>
@ -255,11 +337,14 @@ function ColorSwatch({ color, onChange }: { color?: string; onChange: (c: string
<input
type="color"
value={color || '#06b6d4'}
onChange={(e) => { onChange(e.target.value); setOpen(false) }}
onChange={(e) => {
onChange(e.target.value);
setOpen(false);
}}
/>
</div>
</div>
)}
</div>
)
);
}

파일 보기

@ -1,12 +1,12 @@
import type { ReactNode } from 'react'
import type { MainTab } from '../../types/navigation'
import { TopBar } from './TopBar'
import { SubMenuBar } from './SubMenuBar'
import type { ReactNode } from 'react';
import type { MainTab } from '../../types/navigation';
import { TopBar } from './TopBar';
import { SubMenuBar } from './SubMenuBar';
interface MainLayoutProps {
children: ReactNode
activeMainTab: MainTab
onMainTabChange: (tab: MainTab) => void
children: ReactNode;
activeMainTab: MainTab;
onMainTabChange: (tab: MainTab) => void;
}
export function MainLayout({ children, activeMainTab, onMainTabChange }: MainLayoutProps) {
@ -19,9 +19,7 @@ export function MainLayout({ children, activeMainTab, onMainTabChange }: MainLay
<SubMenuBar activeMainTab={activeMainTab} />
{/* Main Content Area */}
<div className="flex flex-1 overflow-hidden">
{children}
</div>
<div className="flex flex-1 overflow-hidden">{children}</div>
</div>
)
);
}

파일 보기

@ -1,16 +1,16 @@
import type { MainTab } from '../../types/navigation'
import { useSubMenu } from '../../hooks/useSubMenu'
import type { MainTab } from '../../types/navigation';
import { useSubMenu } from '../../hooks/useSubMenu';
interface SubMenuBarProps {
activeMainTab: MainTab
activeMainTab: MainTab;
}
export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
const { activeSubTab, setActiveSubTab, subMenuConfig } = useSubMenu(activeMainTab)
const { activeSubTab, setActiveSubTab, subMenuConfig } = useSubMenu(activeMainTab);
// 서브 메뉴가 없는 탭은 표시하지 않음
if (!subMenuConfig || subMenuConfig.length === 0) {
return null
return null;
}
return (
@ -21,20 +21,15 @@ export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
key={item.id}
onClick={() => setActiveSubTab(item.id)}
className={`
px-4 py-2.5 text-[0.8125rem] font-bold transition-all duration-200
font-korean tracking-tight
${
activeSubTab === item.id
? 'text-color-accent bg-[rgba(6,182,212,0.12)] border-b-2 border-color-accent'
: 'text-fg-sub hover:text-fg hover:bg-[rgba(255,255,255,0.06)]'
}
px-4 py-2.5 text-title-5 font-medium transition-all duration-200
font-korean tracking-navigation
${activeSubTab === item.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'}
`}
>
<span className="mr-1.5">{item.icon}</span>
{item.label}
</button>
))}
</div>
</div>
)
);
}

파일 보기

@ -1,27 +1,27 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import type { MainTab } from '../../types/navigation'
import { useAuthStore } from '../../store/authStore'
import { useMenuStore } from '../../store/menuStore'
import { useMapStore } from '../../store/mapStore'
import { useThemeStore } from '../../store/themeStore'
import UserManualPopup from '../ui/UserManualPopup'
import { useState, useRef, useEffect, useMemo } from 'react';
import type { MainTab } from '../../types/navigation';
import { useAuthStore } from '../../store/authStore';
import { useMenuStore } from '../../store/menuStore';
import { useMapStore } from '../../store/mapStore';
import { useThemeStore } from '../../store/themeStore';
import UserManualPopup from '../ui/UserManualPopup';
interface TopBarProps {
activeTab: MainTab
onTabChange: (tab: MainTab) => void
activeTab: MainTab;
onTabChange: (tab: MainTab) => void;
}
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
const [showQuickMenu, setShowQuickMenu] = useState(false)
const [showManual, setShowManual] = useState(false)
const quickMenuRef = useRef<HTMLDivElement>(null)
const { hasPermission, user, logout } = useAuthStore()
const { menuConfig, isLoaded } = useMenuStore()
const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore()
const { theme, toggleTheme } = useThemeStore()
const [showQuickMenu, setShowQuickMenu] = useState(false);
const [showManual, setShowManual] = useState(false);
const quickMenuRef = useRef<HTMLDivElement>(null);
const { hasPermission, user, logout } = useAuthStore();
const { menuConfig, isLoaded } = useMenuStore();
const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore();
const { theme, toggleTheme } = useThemeStore();
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'incidents'])
const isMapTab = MAP_TABS.has(activeTab)
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'incidents']);
const isMapTab = MAP_TABS.has(activeTab);
const handleToggleMeasure = (mode: 'distance' | 'area') => {
if (!isMapTab) return;
@ -30,20 +30,21 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
};
const tabs = useMemo(() => {
if (!isLoaded || menuConfig.length === 0) return []
if (!isLoaded || menuConfig.length === 0) return [];
return menuConfig
.filter((m) => m.enabled && hasPermission(m.id))
.sort((a, b) => a.order - b.order)
}, [hasPermission, user?.permissions, menuConfig, isLoaded])
.sort((a, b) => a.order - b.order);
}, [hasPermission, user?.permissions, menuConfig, isLoaded]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (quickMenuRef.current && !quickMenuRef.current.contains(e.target as Node)) setShowQuickMenu(false)
}
if (showQuickMenu) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showQuickMenu])
if (quickMenuRef.current && !quickMenuRef.current.contains(e.target as Node))
setShowQuickMenu(false);
};
if (showQuickMenu) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showQuickMenu]);
return (
<div className="h-[52px] bg-bg-surface border-b border-stroke flex items-center justify-between px-5 relative z-[100]">
@ -55,7 +56,11 @@ 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-3.5 wing-logo"
/>
</button>
{/* Divider */}
@ -64,54 +69,57 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* Tabs */}
<div className="flex gap-0.5">
{tabs.map((tab) => {
const isIncident = tab.id === 'incidents'
const isMonitor = tab.id === 'monitor'
const isIncident = tab.id === 'incidents';
const isMonitor = tab.id === 'monitor';
const handleClick = () => {
if (isMonitor) {
window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank')
window.open(
import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev',
'_blank',
);
} else {
onTabChange(tab.id as MainTab)
onTabChange(tab.id as MainTab);
}
}
};
return (
<button
key={tab.id}
onClick={handleClick}
title={tab.label}
className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[0.8125rem] transition-all duration-200
font-korean tracking-[0.2px]
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
${isMonitor ? 'border-l border-l-[rgba(239,68,68,0.25)] ml-1 flex items-center gap-1.5' : ''}
<button
key={tab.id}
onClick={handleClick}
title={tab.label}
className={`
px-2.5 xl:px-4 py-2 text-title-4 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' : ''}
${
isMonitor
? 'text-color-danger hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
? 'text-color-danger'
: activeTab === tab.id
? isIncident
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
: 'text-color-accent bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
? 'text-[#a5b4fc] border-b-[#a5b4fc]'
: 'text-color-accent border-b-color-accent'
: isIncident
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
: 'text-fg-sub hover:text-fg hover:bg-[var(--hover-overlay)]'
? 'text-[#818cf8] hover:text-[#a5b4fc]'
: 'text-fg-sub hover:text-fg'
}
`}
>
{isMonitor ? (
<>
<span className="hidden xl:flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse inline-block" />
{tab.label}
</span>
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
</>
) : (
<>
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
</>
)}
</button>
)
>
{isMonitor ? (
<>
<span className="hidden xl:flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse inline-block" />
{tab.label}
</span>
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
</>
) : (
<>
<span className="xl:hidden text-[1rem] leading-none">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
</>
)}
</button>
);
})}
</div>
</div>
@ -125,9 +133,9 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
</div> */}
{/* Icon Buttons */}
<button className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all">
{/* <button className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all">
🔔
</button>
</button> */}
{hasPermission('admin') && (
<button
onClick={() => onTabChange('admin')}
@ -142,10 +150,10 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
)}
{user && (
<div className="flex items-center gap-2 pl-2 border-l border-stroke">
<span className="text-[0.6875rem] text-fg-sub font-korean">{user.name}</span>
<span className="text-label-2 text-fg-sub font-korean">{user.name}</span>
<button
onClick={() => logout()}
className="px-2 py-1 text-[0.625rem] font-semibold text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover hover:text-fg transition-all font-korean"
className="px-2 py-1 text-label-2 font-medium text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover hover:text-fg transition-all font-korean"
title="로그아웃"
>
@ -163,19 +171,31 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
: 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg'
}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="2" y1="4" x2="14" y2="4" /><line x1="2" y1="8" x2="14" y2="8" /><line x1="2" y1="12" x2="14" y2="12" /></svg>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="2" y1="4" x2="14" y2="4" />
<line x1="2" y1="8" x2="14" y2="8" />
<line x1="2" y1="12" x2="14" y2="12" />
</svg>
</button>
{showQuickMenu && (
<div className="absolute top-[44px] right-0 w-[220px] bg-[var(--dropdown-bg)] backdrop-blur-xl border border-stroke rounded-lg shadow-2xl z-[200] py-2 font-korean">
{/* 거리·면적 계산 */}
{/* <div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
{/* <div className="px-3 py-1.5 flex items-center gap-2 text-title-6 font-bold text-fg-disabled">
<span>📐</span> ·
</div> */}
<button
onClick={() => handleToggleMeasure('distance')}
disabled={!isMapTab}
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] transition-all ${
className={`w-full px-3 py-2 flex items-center gap-2.5 text-title-5 transition-all ${
!isMapTab
? 'text-fg-disabled opacity-40 cursor-not-allowed'
: measureMode === 'distance'
@ -183,13 +203,15 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
: 'text-fg hover:bg-[var(--hover-overlay)]'
}`}
>
<span className="text-[0.8125rem]"></span>
{measureMode === 'distance' && <span className="ml-auto text-[0.625rem] text-color-accent"></span>}
<span className="text-title-4"></span>
{measureMode === 'distance' && (
<span className="ml-auto text-title-6 text-color-accent"></span>
)}
</button>
<button
onClick={() => handleToggleMeasure('area')}
disabled={!isMapTab}
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] transition-all ${
className={`w-full px-3 py-2 flex items-center gap-2.5 text-title-5 transition-all ${
!isMapTab
? 'text-fg-disabled opacity-40 cursor-not-allowed'
: measureMode === 'area'
@ -197,36 +219,49 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
: 'text-fg hover:bg-[var(--hover-overlay)]'
}`}
>
<span className="text-[0.8125rem]"></span>
{measureMode === 'area' && <span className="ml-auto text-[0.625rem] text-color-accent"></span>}
<span className="text-title-4"></span>
{measureMode === 'area' && (
<span className="ml-auto text-title-6 text-color-accent"></span>
)}
</button>
<div className="my-1.5 border-t border-stroke" />
{/* 출력 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
<div className="px-3 py-1.5 flex items-center gap-2 text-title-6 font-bold text-fg-disabled">
<span>🖨</span>
</div>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all">
<span className="text-[0.8125rem]">📸</span>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-title-5 text-fg hover:bg-[var(--hover-overlay)] transition-all">
<span className="text-title-4">📸</span>
</button>
<button onClick={() => window.print()} className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all">
<span className="text-[0.8125rem]">🖨</span>
<button
onClick={() => window.print()}
className="w-full px-3 py-2 flex items-center gap-2.5 text-title-5 text-fg hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="text-title-4">🖨</span>
</button>
<div className="my-1.5 border-t border-stroke" />
{/* 지도 유형 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[0.6875rem] font-bold text-fg-disabled">
<div className="px-3 py-1.5 flex items-center gap-2 text-title-6 font-bold text-fg-disabled">
<span>🗺</span>
</div>
{mapTypes.map(item => (
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[0.75rem] text-fg-sub hover:bg-[var(--hover-overlay)] transition-all">
{mapTypes.map((item) => (
<button
key={item.mapKey}
onClick={() => toggleMap(item.mapKey)}
className="w-full px-3 py-2 flex items-center justify-between text-title-5 text-fg-sub hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="flex items-center gap-2.5">
<span className="text-[0.8125rem]">🗺</span> {item.mapNm}
<span className="text-title-4">🗺</span> {item.mapNm}
</span>
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}>
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`} />
<div
className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
>
<div
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`}
/>
</div>
</button>
))}
@ -235,19 +270,28 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* 테마 전환 */}
<button
onClick={() => { toggleTheme(); setShowQuickMenu(false); }}
className="w-full px-3 py-2 flex items-center justify-between text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all"
onClick={() => {
toggleTheme();
setShowQuickMenu(false);
}}
className="w-full px-3 py-2 flex items-center justify-between text-title-5 text-fg hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="flex items-center gap-2.5">
<span className="text-[0.8125rem]">{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}</span>
<span className="text-title-4">
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
</span>
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
</span>
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${
theme === 'light' ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
}`}>
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${
theme === 'light' ? 'left-[16px]' : 'left-[2px]'
}`} />
<div
className={`w-[34px] h-[18px] rounded-full transition-all relative ${
theme === 'light' ? 'bg-color-accent' : 'bg-bg-card border border-stroke'
}`}
>
<div
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${
theme === 'light' ? 'left-[16px]' : 'left-[2px]'
}`}
/>
</div>
</button>
@ -256,12 +300,12 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* 매뉴얼 */}
<button
onClick={() => {
setShowManual(true)
setShowQuickMenu(false)
setShowManual(true);
setShowQuickMenu(false);
}}
className="w-full px-3 py-2 flex items-center gap-2.5 text-[0.75rem] text-fg hover:bg-[var(--hover-overlay)] transition-all"
className="w-full px-3 py-2 flex items-center gap-2.5 text-title-5 text-fg hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="text-[0.8125rem]">&#x1F4D6;</span>
<span className="text-title-4">&#x1F4D6;</span>
</button>
</div>
)}
@ -271,5 +315,5 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* 사용자 매뉴얼 팝업 */}
<UserManualPopup isOpen={showManual} onClose={() => setShowManual(false)} />
</div>
)
);
}

파일 보기

@ -1,19 +1,19 @@
import { useRef, useEffect } from 'react'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { useRef, useEffect } from 'react';
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack';
interface BacktrackReplayBarProps {
isPlaying: boolean
replayFrame: number
totalFrames: number
replaySpeed: number
onTogglePlay: () => void
onSeek: (frame: number) => void
onSpeedChange: (speed: number) => void
onClose: () => void
replayShips: ReplayShip[]
collisionEvent: CollisionEvent | null
replayTimeRange?: { start: string; end: string }
hasBackwardParticles?: boolean
isPlaying: boolean;
replayFrame: number;
totalFrames: number;
replaySpeed: number;
onTogglePlay: () => void;
onSeek: (frame: number) => void;
onSpeedChange: (speed: number) => void;
onClose: () => void;
replayShips: ReplayShip[];
collisionEvent: CollisionEvent | null;
replayTimeRange?: { start: string; end: string };
hasBackwardParticles?: boolean;
}
export function BacktrackReplayBar({
@ -30,94 +30,107 @@ export function BacktrackReplayBar({
replayTimeRange,
hasBackwardParticles,
}: BacktrackReplayBarProps) {
const progress = (replayFrame / totalFrames) * 100
const isFinished = replayFrame >= totalFrames
const progress = (replayFrame / totalFrames) * 100;
const isFinished = replayFrame >= totalFrames;
// 드래그 시크
const barRef = useRef<HTMLDivElement>(null)
const isDraggingRef = useRef(false)
const onSeekRef = useRef(onSeek)
const totalFramesRef = useRef(totalFrames)
const barRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const onSeekRef = useRef(onSeek);
const totalFramesRef = useRef(totalFrames);
useEffect(() => { onSeekRef.current = onSeek }, [onSeek])
useEffect(() => { totalFramesRef.current = totalFrames }, [totalFrames])
useEffect(() => {
onSeekRef.current = onSeek;
}, [onSeek]);
useEffect(() => {
totalFramesRef.current = totalFrames;
}, [totalFrames]);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current || !barRef.current) return
const rect = barRef.current.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeekRef.current(Math.round(pct * totalFramesRef.current))
}
const onMouseUp = () => { isDraggingRef.current = false }
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
if (!isDraggingRef.current || !barRef.current) return;
const rect = barRef.current.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onSeekRef.current(Math.round(pct * totalFramesRef.current));
};
const onMouseUp = () => {
isDraggingRef.current = false;
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
}, [])
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, []);
const handleBarMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
isDraggingRef.current = true
if (!barRef.current) return
const rect = barRef.current.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeek(Math.round(pct * totalFrames))
}
isDraggingRef.current = true;
if (!barRef.current) return;
const rect = barRef.current.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onSeek(Math.round(pct * totalFrames));
};
// 재생 완료 후 재생 버튼 클릭 → 처음부터 재시작
const handlePlayClick = () => {
if (!isPlaying && isFinished) {
onSeek(0)
onSeek(0);
}
onTogglePlay()
}
onTogglePlay();
};
// 타임 계산
let startLabel: string
let endLabel: string
let currentTimeLabel: string
let startLabel: string;
let endLabel: string;
let currentTimeLabel: string;
if (replayTimeRange) {
const startMs = new Date(replayTimeRange.start).getTime()
const endMs = new Date(replayTimeRange.end).getTime()
const currentMs = startMs + (replayFrame / totalFrames) * (endMs - startMs)
const startMs = new Date(replayTimeRange.start).getTime();
const endMs = new Date(replayTimeRange.end).getTime();
const currentMs = startMs + (replayFrame / totalFrames) * (endMs - startMs);
const fmt = (ms: number) => {
const d = new Date(ms + 9 * 3600000) // KST
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
const hh = String(d.getUTCHours()).padStart(2, '0')
const mm = String(d.getUTCMinutes()).padStart(2, '0')
return { date: `${mo}-${day}`, time: `${hh}:${mm}` }
}
const startFmt = fmt(startMs)
const endFmt = fmt(endMs)
const curFmt = fmt(currentMs)
startLabel = `${startFmt.date} ${startFmt.time}`
endLabel = `${endFmt.date} ${endFmt.time}`
currentTimeLabel = `${curFmt.date} ${curFmt.time} KST`
const d = new Date(ms + 9 * 3600000); // KST
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
return { date: `${mo}-${day}`, time: `${hh}:${mm}` };
};
const startFmt = fmt(startMs);
const endFmt = fmt(endMs);
const curFmt = fmt(currentMs);
startLabel = `${startFmt.date} ${startFmt.time}`;
endLabel = `${endFmt.date} ${endFmt.time}`;
currentTimeLabel = `${curFmt.date} ${curFmt.time} KST`;
} else {
// 기존 하드코딩 폴백
const hours = 18.5 + (replayFrame / totalFrames) * 12
const displayHours = hours >= 24 ? hours - 24 : hours
const h = Math.floor(displayHours)
const m = Math.round((displayHours - h) * 60)
const dayLabel = hours >= 24 ? '02-10' : '02-09'
startLabel = '18:30'
endLabel = '06:30'
currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`
const hours = 18.5 + (replayFrame / totalFrames) * 12;
const displayHours = hours >= 24 ? hours - 24 : hours;
const h = Math.floor(displayHours);
const m = Math.round((displayHours - h) * 60);
const dayLabel = hours >= 24 ? '02-10' : '02-09';
startLabel = '18:30';
endLabel = '06:30';
currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`;
}
return (
<div
className="absolute flex flex-col"
style={{
bottom: '80px', left: '50%', transform: 'translateX(-50%)',
minWidth: '480px', maxWidth: '680px', width: '60%',
background: 'rgba(10,15,25,0.92)', backdropFilter: 'blur(12px)',
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '12px',
padding: '12px 18px', zIndex: 1200,
bottom: '80px',
left: '50%',
transform: 'translateX(-50%)',
minWidth: '480px',
maxWidth: '680px',
width: '60%',
background: 'rgba(10,15,25,0.92)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(168,85,247,0.3)',
borderRadius: '12px',
padding: '12px 18px',
zIndex: 1200,
gap: '10px',
}}
>
@ -128,9 +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-xs font-bold"> </span>
</div>
<div className="flex items-center gap-1.5">
@ -152,8 +163,11 @@ export function BacktrackReplayBar({
onClick={onClose}
className="text-color-danger cursor-pointer font-bold"
style={{
padding: '4px 10px', borderRadius: '6px', fontSize: '10px',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '10px',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
@ -194,7 +208,8 @@ export function BacktrackReplayBar({
style={{
width: `${progress}%`,
background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))',
borderRadius: '2px', transition: 'width 0.05s',
borderRadius: '2px',
transition: 'width 0.05s',
}}
/>
@ -222,7 +237,8 @@ export function BacktrackReplayBar({
transform: 'translate(-50%, -50%)',
border: '3px solid var(--color-tertiary)',
boxShadow: '0 0 8px rgba(168,85,247,0.4)',
zIndex: 2, transition: 'left 0.05s',
zIndex: 2,
transition: 'left 0.05s',
}}
/>
</div>
@ -241,9 +257,7 @@ export function BacktrackReplayBar({
{replayShips.map((ship) => (
<div key={ship.vesselName} className="flex items-center gap-1.5">
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
<span className="text-[9px] text-fg-sub font-mono">
{ship.vesselName}
</span>
<span className="text-[9px] text-fg-sub font-mono">{ship.vesselName}</span>
</div>
))}
{hasBackwardParticles && (
@ -254,5 +268,5 @@ export function BacktrackReplayBar({
)}
</div>
</div>
)
);
}

파일 보기

@ -1,90 +1,112 @@
import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers'
import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack'
import { hexToRgba } from './mapUtils'
import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers';
import type {
ReplayShip,
CollisionEvent,
ReplayPathPoint,
BackwardParticleStep,
} from '@common/types/backtrack';
import { hexToRgba } from './mapUtils';
// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산
function convexHull(points: [number, number][]): [number, number][] {
if (points.length < 3) return points
if (points.length < 3) return points;
const cross = (O: [number, number], A: [number, number], B: [number, number]) =>
(A[0] - O[0]) * (B[1] - O[1]) - (A[1] - O[1]) * (B[0] - O[0])
const sorted = [...points].sort((a, b) => a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1])
const lower: [number, number][] = []
(A[0] - O[0]) * (B[1] - O[1]) - (A[1] - O[1]) * (B[0] - O[0]);
const sorted = [...points].sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]));
const lower: [number, number][] = [];
for (const p of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop()
lower.push(p)
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
lower.pop();
lower.push(p);
}
const upper: [number, number][] = []
const upper: [number, number][] = [];
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i]
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop()
upper.push(p)
const p = sorted[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
upper.pop();
upper.push(p);
}
lower.pop()
upper.pop()
return [...lower, ...upper]
lower.pop();
upper.pop();
return [...lower, ...upper];
}
function getInterpolatedPosition(
path: ReplayPathPoint[],
frame: number,
totalFrames: number
totalFrames: number,
): { lat: number; lon: number; segmentIndex: number } {
const progress = Math.min(frame / totalFrames, 1)
const floatIndex = progress * (path.length - 1)
const idx = Math.min(Math.floor(floatIndex), path.length - 2)
const frac = floatIndex - idx
const progress = Math.min(frame / totalFrames, 1);
const floatIndex = progress * (path.length - 1);
const idx = Math.min(Math.floor(floatIndex), path.length - 2);
const frac = floatIndex - idx;
return {
lat: path[idx].lat + (path[idx + 1].lat - path[idx].lat) * frac,
lon: path[idx].lon + (path[idx + 1].lon - path[idx].lon) * frac,
segmentIndex: idx,
}
};
}
interface BacktrackReplayParams {
replayShips: ReplayShip[]
collisionEvent: CollisionEvent | null
replayFrame: number
totalFrames: number
incidentCoord: { lat: number; lon: number }
backwardParticles?: BackwardParticleStep[]
replayShips: ReplayShip[];
collisionEvent: CollisionEvent | null;
replayFrame: number;
totalFrames: number;
incidentCoord: { lat: number; lon: number };
backwardParticles?: BackwardParticleStep[];
}
const BACKWARD_COLOR: [number, number, number, number] = [168, 85, 247, 160]
const BACKWARD_COLOR: [number, number, number, number] = [168, 85, 247, 160];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord, backwardParticles } = params
const {
replayShips,
collisionEvent,
replayFrame,
totalFrames,
incidentCoord,
backwardParticles,
} = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = []
const progress = replayFrame / totalFrames
const layers: any[] = [];
const progress = replayFrame / totalFrames;
// Per-ship track lines + waypoints + ship position
const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> = []
const allWaypoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
const allShipPositions: Array<{ position: [number, number]; color: [number, number, number, number]; name: string }> = []
const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> =
[];
const allWaypoints: Array<{
position: [number, number];
color: [number, number, number, number];
}> = [];
const allShipPositions: Array<{
position: [number, number];
color: [number, number, number, number];
name: string;
}> = [];
replayShips.forEach((ship) => {
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames)
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames);
const trackPath: [number, number][] = ship.path
.slice(0, pos.segmentIndex + 2)
.map((p, i, arr) => {
if (i === arr.length - 1) return [pos.lon, pos.lat]
return [p.lon, p.lat]
})
if (i === arr.length - 1) return [pos.lon, pos.lat];
return [p.lon, p.lat];
});
const rgba = hexToRgba(ship.color, 180)
allTrackData.push({ path: trackPath, color: rgba })
const rgba = hexToRgba(ship.color, 180);
allTrackData.push({ path: trackPath, color: rgba });
ship.path.slice(0, pos.segmentIndex + 1).forEach((p) => {
allWaypoints.push({ position: [p.lon, p.lat], color: hexToRgba(ship.color, 130) })
})
allWaypoints.push({ position: [p.lon, p.lat], color: hexToRgba(ship.color, 130) });
});
allShipPositions.push({
position: [pos.lon, pos.lat],
color: hexToRgba(ship.color),
name: ship.vesselName,
})
})
});
});
// Track lines
layers.push(
@ -98,8 +120,8 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
dashJustified: true,
widthMinPixels: 2,
extensions: [],
})
)
}),
);
// Waypoint dots
layers.push(
@ -111,8 +133,8 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
getFillColor: (d: (typeof allWaypoints)[0]) => d.color,
radiusMinPixels: 3,
radiusMaxPixels: 5,
})
)
}),
);
// Ship position markers
layers.push(
@ -128,12 +150,12 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
radiusMinPixels: 8,
radiusMaxPixels: 14,
pickable: true,
})
)
}),
);
// Collision point
const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75
const showCollision = progress >= collisionProgress
const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75;
const showCollision = progress >= collisionProgress;
if (showCollision && collisionEvent) {
layers.push(
@ -148,11 +170,14 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
stroked: true,
radiusMinPixels: 12,
pickable: true,
})
)
}),
);
// Oil spill expansion
const spillSize = Math.min(500, ((progress - collisionProgress) / (1 - collisionProgress)) * 500)
const spillSize = Math.min(
500,
((progress - collisionProgress) / (1 - collisionProgress)) * 500,
);
if (spillSize > 0) {
layers.push(
new ScatterplotLayer({
@ -165,17 +190,17 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
getLineWidth: 1,
stroked: true,
radiusUnits: 'meters' as const,
})
)
}),
);
}
}
// 역방향 예측 파티클 + 전체 경로 외각 폴리곤
if (backwardParticles && backwardParticles.length > 0) {
// 전체 스텝의 모든 파티클을 합쳐 외각 폴리곤 계산 (정적 — 항상 표시)
const allPoints = backwardParticles.flat()
const allPoints = backwardParticles.flat();
if (allPoints.length >= 3) {
const hull = convexHull(allPoints)
const hull = convexHull(allPoints);
if (hull.length >= 3) {
layers.push(
new PolygonLayer({
@ -188,14 +213,14 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
stroked: true,
filled: true,
lineWidthMinPixels: 1,
})
)
}),
);
}
}
// 현재 프레임 파티클
const stepIndex = Math.round((1 - progress) * (backwardParticles.length - 1))
const particles = backwardParticles[stepIndex] ?? []
const stepIndex = Math.round((1 - progress) * (backwardParticles.length - 1));
const particles = backwardParticles[stepIndex] ?? [];
if (particles.length > 0) {
layers.push(
new ScatterplotLayer({
@ -206,10 +231,10 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
getFillColor: BACKWARD_COLOR,
radiusMinPixels: 2.5,
radiusMaxPixels: 5,
})
)
}),
);
}
}
return layers
return layers;
}

파일 보기

@ -11,10 +11,13 @@ const PARTICLE_COUNT = 3000;
const MAX_AGE = 300;
const SPEED_SCALE = 0.1;
const DT = 600;
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
interface TrailPoint { x: number; y: number; }
interface TrailPoint {
x: number;
y: number;
}
interface Particle {
lon: number;
lat: number;
@ -22,7 +25,10 @@ interface Particle {
age: number;
}
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
export default function HydrParticleOverlay({
hydrStep,
lightMode = false,
}: HydrParticleOverlayProps) {
const { current: map } = useMap();
const animRef = useRef<number>();
@ -37,7 +43,10 @@ export default function HydrParticleOverlay({ hydrStep, lightMode = false }: Hyd
container.appendChild(canvas);
const ctx = canvas.getContext('2d')!;
const { value: [u2d, v2d], grid } = hydrStep;
const {
value: [u2d, v2d],
grid,
} = hydrStep;
const { boundLonLat, lonInterval, latInterval } = grid;
const lons: number[] = [boundLonLat.left];
@ -46,22 +55,35 @@ export default function HydrParticleOverlay({ hydrStep, lightMode = false }: Hyd
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
function getUV(lon: number, lat: number): [number, number] {
let col = -1, row = -1;
let col = -1,
row = -1;
for (let i = 0; i < lons.length - 1; i++) {
if (lon >= lons[i] && lon < lons[i + 1]) { col = i; break; }
if (lon >= lons[i] && lon < lons[i + 1]) {
col = i;
break;
}
}
for (let i = 0; i < lats.length - 1; i++) {
if (lat >= lats[i] && lat < lats[i + 1]) { row = i; break; }
if (lat >= lats[i] && lat < lats[i + 1]) {
row = i;
break;
}
}
if (col < 0 || row < 0) return [0, 0];
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
const u00 = u2d[row]?.[col] ?? 0, u01 = u2d[row]?.[col + 1] ?? u00;
const u10 = u2d[row + 1]?.[col] ?? u00, u11 = u2d[row + 1]?.[col + 1] ?? u00;
const v00 = v2d[row]?.[col] ?? 0, v01 = v2d[row]?.[col + 1] ?? v00;
const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00;
const u = u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
const v = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
const u00 = u2d[row]?.[col] ?? 0,
u01 = u2d[row]?.[col + 1] ?? u00;
const u10 = u2d[row + 1]?.[col] ?? u00,
u11 = u2d[row + 1]?.[col + 1] ?? u00;
const v00 = v2d[row]?.[col] ?? 0,
v01 = v2d[row]?.[col + 1] ?? v00;
const v10 = v2d[row + 1]?.[col] ?? v00,
v11 = v2d[row + 1]?.[col + 1] ?? v00;
const u =
u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
const v =
v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
return [u, v];
}
@ -81,7 +103,9 @@ export default function HydrParticleOverlay({ hydrStep, lightMode = false }: Hyd
}
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
const onMove = () => { for (const p of particles) p.trail = []; };
const onMove = () => {
for (const p of particles) p.trail = [];
};
map.on('move', onMove);
function animate() {
@ -89,24 +113,34 @@ export default function HydrParticleOverlay({ hydrStep, lightMode = false }: Hyd
ctx.clearRect(0, 0, canvas.width, canvas.height);
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
const bands: [number, number, number, number][][] =
Array.from({ length: NUM_ALPHA_BANDS }, () => []);
const bands: [number, number, number, number][][] = Array.from(
{ length: NUM_ALPHA_BANDS },
() => [],
);
for (const p of particles) {
const [u, v] = getUV(p.lon, p.lat);
const speed = Math.sqrt(u * u + v * v);
if (speed < 0.001) { resetParticle(p); continue; }
if (speed < 0.001) {
resetParticle(p);
continue;
}
const cosLat = Math.cos(p.lat * Math.PI / 180);
p.lon += u * SPEED_SCALE * DT / (cosLat * 111320);
p.lat += v * SPEED_SCALE * DT / 111320;
const cosLat = Math.cos((p.lat * Math.PI) / 180);
p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320);
p.lat += (v * SPEED_SCALE * DT) / 111320;
p.age++;
if (
p.lon < bbox.left || p.lon > bbox.right ||
p.lat < bbox.bottom || p.lat > bbox.top ||
p.lon < bbox.left ||
p.lon > bbox.right ||
p.lat < bbox.bottom ||
p.lat > bbox.top ||
p.age > MAX_AGE
) { resetParticle(p); continue; }
) {
resetParticle(p);
continue;
}
const curr = map.project([p.lon, p.lat]);
if (!curr) continue;
@ -116,9 +150,10 @@ export default function HydrParticleOverlay({ hydrStep, lightMode = false }: Hyd
if (p.trail.length < 2) continue;
for (let i = 1; i < p.trail.length; i++) {
const t = i / p.trail.length; // 0=oldest, 1=newest
const t = i / p.trail.length; // 0=oldest, 1=newest
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
const a = p.trail[i - 1], b = p.trail[i];
const a = p.trail[i - 1],
b = p.trail[i];
bands[band].push([a.x, a.y, b.x, b.y]);
}
}

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

파일 보기

@ -10,10 +10,7 @@ export function MeasureOverlay() {
const markers = useMemo(() => {
return measurements.map((m) => {
const pos =
m.mode === 'distance'
? midpointOf(m.points[0], m.points[1])
: centroid(m.points);
const pos = m.mode === 'distance' ? midpointOf(m.points[0], m.points[1]) : centroid(m.points);
return { id: m.id, lon: pos[0], lat: pos[1] };
});
}, [measurements]);

파일 보기

@ -1,13 +1,13 @@
/** 색상 문자열(#rrggbb 또는 rgba(...))을 deck.gl용 RGBA 배열로 변환 */
export function hexToRgba(color: string, alpha = 255): [number, number, number, number] {
// rgba(r,g,b,a) 형식 처리
const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbaMatch) {
return [Number(rgbaMatch[1]), Number(rgbaMatch[2]), Number(rgbaMatch[3]), alpha]
return [Number(rgbaMatch[1]), Number(rgbaMatch[2]), Number(rgbaMatch[3]), alpha];
}
// hex #rrggbb 형식 처리
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return [isNaN(r) ? 0 : r, isNaN(g) ? 0 : g, isNaN(b) ? 0 : b, alpha]
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return [isNaN(r) ? 0 : r, isNaN(g) ? 0 : g, isNaN(b) ? 0 : b, alpha];
}

파일 보기

@ -13,10 +13,7 @@ function midpoint(a: MeasurePoint, b: MeasurePoint): [number, number] {
export function centroid(pts: MeasurePoint[]): [number, number] {
const n = pts.length;
return [
pts.reduce((s, p) => s + p.lon, 0) / n,
pts.reduce((s, p) => s + p.lat, 0) / n,
];
return [pts.reduce((s, p) => s + p.lon, 0) / n, pts.reduce((s, p) => s + p.lat, 0) / n];
}
function toPos(pt: MeasurePoint): [number, number] {

파일 보기

@ -1,35 +1,35 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect } from 'react';
interface ComboBoxOption {
value: string
label: string
value: string;
label: string;
}
interface ComboBoxProps {
value: string | number
onChange: (value: string) => void
options: ComboBoxOption[]
placeholder?: string
className?: string
value: string | number;
onChange: (value: string) => void;
options: ComboBoxOption[];
placeholder?: string;
className?: string;
}
export function ComboBox({ value, onChange, options, placeholder, className }: ComboBoxProps) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
setIsOpen(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const selectedOption = options.find(opt => opt.value === String(value))
const displayText = selectedOption?.label || placeholder || '선택'
const selectedOption = options.find((opt) => opt.value === String(value));
const displayText = selectedOption?.label || placeholder || '선택';
return (
<div ref={containerRef} className="relative">
@ -42,7 +42,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
className="text-[8px] text-fg-disabled"
style={{
transition: 'transform 0.2s',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)'
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
}}
>
@ -57,15 +57,15 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
borderRadius: 'var(--radius-sm)',
maxHeight: '200px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
animation: 'fadeSlideDown 0.15s ease-out'
animation: 'fadeSlideDown 0.15s ease-out',
}}
>
{options.map((option) => (
<div
key={option.value}
onClick={() => {
onChange(option.value)
setIsOpen(false)
onChange(option.value);
setIsOpen(false);
}}
className="text-[11px] cursor-pointer"
style={{
@ -73,16 +73,19 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',
background: option.value === String(value) ? 'rgba(6,182,212,0.1)' : 'transparent',
transition: '0.1s',
borderLeft: option.value === String(value) ? '2px solid var(--color-accent)' : '2px solid transparent'
borderLeft:
option.value === String(value)
? '2px solid var(--color-accent)'
: '2px solid transparent',
}}
onMouseEnter={(e) => {
if (option.value !== String(value)) {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.background = 'rgba(255,255,255,0.03)';
}
}}
onMouseLeave={(e) => {
if (option.value !== String(value)) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.background = 'transparent';
}
}}
>
@ -92,5 +95,5 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
</div>
)}
</div>
)
);
}

파일 보기

@ -47,13 +47,13 @@ const CHAPTERS: Chapter[] = [
overview:
'해양 유출유 사고 발생 시 오염원 위치와 유출 조건을 직접 입력하여 확산 범위를 예측하는 주요 분석 화면이다. KOSPS·POSEIDON·OpenDrift·앙상블의 4종 수치 모델을 선택적으로 적용할 수 있다. 실시간 기상·해양 데이터와 연계하여 즉시 예측 결과를 지도에 표출한다.',
description:
'화면 좌측에 예측정보 입력 패널(사고명, 위치, 유종, 유출량, 예측 시간)이 위치한다. 중앙 지도에서 클릭하여 사고 발생 위치를 직접 지정하거나 위·경도 좌표를 수동 입력할 수 있다. 하단 타임라인 슬라이더로 시간 경과에 따른 확산 경과를 재생할 수 있다. 우측 \'분석 요약\' 패널에서 예측 면적, 이동거리, 이동 방향, 풍화 비율 등을 확인할 수 있다.',
"화면 좌측에 예측정보 입력 패널(사고명, 위치, 유종, 유출량, 예측 시간)이 위치한다. 중앙 지도에서 클릭하여 사고 발생 위치를 직접 지정하거나 위·경도 좌표를 수동 입력할 수 있다. 하단 타임라인 슬라이더로 시간 경과에 따른 확산 경과를 재생할 수 있다. 우측 '분석 요약' 패널에서 예측 면적, 이동거리, 이동 방향, 풍화 비율 등을 확인할 수 있다.",
procedure: [
'상단 메뉴에서 \'유출유확산예측 > 유출유확산분석\'을 클릭하여 화면을 이동한다.',
"상단 메뉴에서 '유출유확산예측 > 유출유확산분석'을 클릭하여 화면을 이동한다.",
'좌측 패널에서 사고명, 날짜, 유종, 유출량, 예측 시간을 입력한다.',
'지도를 클릭하거나 좌표 입력란에 위·경도를 직접 입력하여 사고 위치를 지정한다.',
'적용할 확산 모델(KOSPS·POSEIDON·OpenDrift·앙상블) 체크박스를 선택한다.',
'\'확산예측 실행\' 버튼을 클릭하여 예측을 시작한다.',
"'확산예측 실행' 버튼을 클릭하여 예측을 시작한다.",
'하단 타임라인 재생 버튼으로 시간별 확산 결과를 확인한다.',
],
inputs: [
@ -63,10 +63,15 @@ const CHAPTERS: Chapter[] = [
{ label: '유종', type: '드롭다운', required: true, desc: '벙커C유·경유·연료유 등' },
{ label: '유출량', type: '숫자 kL', required: true, desc: '유출 유류 총량' },
{ label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' },
{ label: '적용 모델', type: '체크박스', required: true, desc: 'KOSPS·POSEIDON·OpenDrift·앙상블 중 선택' },
{
label: '적용 모델',
type: '체크박스',
required: true,
desc: 'KOSPS·POSEIDON·OpenDrift·앙상블 중 선택',
},
],
notes: [
'좌표 입력 후 반드시 \'적용\' 버튼을 클릭해야 지도에 반영된다.',
"좌표 입력 후 반드시 '적용' 버튼을 클릭해야 지도에 반영된다.",
'앙상블 모델 선택 시 3개 모델이 동시 실행되어 계산 시간이 증가할 수 있다.',
'유출량이 0 이하이거나 예측 시간이 입력되지 않으면 실행 버튼이 비활성화된다.',
],
@ -79,12 +84,12 @@ const CHAPTERS: Chapter[] = [
overview:
'KOSPS·POSEIDON·OpenDrift 3개 모델의 예측 결과를 동시에 지도에 표출하여 모델 간 비교 분석을 지원한다. 모델별 입자 궤적(파란점·빨간점·하늘점)이 중첩 표시되어 확산 경향의 편차를 한눈에 파악할 수 있다.',
description:
'지도에 모델별 색상 구분 입자가 동시에 표시되며, 각 모델의 확산 방향과 범위 차이를 시각적으로 비교할 수 있다. 우측 \'분석 요약\' 패널에 예측 면적(km2), 이동거리(km), 방향, 이동속도(cm/s)가 표출된다. 풍화 상태 막대그래프(유출량·해면연소·자연분산·수중분산·잔류 비율)를 제공한다. \'다각형 분석수행\' 버튼으로 특정 구역 내 오염 면적을 별도 산정할 수 있다.',
"지도에 모델별 색상 구분 입자가 동시에 표시되며, 각 모델의 확산 방향과 범위 차이를 시각적으로 비교할 수 있다. 우측 '분석 요약' 패널에 예측 면적(km2), 이동거리(km), 방향, 이동속도(cm/s)가 표출된다. 풍화 상태 막대그래프(유출량·해면연소·자연분산·수중분산·잔류 비율)를 제공한다. '다각형 분석수행' 버튼으로 특정 구역 내 오염 면적을 별도 산정할 수 있다.",
procedure: [
'확산분석 화면에서 적용 모델 체크박스를 2개 이상 선택한다.',
'\'확산예측 실행\' 버튼을 클릭한다.',
"'확산예측 실행' 버튼을 클릭한다.",
'지도에서 각 모델의 입자 분포와 확산 범위를 비교한다.',
'우측 \'분석 요약\' 탭에서 모델별 정량 지표를 확인한다.',
"우측 '분석 요약' 탭에서 모델별 정량 지표를 확인한다.",
'하단 타임라인으로 시간 경과별 확산 변화를 재생한다.',
],
notes: [
@ -100,16 +105,16 @@ const CHAPTERS: Chapter[] = [
overview:
'시스템에 등록된 실제 사고 정보를 불러와 확산분석에 자동 연동하는 기능이다. 사고코드·선박명·유종·유출량·발생 좌표 등이 자동으로 예측정보 입력란에 채워진다.',
description:
'좌측 \'사고정보\' 패널에 진행 중인 사고 목록이 표시된다. 사고를 선택하면 지도 중심이 해당 사고 발생 위치로 자동 이동하고 위치 핀이 표시된다. 사고 상태는 \'진행중(빨간 배지)\'과 \'종료(회색 배지)\'로 구분된다.',
"좌측 '사고정보' 패널에 진행 중인 사고 목록이 표시된다. 사고를 선택하면 지도 중심이 해당 사고 발생 위치로 자동 이동하고 위치 핀이 표시된다. 사고 상태는 '진행중(빨간 배지)'과 '종료(회색 배지)'로 구분된다.",
procedure: [
'좌측 \'사고정보\' 패널의 펼침 버튼을 클릭하여 패널을 연다.',
"좌측 '사고정보' 패널의 펼침 버튼을 클릭하여 패널을 연다.",
'목록에서 분석할 사고를 클릭한다.',
'사고 정보가 예측정보 입력란에 자동으로 입력되었는지 확인한다.',
'필요 시 유출량·예측 시간 등 추가 정보를 수정한다.',
'\'확산예측 실행\'을 클릭하여 분석을 시작한다.',
"'확산예측 실행'을 클릭하여 분석을 시작한다.",
],
notes: [
'\'진행중\' 상태의 사고만 실시간 확산예측과 연동된다.',
"'진행중' 상태의 사고만 실시간 확산예측과 연동된다.",
'사고 데이터는 관계 기관으로부터 등록된 정보를 기반으로 하며, 입력 오류 시 관리자에게 문의한다.',
],
},
@ -121,9 +126,9 @@ const CHAPTERS: Chapter[] = [
overview:
'유출유 확산 예측 결과와 해양 민감자원(환경생태·수산자원·관광지 등) 레이어를 지도에 동시 표출하는 기능이다. 잠재적 피해 자원을 사전에 파악하여 방제 우선순위 설정에 활용한다.',
description:
'좌측 하단 \'정보 레이어\' 패널에서 환경생태·사회경제·민감도평가·해경관할구역 등 대분류 토글을 제공한다. 각 항목 우측에 해당 레이어 데이터 건수가 표시된다. \'전체 켜기/끄기\' 버튼으로 모든 레이어를 일괄 제어할 수 있다.',
"좌측 하단 '정보 레이어' 패널에서 환경생태·사회경제·민감도평가·해경관할구역 등 대분류 토글을 제공한다. 각 항목 우측에 해당 레이어 데이터 건수가 표시된다. '전체 켜기/끄기' 버튼으로 모든 레이어를 일괄 제어할 수 있다.",
procedure: [
'좌측 하단 \'정보 레이어\' 패널을 열고 원하는 레이어 항목을 활성화한다.',
"좌측 하단 '정보 레이어' 패널을 열고 원하는 레이어 항목을 활성화한다.",
'확산예측 실행 후 지도에 표출된 오염 범위와 레이어 분포를 비교한다.',
'오염 영향이 우려되는 자원 레이어를 클릭하여 상세 정보를 확인한다.',
'필요한 레이어 조합을 선택하여 보고서용 지도 캡처에 활용한다.',
@ -140,12 +145,12 @@ const CHAPTERS: Chapter[] = [
overview:
'확산예측 결과를 기반으로 AI(NSGA-II 알고리즘)가 최적 오일펜스 배치안을 자동으로 생성하는 기능이다. 최대 3개 방어선의 위치·방향·총 길이·차단율을 자동 계산하여 지도에 표출한다.',
description:
'\'오일펜스 배치 가이드\' 패널 상단 \'AI자동 추천\' 탭에서 배치 결과를 확인한다. 1차~3차 방어선별 배치 위치(좌표), 방향, 오일펜스 길이, 예상 차단율이 표시된다.',
"'오일펜스 배치 가이드' 패널 상단 'AI자동 추천' 탭에서 배치 결과를 확인한다. 1차~3차 방어선별 배치 위치(좌표), 방향, 오일펜스 길이, 예상 차단율이 표시된다.",
procedure: [
'확산예측 실행 완료 후 좌측 \'오일펜스 배치 가이드\' 패널을 연다.',
'\'AI자동 추천\' 탭을 선택하여 추천 배치안을 확인한다.',
"확산예측 실행 완료 후 좌측 '오일펜스 배치 가이드' 패널을 연다.",
"'AI자동 추천' 탭을 선택하여 추천 배치안을 확인한다.",
'방어선별 차단율 및 오일펜스 총 길이를 검토한다.',
'\'추천 배치안 적용하기\' 버튼을 클릭하여 지도에 표출한다.',
"'추천 배치안 적용하기' 버튼을 클릭하여 지도에 표출한다.",
'현장 여건(조류·지형·자원 현황)을 고려하여 최종 배치안을 확정한다.',
],
inputs: [
@ -165,15 +170,20 @@ const CHAPTERS: Chapter[] = [
overview:
'위성·드론·항공기에서 촬영한 이미지를 업로드하여 실제 오염 현황과 모델 예측 결과를 비교 분석하는 기능이다.',
description:
'예측정보 입력 패널 상단 \'이미지 업로드\' 탭을 선택하면 파일 업로드 영역이 활성화된다. 업로드된 이미지는 지도에 자동으로 오버레이 표출된다.',
"예측정보 입력 패널 상단 '이미지 업로드' 탭을 선택하면 파일 업로드 영역이 활성화된다. 업로드된 이미지는 지도에 자동으로 오버레이 표출된다.",
procedure: [
'예측정보 입력 패널에서 \'이미지 업로드\' 탭을 선택한다.',
'\'파일 선택\' 버튼을 클릭하거나 파일을 드래그하여 업로드한다.',
"예측정보 입력 패널에서 '이미지 업로드' 탭을 선택한다.",
"'파일 선택' 버튼을 클릭하거나 파일을 드래그하여 업로드한다.",
'업로드된 이미지가 지도에 올바르게 표출되는지 위치를 확인한다.',
'확산예측 결과와 이미지를 동시에 표출하여 비교한다.',
],
inputs: [
{ label: '업로드 파일', type: '파일 PNG/JPG', required: false, desc: '위성·드론·항공기 촬영 이미지' },
{
label: '업로드 파일',
type: '파일 PNG/JPG',
required: false,
desc: '위성·드론·항공기 촬영 이미지',
},
],
notes: [
'지원 파일 형식은 PNG, JPG이다.',
@ -188,12 +198,12 @@ const CHAPTERS: Chapter[] = [
overview:
'완료 또는 진행 중인 유출유 확산예측 분석 이력을 목록 형식으로 조회하는 화면이다. 사고명·날짜·유종·유출량·모델 상태 등을 한눈에 파악할 수 있다.',
description:
'목록에는 번호·사고명·사고일시·예측시간·유종·유출량·모델별 상태(KOSPS·POSEIDON·OPENDRIFT)·담당자 등이 표시된다. 모델 상태는 \'완료(녹색 배지)\'와 \'대기(회색 배지)\'로 구분된다.',
"목록에는 번호·사고명·사고일시·예측시간·유종·유출량·모델별 상태(KOSPS·POSEIDON·OPENDRIFT)·담당자 등이 표시된다. 모델 상태는 '완료(녹색 배지)'와 '대기(회색 배지)'로 구분된다.",
procedure: [
'상단 메뉴에서 \'유출유확산예측\'을 클릭하여 분석 목록 화면으로 이동한다.',
"상단 메뉴에서 '유출유확산예측'을 클릭하여 분석 목록 화면으로 이동한다.",
'검색창에 사고명을 입력하거나 페이지를 이동하여 원하는 분석을 찾는다.',
'사고명 링크를 클릭하여 해당 분석 결과 상세 화면으로 이동한다.',
'신규 분석이 필요한 경우 우측 상단 \'+ 새 분석\' 버튼을 클릭한다.',
"신규 분석이 필요한 경우 우측 상단 '+ 새 분석' 버튼을 클릭한다.",
],
notes: [
'분석 결과 삭제 시 복구가 불가하므로, 삭제 전 보고서 출력 또는 데이터 저장 여부를 확인한다.',
@ -209,7 +219,7 @@ const CHAPTERS: Chapter[] = [
procedure: [
'분석 목록에서 조회할 사고명 링크를 클릭한다.',
'로드된 분석 정보를 확인한다.',
'필요 시 입력 조건을 수정하고 \'확산예측 실행\'을 다시 클릭하여 재분석한다.',
"필요 시 입력 조건을 수정하고 '확산예측 실행'을 다시 클릭하여 재분석한다.",
'결과를 보고서로 출력하거나 저장한다.',
],
notes: [
@ -224,8 +234,8 @@ const CHAPTERS: Chapter[] = [
overview:
'Wing 시스템에 탑재된 유출유 확산 수치 모델의 개요와 운용 체계를 안내하는 이론 화면이다. KOSPS·POSEIDON·OpenDrift 3종 모델의 특징·비교·데이터 흐름을 확인할 수 있다.',
procedure: [
'상단 메뉴에서 \'유출유확산예측 > 유출유확산모델 이론\'을 클릭한다.',
'\'시스템 개요\' 탭을 선택하여 전체 모델 체계를 확인한다.',
"상단 메뉴에서 '유출유확산예측 > 유출유확산모델 이론'을 클릭한다.",
"'시스템 개요' 탭을 선택하여 전체 모델 체계를 확인한다.",
'각 탭(KOSPS·POSEIDON·OpenDrift 등)을 클릭하여 상세 이론을 열람한다.',
],
},
@ -304,8 +314,7 @@ const CHAPTERS: Chapter[] = [
name: '유출유확산모델 이론 - 발전 방향',
menuPath: '유출유확산예측 > 유출유확산모델 이론',
imageIndex: 18,
overview:
'현재 유출유 확산 모델의 한계와 향후 개선 방향(4단계 로드맵)을 안내한다.',
overview: '현재 유출유 확산 모델의 한계와 향후 개선 방향(4단계 로드맵)을 안내한다.',
},
{
id: '019',
@ -336,8 +345,7 @@ const CHAPTERS: Chapter[] = [
name: '오일펜스 배치 알고리즘 이론 - 유체역학 모델',
menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론',
imageIndex: 22,
overview:
'오일펜스 차단 성능 평가에 사용되는 유체역학 모델의 이론을 안내한다.',
overview: '오일펜스 차단 성능 평가에 사용되는 유체역학 모델의 이론을 안내한다.',
},
{
id: '023',
@ -371,21 +379,26 @@ const CHAPTERS: Chapter[] = [
overview:
'HNS(위험유해물질) 해상 유출 시 대기 확산 범위, 위험 농도 구역, 영향 인구를 예측하는 핵심 분석 화면이다. ALOHA(EPA) 또는 이문진박사모델 두 가지 알고리즘 중 선택하여 가우시안 Plume/Puff 모델을 적용한다.',
description:
'화면 좌측에 사고 기본정보·물질 및 유출 조건·기상조건 입력 패널이 위치한다. 중앙 지도에 AEGL-1~3 위험 구역이 색상별 Plume 형태로 표출된다. 우측 \'예측 결과\' 패널에 최대 농도(ppm), AEGL-1 영향 면적, 위험 등급 등이 표시된다.',
"화면 좌측에 사고 기본정보·물질 및 유출 조건·기상조건 입력 패널이 위치한다. 중앙 지도에 AEGL-1~3 위험 구역이 색상별 Plume 형태로 표출된다. 우측 '예측 결과' 패널에 최대 농도(ppm), AEGL-1 영향 면적, 위험 등급 등이 표시된다.",
procedure: [
'상단 메뉴에서 \'HNS대기확산 > 대기확산분석\'을 클릭한다.',
"상단 메뉴에서 'HNS대기확산 > 대기확산분석'을 클릭한다.",
'사고 기본정보(사고명·날짜·시간·위치)를 입력한다.',
'HNS 물질 종류 및 누출 방식·유출량을 선택·입력한다.',
'기상조건(풍향·풍속·기온·대기안정도·예측 시간)을 입력한다.',
'적용 알고리즘(ALOHA/이문진박사모델)을 선택한다.',
'\'확산예측 실행\' 버튼을 클릭한다.',
"'확산예측 실행' 버튼을 클릭한다.",
'지도의 위험 구역 Plume과 우측 예측 결과 패널을 확인한다.',
],
inputs: [
{ label: '사고명', type: '텍스트', required: true, desc: '사고 식별 명칭' },
{ label: '사고 일시', type: '날짜+시간', required: true, desc: '사고 발생 일시' },
{ label: '위도/경도', type: '숫자', required: true, desc: '사고 발생 지점 좌표' },
{ label: 'HNS 물질', type: '드롭다운', required: true, desc: 'AEGL/ERPG 기준값 자동 로드' },
{
label: 'HNS 물질',
type: '드롭다운',
required: true,
desc: 'AEGL/ERPG 기준값 자동 로드',
},
{ label: '누출 방식', type: '라디오', required: true, desc: '순간 유출 또는 지속 유출' },
{ label: '유출량', type: '숫자', required: true, desc: 'ton 또는 g/s 단위' },
{ label: '풍향', type: '숫자', required: true, desc: '0~360도' },
@ -393,7 +406,12 @@ const CHAPTERS: Chapter[] = [
{ label: '기온', type: '숫자', required: true, desc: '현재 기온' },
{ label: '대기안정도', type: '선택 A~F', required: true, desc: 'Pasquill-Gifford 분류' },
{ label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' },
{ label: '적용 알고리즘', type: '라디오', required: true, desc: 'ALOHA 또는 이문진박사모델' },
{
label: '적용 알고리즘',
type: '라디오',
required: true,
desc: 'ALOHA 또는 이문진박사모델',
},
],
notes: [
'풍속은 최소 0.5 m/s 이상 입력해야 하며 0 입력 시 오류 발생.',
@ -408,7 +426,7 @@ const CHAPTERS: Chapter[] = [
overview:
'완료된 HNS 대기확산 분석 이력을 목록 형식으로 조회하는 화면이다. AEGL-1~3 거리·위험 등급·피해반경·담당자 등 주요 결과 정보를 한눈에 파악할 수 있다.',
procedure: [
'상단 메뉴에서 \'HNS대기확산\'을 클릭하여 분석 목록으로 이동한다.',
"상단 메뉴에서 'HNS대기확산'을 클릭하여 분석 목록으로 이동한다.",
'검색창에서 분석명을 검색하거나 목록을 스크롤하여 원하는 분석을 찾는다.',
'분석명 링크를 클릭하여 해당 분석 결과 지도 화면으로 이동한다.',
],
@ -423,10 +441,10 @@ const CHAPTERS: Chapter[] = [
description:
'시나리오 목록 카드에 시나리오명·위험도 배지(CRITICAL/HIGH/MEDIUM/RESOLVED)·최대농도·IDLH 거리·ERPG-2 거리·영향인구 요약이 표시된다.',
procedure: [
'\'HNS대기확산 > 시나리오 관리\'를 클릭하여 이동한다.',
"'HNS대기확산 > 시나리오 관리'를 클릭하여 이동한다.",
'원하는 사고 건을 선택하면 시나리오 목록이 표시된다.',
'시나리오 카드를 클릭하여 상세 위험 구역과 대응 권고사항을 확인한다.',
'\'비교 차트\' 탭으로 이동하여 시나리오 간 시간별 변화를 비교한다.',
"'비교 차트' 탭으로 이동하여 시나리오 간 시간별 변화를 비교한다.",
],
},
{
@ -447,7 +465,7 @@ const CHAPTERS: Chapter[] = [
overview:
'HNS 대기확산 시나리오의 시간대별(T+0h~T+4h) 확산 범위를 지도에 오버레이 표출하는 화면이다.',
procedure: [
'시나리오 관리 화면에서 \'확산범위 오버레이\' 탭을 클릭한다.',
"시나리오 관리 화면에서 '확산범위 오버레이' 탭을 클릭한다.",
'지도에서 시간대별 확산 범위를 확인한다.',
'슬라이더를 이동하여 원하는 시간대의 확산 상태를 조회한다.',
],
@ -460,11 +478,11 @@ const CHAPTERS: Chapter[] = [
overview:
'기상·유출 조건을 변경하여 새로운 HNS 대기확산 시나리오를 생성하는 입력 모달 화면이다.',
procedure: [
'시나리오 관리 화면 우측 상단 \'신규 시나리오\' 버튼을 클릭한다.',
"시나리오 관리 화면 우측 상단 '신규 시나리오' 버튼을 클릭한다.",
'시나리오명과 기준 시각을 입력한다.',
'HNS 물질·누출 구분·유출량을 입력한다.',
'기상조건(풍향·풍속·기온·대기안정도)을 입력한다.',
'\'시나리오 생성 및 예측 실행\'을 클릭한다.',
"'시나리오 생성 및 예측 실행'을 클릭한다.",
],
inputs: [
{ label: '시나리오명', type: '텍스트', required: true, desc: '시나리오 식별 명칭' },
@ -484,7 +502,7 @@ const CHAPTERS: Chapter[] = [
overview:
'해양 HNS 사고 대응 절차를 정리한 Marine HNS Response Manual(Bonn Agreement·HELCOM·REMPEC 기반)을 시스템 내에서 직접 열람하는 화면이다. 8개 챕터. SEBC 거동 분류 5유형 카드.',
procedure: [
'상단 메뉴에서 \'HNS대기확산 > HNS 대응매뉴얼\'을 클릭한다.',
"상단 메뉴에서 'HNS대기확산 > HNS 대응매뉴얼'을 클릭한다.",
'원하는 챕터 카드를 클릭하여 세부 내용을 확인한다.',
'하단 SEBC 분류 카드를 클릭하여 거동 유형별 대응 방법을 확인한다.',
],
@ -580,9 +598,7 @@ const CHAPTERS: Chapter[] = [
{ label: '검색어', type: '텍스트', required: true, desc: 'CAS 번호 또는 물질명' },
{ label: '거동 분류', type: '체크박스', required: false, desc: 'SEBC 유형 필터' },
],
notes: [
'CAS 번호 입력 시 하이픈(-) 포함 정확한 형식으로 입력해야 검색된다.',
],
notes: ['CAS 번호 입력 시 하이픈(-) 포함 정확한 형식으로 입력해야 검색된다.'],
},
],
},
@ -602,16 +618,21 @@ const CHAPTERS: Chapter[] = [
description:
'화면 좌측에 사고 발생 위치·일시·표류체 종류·예측 시간·기상조건 입력 패널이 위치한다. 중앙 지도에 표류 예측 궤적과 탐색 구역(최우선·일반)이 표출된다.',
procedure: [
'상단 메뉴에서 \'긴급구난\'을 클릭한다.',
"상단 메뉴에서 '긴급구난'을 클릭한다.",
'사고 발생 위치(위경도)와 일시를 입력한다.',
'표류체 종류(선박/사람/컨테이너 등)를 선택한다.',
'예측 시간과 기상 조건을 입력한다.',
'\'예측 실행\' 버튼을 클릭하여 표류 궤적과 탐색 구역을 확인한다.',
"'예측 실행' 버튼을 클릭하여 표류 궤적과 탐색 구역을 확인한다.",
],
inputs: [
{ label: '사고 위치', type: '숫자', required: true, desc: '위·경도' },
{ label: '사고 일시', type: '날짜+시간', required: true, desc: '발생 일시' },
{ label: '표류체 종류', type: '드롭다운', required: true, desc: '선박·사람·컨테이너·구명정 등' },
{
label: '표류체 종류',
type: '드롭다운',
required: true,
desc: '선박·사람·컨테이너·구명정 등',
},
{ label: '예측 시간', type: '숫자 h', required: true, desc: '표류 예측 기간' },
{ label: '기상조건', type: '숫자', required: false, desc: '미입력 시 실시간 자동 적용' },
],
@ -625,10 +646,9 @@ const CHAPTERS: Chapter[] = [
name: '긴급구난 목록',
menuPath: '긴급구난',
imageIndex: 44,
overview:
'완료 또는 진행 중인 긴급구난 예측 분석 이력을 목록 형식으로 조회하는 화면이다.',
overview: '완료 또는 진행 중인 긴급구난 예측 분석 이력을 목록 형식으로 조회하는 화면이다.',
procedure: [
'상단 메뉴에서 \'긴급구난\'을 클릭하면 목록 화면이 표시된다.',
"상단 메뉴에서 '긴급구난'을 클릭하면 목록 화면이 표시된다.",
'원하는 분석을 검색하거나 목록에서 선택한다.',
'사고명 링크를 클릭하여 상세 분석 결과로 이동한다.',
],
@ -641,8 +661,8 @@ const CHAPTERS: Chapter[] = [
overview:
'긴급구난 사고에 대해 기상·해양 조건을 달리 설정한 복수 시나리오를 생성·비교 관리하는 화면이다.',
procedure: [
'\'긴급구난 > 시나리오 관리\'를 클릭하여 이동한다.',
'생성된 시나리오 카드를 확인하거나 \'신규 시나리오\' 버튼으로 추가 생성한다.',
"'긴급구난 > 시나리오 관리'를 클릭하여 이동한다.",
"생성된 시나리오 카드를 확인하거나 '신규 시나리오' 버튼으로 추가 생성한다.",
'시나리오 카드를 클릭하여 상세 내용을 확인한다.',
],
},
@ -686,7 +706,7 @@ const CHAPTERS: Chapter[] = [
overview:
'시스템에서 생성된 모든 사고 대응 보고서를 목록 형식으로 관리하는 화면이다. 보고서명·사고명·생성일시·유형·작성자·상태가 표시된다.',
procedure: [
'상단 메뉴에서 \'보고자료\'를 클릭하여 보고서 목록으로 이동한다.',
"상단 메뉴에서 '보고자료'를 클릭하여 보고서 목록으로 이동한다.",
'검색창에서 원하는 보고서를 검색하거나 목록에서 선택한다.',
'보고서명을 클릭하여 미리보기하거나 PDF를 다운로드한다.',
],
@ -699,10 +719,10 @@ const CHAPTERS: Chapter[] = [
overview:
'사고 발생 초기 지휘부 보고를 위한 표준 보고서 템플릿을 제공한다. 확산예측 결과와 사고 기본정보가 자동 연동된다.',
procedure: [
'보고자료 메뉴에서 \'표준보고서 템플릿\'을 클릭한다.',
'좌측 사이드바에서 \'초기보고서\'를 선택한다.',
"보고자료 메뉴에서 '표준보고서 템플릿'을 클릭한다.",
"좌측 사이드바에서 '초기보고서'를 선택한다.",
'자동 입력된 항목을 검토하고 필요 내용을 추가 입력한다.',
'\'저장\' 또는 \'PDF 출력\' 버튼을 활용한다.',
"'저장' 또는 'PDF 출력' 버튼을 활용한다.",
],
},
{
@ -742,14 +762,13 @@ const CHAPTERS: Chapter[] = [
name: '유출유 확산예측 보고서 생성',
menuPath: '보고자료 > 보고서 생성',
imageIndex: 55,
overview:
'유출유 확산예측 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.',
overview: '유출유 확산예측 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.',
procedure: [
'보고자료 메뉴에서 \'보고서 생성\'을 클릭한다.',
'\'유출유 확산예측\' 유형을 선택한다.',
"보고자료 메뉴에서 '보고서 생성'을 클릭한다.",
"'유출유 확산예측' 유형을 선택한다.",
'연동할 분석 결과를 드롭다운에서 선택한다.',
'보고 대상을 선택한다.',
'\'보고서 생성\' 버튼을 클릭한다.',
"'보고서 생성' 버튼을 클릭한다.",
],
inputs: [
{ label: '분석 결과', type: '드롭다운', required: true, desc: '연동할 분석 건 선택' },
@ -761,8 +780,7 @@ const CHAPTERS: Chapter[] = [
name: 'HNS 대기확산 보고서 생성',
menuPath: '보고자료 > 보고서 생성',
imageIndex: 56,
overview:
'HNS 대기확산 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.',
overview: 'HNS 대기확산 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.',
},
{
id: '057',
@ -788,18 +806,16 @@ const CHAPTERS: Chapter[] = [
overview:
'드론·항공기·위성에서 수집된 영상 및 사진을 통합 관리하는 화면이다. 촬영 일시·위치·기기 유형별 분류 조회와 유출유 면적분석 연계를 지원한다.',
procedure: [
'상단 메뉴에서 \'항공탐색 > 영상사진관리\'를 클릭한다.',
"상단 메뉴에서 '항공탐색 > 영상사진관리'를 클릭한다.",
'목록에서 원하는 영상/사진을 선택하거나 지도 마커를 클릭하여 확인한다.',
'신규 파일 업로드 시 \'파일 업로드\' 버튼을 클릭한다.',
"신규 파일 업로드 시 '파일 업로드' 버튼을 클릭한다.",
],
inputs: [
{ label: '업로드 파일', type: '파일', required: false, desc: 'JPG·PNG·GeoTIFF·KMZ' },
{ label: '촬영 일시', type: '날짜+시간', required: false, desc: '촬영 시점' },
{ label: '장비 유형', type: '드롭다운', required: false, desc: '드론·항공기·위성·CCTV' },
],
notes: [
'50MB 초과 파일은 업로드 전 압축하거나 관리자에게 문의한다.',
],
notes: ['50MB 초과 파일은 업로드 전 압축하거나 관리자에게 문의한다.'],
},
{
id: '059',
@ -809,8 +825,8 @@ const CHAPTERS: Chapter[] = [
overview:
'항공·위성 이미지를 기반으로 유출유 오염 면적을 AI 자동 산출하는 화면이다. 자동 추출된 오염 경계선을 수동 편집하여 정밀 면적을 산정할 수 있다.',
procedure: [
'영상사진관리에서 분석 대상 이미지를 선택하고 \'면적 분석\' 버튼을 클릭한다.',
'\'AI 자동 분석 실행\' 버튼을 클릭하여 오염 경계선을 추출한다.',
"영상사진관리에서 분석 대상 이미지를 선택하고 '면적 분석' 버튼을 클릭한다.",
"'AI 자동 분석 실행' 버튼을 클릭하여 오염 경계선을 추출한다.",
'폴리곤 경계선을 검토하고 필요 시 편집 도구로 수동 조정한다.',
'최종 면적·둘레·중심 좌표를 확인하고 저장한다.',
],
@ -823,13 +839,11 @@ const CHAPTERS: Chapter[] = [
overview:
'현장 드론에서 실시간 전송되는 영상 스트리밍을 시스템 내에서 직접 모니터링하는 화면이다.',
procedure: [
'상단 메뉴에서 \'항공탐색 > 실시간 드론\'을 클릭한다.',
"상단 메뉴에서 '항공탐색 > 실시간 드론'을 클릭한다.",
'연결된 드론 목록에서 모니터링할 드론을 선택한다.',
'실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.',
],
notes: [
'드론 스트리밍은 네트워크 연결 상태에 따라 화질 및 지연이 달라질 수 있다.',
],
notes: ['드론 스트리밍은 네트워크 연결 상태에 따라 화질 및 지연이 달라질 수 있다.'],
},
{
id: '061',
@ -844,8 +858,7 @@ const CHAPTERS: Chapter[] = [
name: '위성요청',
menuPath: '항공탐색',
imageIndex: 62,
overview:
'SAR·광학 위성 촬영 요청 및 수신 결과를 관리하는 화면이다.',
overview: 'SAR·광학 위성 촬영 요청 및 수신 결과를 관리하는 화면이다.',
inputs: [
{ label: '요청 위치', type: '숫자', required: true, desc: '위·경도' },
{ label: '촬영 희망 일시', type: '날짜+시간', required: true, desc: '촬영 필요 일시' },
@ -864,7 +877,7 @@ const CHAPTERS: Chapter[] = [
overview:
'해안·항만 CCTV의 실시간 영상을 조회하는 화면이다. 사고 인근 CCTV를 지도에서 선택하여 스트리밍으로 확인할 수 있다.',
procedure: [
'상단 메뉴에서 \'항공탐색 > CCTV조회\'를 클릭한다.',
"상단 메뉴에서 '항공탐색 > CCTV조회'를 클릭한다.",
'지도에서 확인할 CCTV 마커를 클릭한다.',
'실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.',
],
@ -922,8 +935,7 @@ const CHAPTERS: Chapter[] = [
name: '항공탐색 이론 - 논문 특허',
menuPath: '항공탐색 > 항공탐색 이론',
imageIndex: 70,
overview:
'Wing 항공탐색 기능의 기반이 되는 관련 논문과 특허 목록을 안내한다.',
overview: 'Wing 항공탐색 기능의 기반이 되는 관련 논문과 특허 목록을 안내한다.',
},
],
},
@ -938,10 +950,9 @@ const CHAPTERS: Chapter[] = [
name: '전체 게시판',
menuPath: '게시판',
imageIndex: 71,
overview:
'공지사항·자료실·Q&A·해경매뉴얼 게시물을 통합 조회하는 전체 게시판 화면이다.',
overview: '공지사항·자료실·Q&A·해경매뉴얼 게시물을 통합 조회하는 전체 게시판 화면이다.',
procedure: [
'상단 메뉴에서 \'게시판\'을 클릭하여 전체 게시판으로 이동한다.',
"상단 메뉴에서 '게시판'을 클릭하여 전체 게시판으로 이동한다.",
'유형 필터 탭을 선택하거나 검색창에 키워드를 입력한다.',
'원하는 게시물 제목을 클릭하여 상세 내용을 확인한다.',
],
@ -959,19 +970,22 @@ const CHAPTERS: Chapter[] = [
name: '자료실',
menuPath: '게시판',
imageIndex: 73,
overview:
'매뉴얼·지침서·참고자료 등 업무 관련 문서 파일을 공유하는 게시판 화면이다.',
overview: '매뉴얼·지침서·참고자료 등 업무 관련 문서 파일을 공유하는 게시판 화면이다.',
},
{
id: '074',
name: 'QNA',
menuPath: '게시판',
imageIndex: 74,
overview:
'시스템 사용 관련 질문을 등록하고 답변을 확인하는 Q&A 게시판 화면이다.',
overview: '시스템 사용 관련 질문을 등록하고 답변을 확인하는 Q&A 게시판 화면이다.',
inputs: [
{ label: '제목', type: '텍스트', required: true, desc: '질문 제목' },
{ label: '카테고리', type: '드롭다운', required: true, desc: '기능문의·오류신고·개선요청' },
{
label: '카테고리',
type: '드롭다운',
required: true,
desc: '기능문의·오류신고·개선요청',
},
{ label: '내용', type: '텍스트', required: true, desc: '질문 내용' },
{ label: '첨부 파일', type: '파일', required: false, desc: '스크린샷 등' },
],
@ -1002,7 +1016,7 @@ const CHAPTERS: Chapter[] = [
description:
'사고 위치 기준 반경 내 기상 관측소 목록과 최신 관측값이 표시된다. 시계열 그래프로 과거 24시간 기상 변화를 확인할 수 있다.',
procedure: [
'상단 메뉴에서 \'기상정보\'를 클릭하여 이동한다.',
"상단 메뉴에서 '기상정보'를 클릭하여 이동한다.",
'지도에서 관측소 마커를 클릭하거나 목록에서 관측소를 선택한다.',
'최신 기상값과 시계열 그래프를 확인한다.',
],
@ -1028,15 +1042,20 @@ const CHAPTERS: Chapter[] = [
description:
'화면 좌측에 날짜 범위·분석 유형·담당자·사고 지역 복합 필터 패널이 위치한다. 중앙 지도에 검색 결과 사고지점 마커가 표출된다. Excel·CSV 내보내기를 지원한다.',
procedure: [
'상단 메뉴에서 \'통합조회\'를 클릭하여 이동한다.',
"상단 메뉴에서 '통합조회'를 클릭하여 이동한다.",
'날짜 범위·분석 유형 등 필터 조건을 설정한다.',
'\'검색\' 버튼을 클릭하여 결과를 조회한다.',
"'검색' 버튼을 클릭하여 결과를 조회한다.",
'지도 마커 또는 목록 항목을 클릭하여 해당 분석 결과 상세 화면으로 이동한다.',
'Excel·CSV 내보내기 버튼으로 이력 자료를 저장한다.',
],
inputs: [
{ label: '날짜 범위', type: '날짜', required: false, desc: '시작·종료 날짜' },
{ label: '분석 유형', type: '체크박스', required: false, desc: '유출유·HNS·긴급구난·보고서' },
{
label: '분석 유형',
type: '체크박스',
required: false,
desc: '유출유·HNS·긴급구난·보고서',
},
{ label: '담당자', type: '텍스트', required: false, desc: '이름 필터' },
{ label: '사고 지역', type: '텍스트', required: false, desc: '지역명 필터' },
],
@ -1081,14 +1100,14 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
return (
<>
<div
className='fixed inset-0 z-[9999] flex items-center justify-center'
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.65)' }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className='flex flex-col rounded-lg overflow-hidden'
className="flex flex-col rounded-lg overflow-hidden"
style={{
width: '90vw',
height: '85vh',
@ -1099,21 +1118,18 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
>
{/* Header */}
<div
className='flex items-center justify-between px-6 py-4 flex-shrink-0'
className="flex items-center justify-between px-6 py-4 flex-shrink-0"
style={{
background: '#0b1120',
borderBottom: '1px solid #1e2a45',
}}
>
<div className='flex items-center gap-3'>
<span
className='font-bold text-[15px]'
style={{ color: '#e2e8f0' }}
>
<div className="flex items-center gap-3">
<span className="font-bold text-[15px]" style={{ color: '#e2e8f0' }}>
Wing
</span>
<span
className='text-[11px] px-2 py-0.5 rounded font-mono'
className="text-[11px] px-2 py-0.5 rounded font-mono"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1125,7 +1141,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
<button
onClick={onClose}
className='flex items-center justify-center w-7 h-7 rounded text-[13px] font-semibold transition-colors'
className="flex items-center justify-center w-7 h-7 rounded text-[13px] font-semibold transition-colors"
style={{ color: '#94a3b8', background: 'transparent' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a2540';
@ -1141,10 +1157,10 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
{/* Body */}
<div className='flex flex-1 overflow-hidden'>
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
<div
className='flex-shrink-0 overflow-y-auto py-3'
className="flex-shrink-0 overflow-y-auto py-3"
style={{
width: '240px',
background: '#0b1120',
@ -1160,7 +1176,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
setSelectedChapterId(chapter.id);
setExpandedScreenIds(new Set());
}}
className='w-full text-left px-4 py-3 transition-colors'
className="w-full text-left px-4 py-3 transition-colors"
style={{
background: isActive ? 'rgba(6,182,212,0.08)' : 'transparent',
borderLeft: isActive ? '2px solid #06b6d4' : '2px solid transparent',
@ -1176,9 +1192,9 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}
}}
>
<div className='flex items-center gap-2.5'>
<div className="flex items-center gap-2.5">
<span
className='flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-[10px] font-bold font-mono'
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-[10px] font-bold font-mono"
style={{
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
color: isActive ? '#06b6d4' : '#64748b',
@ -1187,15 +1203,15 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
>
{chapter.number}
</span>
<div className='min-w-0'>
<div className="min-w-0">
<div
className='text-[12px] font-medium leading-tight truncate'
className="text-[12px] font-medium leading-tight truncate"
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
>
{chapter.title}
</div>
<div
className='text-[10px] leading-tight mt-0.5 truncate'
className="text-[10px] leading-tight mt-0.5 truncate"
style={{ color: '#475569' }}
>
{chapter.subtitle}
@ -1208,13 +1224,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
{/* Right Content */}
<div className='flex-1 overflow-y-auto p-6'>
<div className="flex-1 overflow-y-auto p-6">
{/* Chapter heading */}
<div className='mb-5 pb-4' style={{ borderBottom: '1px solid #1e2a45' }}>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<div className="mb-5 pb-4" style={{ borderBottom: '1px solid #1e2a45' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className='text-[11px] font-mono px-2 py-0.5 rounded font-bold'
className="text-[11px] font-mono px-2 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1223,20 +1239,20 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
>
CH {selectedChapter.number}
</span>
<h2 className='text-[16px] font-semibold' style={{ color: '#e2e8f0' }}>
<h2 className="text-[16px] font-semibold" style={{ color: '#e2e8f0' }}>
{selectedChapter.title}
</h2>
<span className='text-[12px]' style={{ color: '#475569' }}>
<span className="text-[12px]" style={{ color: '#475569' }}>
{selectedChapter.subtitle}
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-[11px] mr-1' style={{ color: '#64748b' }}>
<div className="flex items-center gap-2">
<span className="text-[11px] mr-1" style={{ color: '#64748b' }}>
{selectedChapter.screens.length}
</span>
<button
onClick={allExpanded ? collapseAll : expandAll}
className='text-[11px] px-3 py-1 rounded transition-colors'
className="text-[11px] px-3 py-1 rounded transition-colors"
style={{
background: 'rgba(6,182,212,0.08)',
color: '#06b6d4',
@ -1256,14 +1272,14 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
{/* Screen cards */}
<div className='flex flex-col gap-2'>
<div className="flex flex-col gap-2">
{selectedChapter.screens.map((screen) => {
const isExpanded = expandedScreenIds.has(screen.id);
const imageSrc = `/manual/image${screen.imageIndex}.png`;
return (
<div
key={screen.id}
className='rounded-lg overflow-hidden'
className="rounded-lg overflow-hidden"
style={{
background: '#141d33',
border: '1px solid #1e2a45',
@ -1272,7 +1288,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Screen header (toggle) */}
<button
onClick={() => toggleScreen(screen.id)}
className='w-full text-left flex items-center gap-3 px-4 py-3 transition-colors'
className="w-full text-left flex items-center gap-3 px-4 py-3 transition-colors"
style={{ background: 'transparent' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a2540';
@ -1282,7 +1298,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}}
>
<span
className='flex-shrink-0 text-[10px] font-mono font-bold px-1.5 py-0.5 rounded'
className="flex-shrink-0 text-[10px] font-mono font-bold px-1.5 py-0.5 rounded"
style={{
background: 'rgba(6,182,212,0.1)',
color: '#06b6d4',
@ -1294,13 +1310,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{screen.id}
</span>
<span
className='flex-1 text-[13px] font-medium'
className="flex-1 text-[13px] font-medium"
style={{ color: '#cbd5e1' }}
>
{screen.name}
</span>
<span
className='flex-shrink-0 text-[10px] font-mono'
className="flex-shrink-0 text-[10px] font-mono"
style={{
color: '#475569',
transition: 'transform 0.2s',
@ -1314,16 +1330,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Screen detail (expanded) */}
{isExpanded && (
<div
className='px-4 pb-5'
style={{ borderTop: '1px solid #1e2a45' }}
>
<div className="px-4 pb-5" style={{ borderTop: '1px solid #1e2a45' }}>
{/* Screenshot image */}
<div className='mt-4 mb-4'>
<div className="mt-4 mb-4">
<img
src={imageSrc}
alt={screen.name}
loading='lazy'
loading="lazy"
onClick={() => setLightboxSrc(imageSrc)}
style={{
width: '100%',
@ -1333,17 +1346,14 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
display: 'block',
}}
/>
<p
className='mt-1 text-[10px] text-right'
style={{ color: '#475569' }}
>
<p className="mt-1 text-[10px] text-right" style={{ color: '#475569' }}>
</p>
</div>
{/* Menu path breadcrumb */}
<div
className='mb-3 text-[11px] font-mono px-2 py-1 rounded inline-block'
className="mb-3 text-[11px] font-mono px-2 py-1 rounded inline-block"
style={{
background: 'rgba(71,85,105,0.15)',
color: '#64748b',
@ -1354,11 +1364,8 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</div>
{/* Overview */}
<div className='mt-2'>
<p
className='text-[12px] leading-relaxed'
style={{ color: '#94a3b8' }}
>
<div className="mt-2">
<p className="text-[12px] leading-relaxed" style={{ color: '#94a3b8' }}>
{screen.overview}
</p>
</div>
@ -1366,20 +1373,20 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Description */}
{screen.description && (
<div
className='mt-3 px-3 py-2.5 rounded'
className="mt-3 px-3 py-2.5 rounded"
style={{
background: 'rgba(30,42,69,0.6)',
border: '1px solid #1e2a45',
}}
>
<div
className='text-[11px] font-semibold mb-1.5 uppercase tracking-wide'
className="text-[11px] font-semibold mb-1.5 uppercase tracking-wide"
style={{ color: '#475569' }}
>
</div>
<p
className='text-[12px] leading-relaxed'
className="text-[12px] leading-relaxed"
style={{ color: '#7f8ea3' }}
>
{screen.description}
@ -1389,18 +1396,18 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Procedure */}
{screen.procedure && screen.procedure.length > 0 && (
<div className='mt-4'>
<div className="mt-4">
<div
className='text-[11px] font-semibold mb-2 uppercase tracking-wide'
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
</div>
<ol className='flex flex-col gap-1.5'>
<ol className="flex flex-col gap-1.5">
{screen.procedure.map((step, idx) => (
<li key={idx} className='flex items-start gap-2.5'>
<li key={idx} className="flex items-start gap-2.5">
<span
className='flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold mt-0.5'
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold mt-0.5"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
@ -1410,7 +1417,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{idx + 1}
</span>
<span
className='text-[12px] leading-relaxed'
className="text-[12px] leading-relaxed"
style={{ color: '#94a3b8' }}
>
{step}
@ -1423,40 +1430,40 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Inputs */}
{screen.inputs && screen.inputs.length > 0 && (
<div className='mt-4'>
<div className="mt-4">
<div
className='text-[11px] font-semibold mb-2 uppercase tracking-wide'
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
</div>
<div
className='rounded overflow-hidden'
className="rounded overflow-hidden"
style={{ border: '1px solid #1e2a45' }}
>
<table className='w-full text-[12px]'>
<table className="w-full text-[12px]">
<thead>
<tr style={{ background: '#0f1729' }}>
<th
className='text-left px-3 py-2 font-medium'
className="text-left px-3 py-2 font-medium"
style={{ color: '#64748b', width: '22%' }}
>
</th>
<th
className='text-left px-3 py-2 font-medium'
className="text-left px-3 py-2 font-medium"
style={{ color: '#64748b', width: '18%' }}
>
</th>
<th
className='text-left px-3 py-2 font-medium'
className="text-left px-3 py-2 font-medium"
style={{ color: '#64748b', width: '12%' }}
>
</th>
<th
className='text-left px-3 py-2 font-medium'
className="text-left px-3 py-2 font-medium"
style={{ color: '#64748b' }}
>
@ -1476,21 +1483,18 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
}}
>
<td
className='px-3 py-2 font-medium'
className="px-3 py-2 font-medium"
style={{ color: '#cbd5e1' }}
>
{input.label}
</td>
<td
className='px-3 py-2'
style={{ color: '#64748b' }}
>
<td className="px-3 py-2" style={{ color: '#64748b' }}>
{input.type}
</td>
<td className='px-3 py-2'>
<td className="px-3 py-2">
{input.required ? (
<span
className='text-[10px] font-bold px-1.5 py-0.5 rounded'
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
style={{
background: 'rgba(239,68,68,0.1)',
color: '#f87171',
@ -1501,7 +1505,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</span>
) : (
<span
className='text-[10px] px-1.5 py-0.5 rounded'
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
background: 'rgba(100,116,139,0.1)',
color: '#64748b',
@ -1512,10 +1516,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
</span>
)}
</td>
<td
className='px-3 py-2'
style={{ color: '#7f8ea3' }}
>
<td className="px-3 py-2" style={{ color: '#7f8ea3' }}>
{input.desc}
</td>
</tr>
@ -1528,22 +1529,22 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Notes */}
{screen.notes && screen.notes.length > 0 && (
<div className='mt-4'>
<div className="mt-4">
<div
className='text-[11px] font-semibold mb-2 uppercase tracking-wide'
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
style={{ color: '#475569' }}
>
</div>
<ul className='flex flex-col gap-1.5'>
<ul className="flex flex-col gap-1.5">
{screen.notes.map((note, idx) => (
<li key={idx} className='flex items-start gap-2'>
<li key={idx} className="flex items-start gap-2">
<span
className='flex-shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full'
className="flex-shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full"
style={{ background: '#f59e0b' }}
/>
<span
className='text-[12px] leading-relaxed'
className="text-[12px] leading-relaxed"
style={{ color: '#94a3b8' }}
>
{note}
@ -1567,18 +1568,18 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
{/* Lightbox */}
{lightboxSrc !== null && (
<div
className='fixed inset-0 z-[10000] flex items-center justify-center'
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.88)' }}
onClick={() => setLightboxSrc(null)}
>
<div
className='relative'
className="relative"
style={{ maxWidth: '92vw', maxHeight: '90vh' }}
onClick={(e) => e.stopPropagation()}
>
<img
src={lightboxSrc}
alt='확대 이미지'
alt="확대 이미지"
style={{
maxWidth: '92vw',
maxHeight: '88vh',
@ -1589,7 +1590,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
/>
<button
onClick={() => setLightboxSrc(null)}
className='absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-[13px] font-bold'
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-[13px] font-bold"
style={{
background: 'rgba(15,23,41,0.85)',
color: '#94a3b8',

파일 보기

@ -1,45 +1,160 @@
// HTML 시안 기반 레이어 트리 구조
export interface LayerNode {
code: string
parentCode: string | null
fullName: string
name: string
level: number
layerName: string | null
dataTblNm?: string | null
icon?: string
count?: number
defaultOn?: boolean
children?: LayerNode[]
code: string;
parentCode: string | null;
fullName: string;
name: string;
level: number;
layerName: string | null;
dataTblNm?: string | null;
icon?: string;
count?: number;
defaultOn?: boolean;
children?: LayerNode[];
}
export const layerData: LayerNode[] = [
// ─── 1. 해양생물자원 ───
{
code: 'BIO', parentCode: null, fullName: '해양생물자원', name: '해양생물자원',
level: 1, layerName: null, icon: '🐟', count: 17129, defaultOn: true,
code: 'BIO',
parentCode: null,
fullName: '해양생물자원',
name: '해양생물자원',
level: 1,
layerName: null,
icon: '🐟',
count: 17129,
defaultOn: true,
children: [
{
code: 'BIO_FARM', parentCode: 'BIO', fullName: '양식장', name: '양식장',
level: 2, layerName: null, icon: '🦪', count: 3947, defaultOn: true,
code: 'BIO_FARM',
parentCode: 'BIO',
fullName: '양식장',
name: '양식장',
level: 2,
layerName: null,
icon: '🦪',
count: 3947,
defaultOn: true,
children: [
{ code: 'BIO_FARM_FISH', parentCode: 'BIO_FARM', fullName: '어류양식장', name: '어류양식장', level: 3, layerName: 'mpc:600', icon: '🐟', count: 87, defaultOn: true },
{ code: 'BIO_FARM_SHELL', parentCode: 'BIO_FARM', fullName: '패류양식장', name: '패류양식장 (굴·전복·홍합)', level: 3, layerName: 'mpc:506', icon: '🦪', count: 720, defaultOn: true },
{ code: 'BIO_FARM_SEAWEED', parentCode: 'BIO_FARM', fullName: '해조류양식장', name: '해조류양식장 (김·미역·다시마)', level: 3, layerName: 'mpc:503', icon: '🌿', count: 1438, defaultOn: true },
{ code: 'BIO_FARM_CAGE', parentCode: 'BIO_FARM', fullName: '가두리양식장', name: '가두리양식장', level: 3, layerName: 'mpc:472', icon: '🔲', count: 59 },
{ code: 'BIO_FARM_CRUST', parentCode: 'BIO_FARM', fullName: '갑각류양식장', name: '갑각류양식장', level: 3, layerName: 'mpc:473', icon: '🦐', count: 470 },
{ code: 'BIO_FARM_ETC', parentCode: 'BIO_FARM', fullName: '기타양식장', name: '기타양식장', level: 3, layerName: 'mpc:486', icon: '📦', count: 1173 },
{
code: 'BIO_FARM_FISH',
parentCode: 'BIO_FARM',
fullName: '어류양식장',
name: '어류양식장',
level: 3,
layerName: 'mpc:600',
icon: '🐟',
count: 87,
defaultOn: true,
},
{
code: 'BIO_FARM_SHELL',
parentCode: 'BIO_FARM',
fullName: '패류양식장',
name: '패류양식장 (굴·전복·홍합)',
level: 3,
layerName: 'mpc:506',
icon: '🦪',
count: 720,
defaultOn: true,
},
{
code: 'BIO_FARM_SEAWEED',
parentCode: 'BIO_FARM',
fullName: '해조류양식장',
name: '해조류양식장 (김·미역·다시마)',
level: 3,
layerName: 'mpc:503',
icon: '🌿',
count: 1438,
defaultOn: true,
},
{
code: 'BIO_FARM_CAGE',
parentCode: 'BIO_FARM',
fullName: '가두리양식장',
name: '가두리양식장',
level: 3,
layerName: 'mpc:472',
icon: '🔲',
count: 59,
},
{
code: 'BIO_FARM_CRUST',
parentCode: 'BIO_FARM',
fullName: '갑각류양식장',
name: '갑각류양식장',
level: 3,
layerName: 'mpc:473',
icon: '🦐',
count: 470,
},
{
code: 'BIO_FARM_ETC',
parentCode: 'BIO_FARM',
fullName: '기타양식장',
name: '기타양식장',
level: 3,
layerName: 'mpc:486',
icon: '📦',
count: 1173,
},
],
},
{
code: 'BIO_REEF', parentCode: 'BIO', fullName: '어초·암초', name: '어초·암초',
level: 2, layerName: null, icon: '🪸', count: 13182, defaultOn: true,
code: 'BIO_REEF',
parentCode: 'BIO',
fullName: '어초·암초',
name: '어초·암초',
level: 2,
layerName: null,
icon: '🪸',
count: 13182,
defaultOn: true,
children: [
{ code: 'BIO_REEF_ART', parentCode: 'BIO_REEF', fullName: '인공어초', name: '인공어초', level: 3, layerName: 'mpc:495', icon: '🪸', count: 6683, defaultOn: true },
{ code: 'BIO_REEF_NAT', parentCode: 'BIO_REEF', fullName: '암초 (자연)', name: '암초 (자연)', level: 3, layerName: 'mpc:497', icon: '🪨', count: 6331 },
{ code: 'BIO_REEF_WRECK', parentCode: 'BIO_REEF', fullName: '침선', name: '침선', level: 3, layerName: 'mpc:488', icon: '🚢', count: 88 },
{ code: 'BIO_REEF_OBS', parentCode: 'BIO_REEF', fullName: '기타 장애물', name: '기타 장애물', level: 3, layerName: 'mpc:470', icon: '⚠', count: 80 },
{
code: 'BIO_REEF_ART',
parentCode: 'BIO_REEF',
fullName: '인공어초',
name: '인공어초',
level: 3,
layerName: 'mpc:495',
icon: '🪸',
count: 6683,
defaultOn: true,
},
{
code: 'BIO_REEF_NAT',
parentCode: 'BIO_REEF',
fullName: '암초 (자연)',
name: '암초 (자연)',
level: 3,
layerName: 'mpc:497',
icon: '🪨',
count: 6331,
},
{
code: 'BIO_REEF_WRECK',
parentCode: 'BIO_REEF',
fullName: '침선',
name: '침선',
level: 3,
layerName: 'mpc:488',
icon: '🚢',
count: 88,
},
{
code: 'BIO_REEF_OBS',
parentCode: 'BIO_REEF',
fullName: '기타 장애물',
name: '기타 장애물',
level: 3,
layerName: 'mpc:470',
icon: '⚠',
count: 80,
},
],
},
],
@ -47,26 +162,97 @@ export const layerData: LayerNode[] = [
// ─── 2. 환경·보호구역 ───
{
code: 'ENV', parentCode: null, fullName: '환경·보호구역', name: '환경·보호구역',
level: 1, layerName: null, icon: '🏛',
code: 'ENV',
parentCode: null,
fullName: '환경·보호구역',
name: '환경·보호구역',
level: 1,
layerName: null,
icon: '🏛',
children: [
{
code: 'ENV_ECO', parentCode: 'ENV', fullName: '생태보호구역', name: '생태보호구역',
level: 2, layerName: null, icon: '🦅',
code: 'ENV_ECO',
parentCode: 'ENV',
fullName: '생태보호구역',
name: '생태보호구역',
level: 2,
layerName: null,
icon: '🦅',
children: [
{ code: 'ENV_ECO_MARINE', parentCode: 'ENV_ECO', fullName: '해양보호구역', name: '해양보호구역', level: 3, layerName: 'mpc:505', icon: '🌿' },
{ code: 'ENV_ECO_BIRD', parentCode: 'ENV_ECO', fullName: '철새도래지', name: '철새도래지', level: 3, layerName: 'mpc:254', icon: '🐦' },
{ code: 'ENV_ECO_WETLAND', parentCode: 'ENV_ECO', fullName: '습지보호구역', name: '습지보호구역', level: 3, layerName: 'mpc:468', icon: '🏖' },
{ code: 'ENV_ECO_ENDANG', parentCode: 'ENV_ECO', fullName: '보호종 서식지', name: '보호종 서식지', level: 3, layerName: 'mpc:255', icon: '🐢' },
{
code: 'ENV_ECO_MARINE',
parentCode: 'ENV_ECO',
fullName: '해양보호구역',
name: '해양보호구역',
level: 3,
layerName: 'mpc:505',
icon: '🌿',
},
{
code: 'ENV_ECO_BIRD',
parentCode: 'ENV_ECO',
fullName: '철새도래지',
name: '철새도래지',
level: 3,
layerName: 'mpc:254',
icon: '🐦',
},
{
code: 'ENV_ECO_WETLAND',
parentCode: 'ENV_ECO',
fullName: '습지보호구역',
name: '습지보호구역',
level: 3,
layerName: 'mpc:468',
icon: '🏖',
},
{
code: 'ENV_ECO_ENDANG',
parentCode: 'ENV_ECO',
fullName: '보호종 서식지',
name: '보호종 서식지',
level: 3,
layerName: 'mpc:255',
icon: '🐢',
},
],
},
{
code: 'ENV_COAST', parentCode: 'ENV', fullName: '해안·연안', name: '해안·연안',
level: 2, layerName: null, icon: '🏖',
code: 'ENV_COAST',
parentCode: 'ENV',
fullName: '해안·연안',
name: '해안·연안',
level: 2,
layerName: null,
icon: '🏖',
children: [
{ code: 'ENV_COAST_BEACH', parentCode: 'ENV_COAST', fullName: '해수욕장', name: '해수욕장', level: 3, layerName: 'mpc:501', icon: '🏖' },
{ code: 'ENV_COAST_MUD', parentCode: 'ENV_COAST', fullName: '갯벌', name: '갯벌', level: 3, layerName: 'mpc:363', icon: '🪨' },
{ code: 'ENV_COAST_INTAKE', parentCode: 'ENV_COAST', fullName: '취수구·배수구', name: '취수구·배수구', level: 3, layerName: 'mpc:466', icon: '🚰' },
{
code: 'ENV_COAST_BEACH',
parentCode: 'ENV_COAST',
fullName: '해수욕장',
name: '해수욕장',
level: 3,
layerName: 'mpc:501',
icon: '🏖',
},
{
code: 'ENV_COAST_MUD',
parentCode: 'ENV_COAST',
fullName: '갯벌',
name: '갯벌',
level: 3,
layerName: 'mpc:363',
icon: '🪨',
},
{
code: 'ENV_COAST_INTAKE',
parentCode: 'ENV_COAST',
fullName: '취수구·배수구',
name: '취수구·배수구',
level: 3,
layerName: 'mpc:466',
icon: '🚰',
},
],
},
],
@ -74,26 +260,97 @@ export const layerData: LayerNode[] = [
// ─── 3. 해양시설·인프라 ───
{
code: 'INF', parentCode: null, fullName: '해양시설·인프라', name: '해양시설·인프라',
level: 1, layerName: null, icon: '⚓',
code: 'INF',
parentCode: null,
fullName: '해양시설·인프라',
name: '해양시설·인프라',
level: 1,
layerName: null,
icon: '⚓',
children: [
{
code: 'INF_PORT', parentCode: 'INF', fullName: '항만·항로', name: '항만·항로',
level: 2, layerName: null, icon: '🚢',
code: 'INF_PORT',
parentCode: 'INF',
fullName: '항만·항로',
name: '항만·항로',
level: 2,
layerName: null,
icon: '🚢',
children: [
{ code: 'INF_PORT_AREA', parentCode: 'INF_PORT', fullName: '항만 구역', name: '항만 구역', level: 3, layerName: 'mpc:469', icon: '⚓' },
{ code: 'INF_PORT_ROUTE', parentCode: 'INF_PORT', fullName: '항로', name: '항로', level: 3, layerName: 'mpc:601', icon: '🚢' },
{ code: 'INF_PORT_ANCHOR', parentCode: 'INF_PORT', fullName: '정박지', name: '정박지', level: 3, layerName: 'mpc:471', icon: '⛵' },
{ code: 'INF_PORT_BUOY', parentCode: 'INF_PORT', fullName: '항로표지', name: '항로표지', level: 3, layerName: 'mpc:492', icon: '🔴' },
{
code: 'INF_PORT_AREA',
parentCode: 'INF_PORT',
fullName: '항만 구역',
name: '항만 구역',
level: 3,
layerName: 'mpc:469',
icon: '⚓',
},
{
code: 'INF_PORT_ROUTE',
parentCode: 'INF_PORT',
fullName: '항로',
name: '항로',
level: 3,
layerName: 'mpc:601',
icon: '🚢',
},
{
code: 'INF_PORT_ANCHOR',
parentCode: 'INF_PORT',
fullName: '정박지',
name: '정박지',
level: 3,
layerName: 'mpc:471',
icon: '⛵',
},
{
code: 'INF_PORT_BUOY',
parentCode: 'INF_PORT',
fullName: '항로표지',
name: '항로표지',
level: 3,
layerName: 'mpc:492',
icon: '🔴',
},
],
},
{
code: 'INF_IND', parentCode: 'INF', fullName: '산업시설', name: '산업시설',
level: 2, layerName: null, icon: '🏭',
code: 'INF_IND',
parentCode: 'INF',
fullName: '산업시설',
name: '산업시설',
level: 2,
layerName: null,
icon: '🏭',
children: [
{ code: 'INF_IND_POWER', parentCode: 'INF_IND', fullName: '발전소·산단', name: '발전소·산단', level: 3, layerName: 'mpc:474', icon: '🏭' },
{ code: 'INF_IND_OIL', parentCode: 'INF_IND', fullName: '저유시설', name: '저유시설', level: 3, layerName: 'mpc:496', icon: '🛢' },
{ code: 'INF_IND_CABLE', parentCode: 'INF_IND', fullName: '해저케이블·배관', name: '해저케이블·배관', level: 3, layerName: 'mpc:499', icon: '🔌' },
{
code: 'INF_IND_POWER',
parentCode: 'INF_IND',
fullName: '발전소·산단',
name: '발전소·산단',
level: 3,
layerName: 'mpc:474',
icon: '🏭',
},
{
code: 'INF_IND_OIL',
parentCode: 'INF_IND',
fullName: '저유시설',
name: '저유시설',
level: 3,
layerName: 'mpc:496',
icon: '🛢',
},
{
code: 'INF_IND_CABLE',
parentCode: 'INF_IND',
fullName: '해저케이블·배관',
name: '해저케이블·배관',
level: 3,
layerName: 'mpc:499',
icon: '🔌',
},
],
},
],
@ -101,16 +358,53 @@ export const layerData: LayerNode[] = [
// ─── 4. 방제자원 ───
{
code: 'DEF', parentCode: null, fullName: '방제자원', name: '방제자원',
level: 1, layerName: null, icon: '🛡', defaultOn: true,
code: 'DEF',
parentCode: null,
fullName: '방제자원',
name: '방제자원',
level: 1,
layerName: null,
icon: '🛡',
defaultOn: true,
children: [
{
code: 'DEF_DEPLOY', parentCode: 'DEF', fullName: '방제 배치', name: '방제 배치',
level: 2, layerName: null, icon: '🛡', defaultOn: true,
code: 'DEF_DEPLOY',
parentCode: 'DEF',
fullName: '방제 배치',
name: '방제 배치',
level: 2,
layerName: null,
icon: '🛡',
defaultOn: true,
children: [
{ code: 'DEF_DEPLOY_BOOM', parentCode: 'DEF_DEPLOY', fullName: '오일펜스 배치선', name: '오일펜스 배치선', level: 3, layerName: 'defense:boom_lines', icon: '🛡', defaultOn: true },
{ code: 'DEF_DEPLOY_WARE', parentCode: 'DEF_DEPLOY', fullName: '방제창고', name: '방제창고', level: 3, layerName: 'defense:warehouse', icon: '🏗' },
{ code: 'DEF_DEPLOY_SHIP', parentCode: 'DEF_DEPLOY', fullName: '방제선 위치', name: '방제선 위치', level: 3, layerName: 'defense:vessels', icon: '🚢' },
{
code: 'DEF_DEPLOY_BOOM',
parentCode: 'DEF_DEPLOY',
fullName: '오일펜스 배치선',
name: '오일펜스 배치선',
level: 3,
layerName: 'defense:boom_lines',
icon: '🛡',
defaultOn: true,
},
{
code: 'DEF_DEPLOY_WARE',
parentCode: 'DEF_DEPLOY',
fullName: '방제창고',
name: '방제창고',
level: 3,
layerName: 'defense:warehouse',
icon: '🏗',
},
{
code: 'DEF_DEPLOY_SHIP',
parentCode: 'DEF_DEPLOY',
fullName: '방제선 위치',
name: '방제선 위치',
level: 3,
layerName: 'defense:vessels',
icon: '🚢',
},
],
},
],
@ -118,47 +412,200 @@ export const layerData: LayerNode[] = [
// ─── 5. Pre-SCAT 데이터 ───
{
code: 'SCAT', parentCode: null, fullName: 'Pre-SCAT 데이터', name: 'Pre-SCAT 데이터',
level: 1, layerName: null, icon: '📊',
code: 'SCAT',
parentCode: null,
fullName: 'Pre-SCAT 데이터',
name: 'Pre-SCAT 데이터',
level: 1,
layerName: null,
icon: '📊',
children: [
{
code: 'SCAT_ESI', parentCode: 'SCAT', fullName: '해안분류 (ESI)', name: '해안분류 (ESI)',
level: 2, layerName: null, icon: '🏖',
code: 'SCAT_ESI',
parentCode: 'SCAT',
fullName: '해안분류 (ESI)',
name: '해안분류 (ESI)',
level: 2,
layerName: null,
icon: '🏖',
children: [
{ code: 'SCAT_ESI_ROCK', parentCode: 'SCAT_ESI', fullName: '암반 해안 (ESI 1)', name: '암반 해안 (ESI 1)', level: 3, layerName: 'prescat:esi_rock', icon: '🪨' },
{ code: 'SCAT_ESI_SAND', parentCode: 'SCAT_ESI', fullName: '사빈 해안 (ESI 3)', name: '사빈 해안 (ESI 3)', level: 3, layerName: 'prescat:esi_sand', icon: '🏖' },
{ code: 'SCAT_ESI_GRAVEL', parentCode: 'SCAT_ESI', fullName: '자갈 해안 (ESI 5)', name: '자갈 해안 (ESI 5)', level: 3, layerName: 'prescat:esi_gravel', icon: '🪹' },
{ code: 'SCAT_ESI_MUD', parentCode: 'SCAT_ESI', fullName: '갯벌·습지 (ESI 9)', name: '갯벌·습지 (ESI 9)', level: 3, layerName: 'prescat:esi_mudflat', icon: '🌿' },
{ code: 'SCAT_ESI_MANG', parentCode: 'SCAT_ESI', fullName: '맹그로브·염습지 (ESI 10)', name: '맹그로브·염습지 (ESI 10)', level: 3, layerName: 'prescat:esi_mangrove', icon: '🌾' },
{
code: 'SCAT_ESI_ROCK',
parentCode: 'SCAT_ESI',
fullName: '암반 해안 (ESI 1)',
name: '암반 해안 (ESI 1)',
level: 3,
layerName: 'prescat:esi_rock',
icon: '🪨',
},
{
code: 'SCAT_ESI_SAND',
parentCode: 'SCAT_ESI',
fullName: '사빈 해안 (ESI 3)',
name: '사빈 해안 (ESI 3)',
level: 3,
layerName: 'prescat:esi_sand',
icon: '🏖',
},
{
code: 'SCAT_ESI_GRAVEL',
parentCode: 'SCAT_ESI',
fullName: '자갈 해안 (ESI 5)',
name: '자갈 해안 (ESI 5)',
level: 3,
layerName: 'prescat:esi_gravel',
icon: '🪹',
},
{
code: 'SCAT_ESI_MUD',
parentCode: 'SCAT_ESI',
fullName: '갯벌·습지 (ESI 9)',
name: '갯벌·습지 (ESI 9)',
level: 3,
layerName: 'prescat:esi_mudflat',
icon: '🌿',
},
{
code: 'SCAT_ESI_MANG',
parentCode: 'SCAT_ESI',
fullName: '맹그로브·염습지 (ESI 10)',
name: '맹그로브·염습지 (ESI 10)',
level: 3,
layerName: 'prescat:esi_mangrove',
icon: '🌾',
},
],
},
{
code: 'SCAT_SENS', parentCode: 'SCAT', fullName: '해안 민감도', name: '해안 민감도',
level: 2, layerName: null, icon: '🎯',
code: 'SCAT_SENS',
parentCode: 'SCAT',
fullName: '해안 민감도',
name: '해안 민감도',
level: 2,
layerName: null,
icon: '🎯',
children: [
{ code: 'SCAT_SENS_MAX', parentCode: 'SCAT_SENS', fullName: '최고 민감 구간', name: '최고 민감 구간', level: 3, layerName: 'prescat:sens_max', icon: '🔴' },
{ code: 'SCAT_SENS_HIGH', parentCode: 'SCAT_SENS', fullName: '고민감 구간', name: '고민감 구간', level: 3, layerName: 'prescat:sens_high', icon: '🟠' },
{ code: 'SCAT_SENS_MID', parentCode: 'SCAT_SENS', fullName: '중민감 구간', name: '중민감 구간', level: 3, layerName: 'prescat:sens_mid', icon: '🟡' },
{ code: 'SCAT_SENS_LOW', parentCode: 'SCAT_SENS', fullName: '저민감 구간', name: '저민감 구간', level: 3, layerName: 'prescat:sens_low', icon: '🟢' },
{
code: 'SCAT_SENS_MAX',
parentCode: 'SCAT_SENS',
fullName: '최고 민감 구간',
name: '최고 민감 구간',
level: 3,
layerName: 'prescat:sens_max',
icon: '🔴',
},
{
code: 'SCAT_SENS_HIGH',
parentCode: 'SCAT_SENS',
fullName: '고민감 구간',
name: '고민감 구간',
level: 3,
layerName: 'prescat:sens_high',
icon: '🟠',
},
{
code: 'SCAT_SENS_MID',
parentCode: 'SCAT_SENS',
fullName: '중민감 구간',
name: '중민감 구간',
level: 3,
layerName: 'prescat:sens_mid',
icon: '🟡',
},
{
code: 'SCAT_SENS_LOW',
parentCode: 'SCAT_SENS',
fullName: '저민감 구간',
name: '저민감 구간',
level: 3,
layerName: 'prescat:sens_low',
icon: '🟢',
},
],
},
{
code: 'SCAT_VULN', parentCode: 'SCAT', fullName: '오염 취약성', name: '오염 취약성',
level: 2, layerName: null, icon: '🛢',
code: 'SCAT_VULN',
parentCode: 'SCAT',
fullName: '오염 취약성',
name: '오염 취약성',
level: 2,
layerName: null,
icon: '🛢',
children: [
{ code: 'SCAT_VULN_RESI', parentCode: 'SCAT_VULN', fullName: '잔류시간 예측', name: '잔류시간 예측', level: 3, layerName: 'prescat:vuln_residency', icon: '⏱' },
{ code: 'SCAT_VULN_DIFF', parentCode: 'SCAT_VULN', fullName: '방제 난이도', name: '방제 난이도', level: 3, layerName: 'prescat:vuln_difficulty', icon: '🧹' },
{ code: 'SCAT_VULN_SELF', parentCode: 'SCAT_VULN', fullName: '자연정화 기대수준', name: '자연정화 기대수준', level: 3, layerName: 'prescat:vuln_selfclean', icon: '🔄' },
{
code: 'SCAT_VULN_RESI',
parentCode: 'SCAT_VULN',
fullName: '잔류시간 예측',
name: '잔류시간 예측',
level: 3,
layerName: 'prescat:vuln_residency',
icon: '⏱',
},
{
code: 'SCAT_VULN_DIFF',
parentCode: 'SCAT_VULN',
fullName: '방제 난이도',
name: '방제 난이도',
level: 3,
layerName: 'prescat:vuln_difficulty',
icon: '🧹',
},
{
code: 'SCAT_VULN_SELF',
parentCode: 'SCAT_VULN',
fullName: '자연정화 기대수준',
name: '자연정화 기대수준',
level: 3,
layerName: 'prescat:vuln_selfclean',
icon: '🔄',
},
],
},
{
code: 'SCAT_STRAT', parentCode: 'SCAT', fullName: '방제전략 권고', name: '방제전략 권고',
level: 2, layerName: null, icon: '📋',
code: 'SCAT_STRAT',
parentCode: 'SCAT',
fullName: '방제전략 권고',
name: '방제전략 권고',
level: 2,
layerName: null,
icon: '📋',
children: [
{ code: 'SCAT_STRAT_PRI', parentCode: 'SCAT_STRAT', fullName: '1순위 방어구간', name: '1순위 방어구간', level: 3, layerName: 'prescat:strat_priority', icon: '🛡' },
{ code: 'SCAT_STRAT_METHOD', parentCode: 'SCAT_STRAT', fullName: '권고 방제공법', name: '권고 방제공법', level: 3, layerName: 'prescat:strat_method', icon: '🧹' },
{ code: 'SCAT_STRAT_BASE', parentCode: 'SCAT_STRAT', fullName: '거점 방제장소', name: '거점 방제장소', level: 3, layerName: 'prescat:strat_base', icon: '📍' },
{ code: 'SCAT_STRAT_ACCESS', parentCode: 'SCAT_STRAT', fullName: '접근경로·진입로', name: '접근경로·진입로', level: 3, layerName: 'prescat:strat_access', icon: '🚧' },
{
code: 'SCAT_STRAT_PRI',
parentCode: 'SCAT_STRAT',
fullName: '1순위 방어구간',
name: '1순위 방어구간',
level: 3,
layerName: 'prescat:strat_priority',
icon: '🛡',
},
{
code: 'SCAT_STRAT_METHOD',
parentCode: 'SCAT_STRAT',
fullName: '권고 방제공법',
name: '권고 방제공법',
level: 3,
layerName: 'prescat:strat_method',
icon: '🧹',
},
{
code: 'SCAT_STRAT_BASE',
parentCode: 'SCAT_STRAT',
fullName: '거점 방제장소',
name: '거점 방제장소',
level: 3,
layerName: 'prescat:strat_base',
icon: '📍',
},
{
code: 'SCAT_STRAT_ACCESS',
parentCode: 'SCAT_STRAT',
fullName: '접근경로·진입로',
name: '접근경로·진입로',
level: 3,
layerName: 'prescat:strat_access',
icon: '🚧',
},
],
},
],
@ -166,25 +613,101 @@ export const layerData: LayerNode[] = [
// ─── 6. 해양관측·기상 ───
{
code: 'OBS', parentCode: null, fullName: '해양관측·기상', name: '해양관측·기상',
level: 1, layerName: null, icon: '🌊', defaultOn: true,
code: 'OBS',
parentCode: null,
fullName: '해양관측·기상',
name: '해양관측·기상',
level: 1,
layerName: null,
icon: '🌊',
defaultOn: true,
children: [
{ code: 'OBS_CURRENT', parentCode: 'OBS', fullName: '해류 벡터', name: '해류 벡터', level: 2, layerName: 'obs:current_vector', icon: '🌊', defaultOn: true },
{ code: 'OBS_TEMP', parentCode: 'OBS', fullName: '수온 분포', name: '수온 분포', level: 2, layerName: 'obs:sst', icon: '🌡' },
{ code: 'OBS_BUOY', parentCode: 'OBS', fullName: '해양관측 부이', name: '해양관측 부이', level: 2, layerName: 'obs:buoy', icon: '📡' },
{ code: 'OBS_WEATHER', parentCode: 'OBS', fullName: '기상 관측소', name: '기상 관측소', level: 2, layerName: 'obs:weather_station', icon: '🌬' },
{ code: 'OBS_SAT', parentCode: 'OBS', fullName: '위성 영상', name: '위성 영상', level: 2, layerName: 'obs:satellite', icon: '🛰' },
{
code: 'OBS_CURRENT',
parentCode: 'OBS',
fullName: '해류 벡터',
name: '해류 벡터',
level: 2,
layerName: 'obs:current_vector',
icon: '🌊',
defaultOn: true,
},
{
code: 'OBS_TEMP',
parentCode: 'OBS',
fullName: '수온 분포',
name: '수온 분포',
level: 2,
layerName: 'obs:sst',
icon: '🌡',
},
{
code: 'OBS_BUOY',
parentCode: 'OBS',
fullName: '해양관측 부이',
name: '해양관측 부이',
level: 2,
layerName: 'obs:buoy',
icon: '📡',
},
{
code: 'OBS_WEATHER',
parentCode: 'OBS',
fullName: '기상 관측소',
name: '기상 관측소',
level: 2,
layerName: 'obs:weather_station',
icon: '🌬',
},
{
code: 'OBS_SAT',
parentCode: 'OBS',
fullName: '위성 영상',
name: '위성 영상',
level: 2,
layerName: 'obs:satellite',
icon: '🛰',
},
],
},
// ─── 7. 선박·교통 ───
{
code: 'SHIP', parentCode: null, fullName: '선박·교통', name: '선박·교통',
level: 1, layerName: null, icon: '🚢',
code: 'SHIP',
parentCode: null,
fullName: '선박·교통',
name: '선박·교통',
level: 1,
layerName: null,
icon: '🚢',
children: [
{ code: 'SHIP_AIS', parentCode: 'SHIP', fullName: 'AIS 실시간 선박', name: 'AIS 실시간 선박', level: 2, layerName: 'ship:ais_realtime', icon: '🚢' },
{ code: 'SHIP_PATROL', parentCode: 'SHIP', fullName: '경비함정', name: '경비함정', level: 2, layerName: 'ship:patrol', icon: '🛥' },
{ code: 'SHIP_AIR', parentCode: 'SHIP', fullName: '항공기·드론', name: '항공기·드론', level: 2, layerName: 'ship:aircraft', icon: '🚁' },
{
code: 'SHIP_AIS',
parentCode: 'SHIP',
fullName: 'AIS 실시간 선박',
name: 'AIS 실시간 선박',
level: 2,
layerName: 'ship:ais_realtime',
icon: '🚢',
},
{
code: 'SHIP_PATROL',
parentCode: 'SHIP',
fullName: '경비함정',
name: '경비함정',
level: 2,
layerName: 'ship:patrol',
icon: '🛥',
},
{
code: 'SHIP_AIR',
parentCode: 'SHIP',
fullName: '항공기·드론',
name: '항공기·드론',
level: 2,
layerName: 'ship:aircraft',
icon: '🚁',
},
],
},
]
];

파일 보기

@ -15,10 +15,9 @@ export function useFeatureTracking(featureId: string) {
useEffect(() => {
if (!isAuthenticated || !featureId) return;
const blob = new Blob(
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
{ type: 'text/plain' },
);
const blob = new Blob([JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], {
type: 'text/plain',
});
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
}, [featureId, isAuthenticated]);
}

파일 보기

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api'
import type { Layer } from '@common/services/layerService'
import { useQuery } from '@tanstack/react-query';
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api';
import type { Layer } from '@common/services/layerService';
// 모든 레이어 조회 훅
export function useLayers() {
@ -9,7 +9,7 @@ export function useLayers() {
queryFn: fetchAllLayers,
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
retry: 3,
})
});
}
// 계층 구조 레이어 트리 조회 훅
@ -19,7 +19,7 @@ export function useLayerTree() {
queryKey: ['layers', 'tree'],
queryFn: fetchLayerTree,
retry: 3,
})
});
}
// WMS 레이어만 조회 훅
@ -29,5 +29,5 @@ export function useWMSLayers() {
queryFn: fetchWMSLayers,
staleTime: 1000 * 60 * 5,
retry: 3,
})
});
}

파일 보기

@ -1,12 +1,12 @@
import { useEffect, useSyncExternalStore } from 'react'
import type { MainTab } from '../types/navigation'
import { useAuthStore } from '@common/store/authStore'
import { API_BASE_URL } from '@common/services/api'
import { useEffect, useSyncExternalStore } from 'react';
import type { MainTab } from '../types/navigation';
import { useAuthStore } from '@common/store/authStore';
import { API_BASE_URL } from '@common/services/api';
interface SubMenuItem {
id: string
label: string
icon: string
id: string;
label: string;
icon: string;
}
// 메인 탭별 서브 메뉴 설정
@ -17,24 +17,24 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
{ id: 'manual', label: 'HNS 대응매뉴얼', icon: '📖' },
{ id: 'theory', label: '확산모델 이론', icon: '📐' },
{ id: 'substance', label: 'HNS 물질정보', icon: '🧬' }
{ id: 'substance', label: 'HNS 물질정보', icon: '🧬' },
],
prediction: [
{ id: 'analysis', label: '유출유 확산분석', icon: '🔬' },
{ id: 'list', label: '분석 목록', icon: '📋' },
{ id: 'theory', label: '유출유확산모델 이론', icon: '📐' },
{ id: 'boom-theory', label: '오일펜스 배치 알고리즘 이론', icon: '🛡️' }
{ id: 'boom-theory', label: '오일펜스 배치 알고리즘 이론', icon: '🛡️' },
],
rescue: [
{ id: 'rescue', label: '긴급구난예측', icon: '🚨' },
{ id: 'list', label: '긴급구난 목록', icon: '📋' },
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
{ id: 'theory', label: '긴급구난모델 이론', icon: '📚' }
{ id: 'theory', label: '긴급구난모델 이론', icon: '📚' },
],
reports: [
{ id: 'report-list', label: '보고서 목록', icon: '📋' },
{ id: 'template', label: '표준보고서 템플릿', icon: '📝' },
{ id: 'generate', label: '보고서 생성', icon: '🔄' }
{ id: 'generate', label: '보고서 생성', icon: '🔄' },
],
aerial: [
{ id: 'media', label: '영상사진관리', icon: '📷' },
@ -44,13 +44,13 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
{ id: 'theory', label: '항공탐색 이론', icon: '📐' },
],
assets: null,
scat: [
{ id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
{ id: 'distribution', label: '해양오염분포도', icon: '🗺' },
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' }
// { id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
// { id: 'distribution', label: '해양오염분포도', icon: '🗺' },
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' },
],
incidents: null,
board: [
@ -58,11 +58,11 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'notice', label: '공지사항', icon: '📢' },
{ id: 'data', label: '자료실', icon: '📂' },
{ id: 'qna', label: 'Q&A', icon: '❓' },
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
],
weather: null,
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
}
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
};
// 전역 상태 관리 (간단한 방식)
const subMenuState: Record<MainTab, string> = {
@ -72,82 +72,84 @@ const subMenuState: Record<MainTab, string> = {
reports: 'report-list',
aerial: 'media',
assets: '',
scat: 'survey',
scat: 'pre-scat',
incidents: '',
board: 'all',
weather: '',
admin: 'users'
}
admin: 'users',
};
const listeners: Set<() => void> = new Set()
const listeners: Set<() => void> = new Set();
function setSubTab(mainTab: MainTab, subTab: string) {
subMenuState[mainTab] = subTab
listeners.forEach(listener => listener())
subMenuState[mainTab] = subTab;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => { listeners.delete(listener) }
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useSubMenu(mainTab: MainTab) {
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasPermission = useAuthStore((s) => s.hasPermission)
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab]);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const hasPermission = useAuthStore((s) => s.hasPermission);
const setActiveSubTab = (subTab: string) => {
setSubTab(mainTab, subTab)
}
setSubTab(mainTab, subTab);
};
// 권한 기반 서브메뉴 필터링
const rawConfig = subMenuConfigs[mainTab]
const filteredConfig = rawConfig?.filter(item =>
hasPermission(`${mainTab}:${item.id}`)
) ?? null
const rawConfig = subMenuConfigs[mainTab];
const filteredConfig =
rawConfig?.filter((item) => hasPermission(`${mainTab}:${item.id}`)) ?? null;
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
useEffect(() => {
if (!isAuthenticated || !activeSubTab) return
const resourcePath = `${mainTab}:${activeSubTab}`
const blob = new Blob(
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
{ type: 'text/plain' },
)
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
}, [mainTab, activeSubTab, isAuthenticated])
if (!isAuthenticated || !activeSubTab) return;
const resourcePath = `${mainTab}:${activeSubTab}`;
const blob = new Blob([JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })], {
type: 'text/plain',
});
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
}, [mainTab, activeSubTab, isAuthenticated]);
return {
activeSubTab,
setActiveSubTab,
subMenuConfig: filteredConfig,
}
};
}
// ─── 글로벌 메인탭 전환 (크로스 뷰 네비게이션) ─────────────
type MainTabListener = (tab: MainTab) => void
let mainTabListener: MainTabListener | null = null
type MainTabListener = (tab: MainTab) => void;
let mainTabListener: MainTabListener | null = null;
/** App.tsx에서 호출하여 글로벌 탭 전환 리스너 등록 */
export function registerMainTabSwitcher(fn: MainTabListener) {
mainTabListener = fn
mainTabListener = fn;
}
/** 어느 컴포넌트에서든 메인탭 + 서브탭을 한번에 전환 */
export function navigateToTab(mainTab: MainTab, subTab?: string) {
if (subTab) setSubTab(mainTab, subTab)
if (mainTabListener) mainTabListener(mainTab)
if (subTab) setSubTab(mainTab, subTab);
if (mainTabListener) mainTabListener(mainTab);
}
// ─── 보고서 생성 카테고리 힌트 ──────────────────────────
/** 보고서 생성 탭으로 이동 시 초기 카테고리 (0=유출유, 1=HNS, 2=긴급구난) */
let _reportGenCategory: number | null = null
let _reportGenCategory: number | null = null;
export function setReportGenCategory(cat: number | null) { _reportGenCategory = cat }
export function setReportGenCategory(cat: number | null) {
_reportGenCategory = cat;
}
export function consumeReportGenCategory(): number | null {
const v = _reportGenCategory
_reportGenCategory = null
return v
const v = _reportGenCategory;
_reportGenCategory = null;
return v;
}
// ─── HNS 보고서 실 데이터 전달 ──────────────────────────
@ -162,7 +164,9 @@ export interface HnsReportPayload {
}
let _hnsReportPayload: HnsReportPayload | null = null;
export function setHnsReportPayload(d: HnsReportPayload | null) { _hnsReportPayload = d; }
export function setHnsReportPayload(d: HnsReportPayload | null) {
_hnsReportPayload = d;
}
export function consumeHnsReportPayload(): HnsReportPayload | null {
const v = _hnsReportPayload;
_hnsReportPayload = null;
@ -244,11 +248,13 @@ export interface OilReportPayload {
}
let _oilReportPayload: OilReportPayload | null = null;
export function setOilReportPayload(d: OilReportPayload | null) { _oilReportPayload = d; }
export function setOilReportPayload(d: OilReportPayload | null) {
_oilReportPayload = d;
}
export function consumeOilReportPayload(): OilReportPayload | null {
const v = _oilReportPayload;
_oilReportPayload = null;
return v;
}
export { subMenuState }
export { subMenuState };

파일 보기

@ -1,30 +1,30 @@
export interface Vessel {
mmsi: number
imo: string
name: string
typS: string
flag: string
status: string
speed: number
heading: number
lat: number
lng: number
draft: number
depart: string
arrive: string
etd: string
eta: string
gt: string
dwt: string
loa: string
beam: string
built: string
yard: string
callSign: string
cls: string
cargo: string
color: string
markerType: string
mmsi: number;
imo: string;
name: string;
typS: string;
flag: string;
status: string;
speed: number;
heading: number;
lat: number;
lng: number;
draft: number;
depart: string;
arrive: string;
etd: string;
eta: string;
gt: string;
dwt: string;
loa: string;
beam: string;
built: string;
yard: string;
callSign: string;
cls: string;
cargo: string;
color: string;
markerType: string;
}
export const VESSEL_TYPE_COLORS: Record<string, string> = {
@ -38,7 +38,7 @@ export const VESSEL_TYPE_COLORS: Record<string, string> = {
Tug: '#06b6d4',
Navy: '#6b7280',
Sailing: '#fbbf24',
}
};
export const VESSEL_LEGEND = [
{ type: 'Tanker', color: '#ef4444' },
@ -47,167 +47,567 @@ export const VESSEL_LEGEND = [
{ type: 'Fishing', color: '#f97316' },
{ type: 'Passenger', color: '#a855f7' },
{ type: 'Tug', color: '#06b6d4' },
]
];
export const mockVessels: Vessel[] = [
{
mmsi: 440123456, imo: '9812345', name: 'HANKUK CHEMI', typS: 'Tanker', flag: '🇰🇷',
status: '항해중', speed: 8.2, heading: 330, lat: 34.60, lng: 127.50,
draft: 5.8, depart: '여수항', arrive: '부산항', etd: '2026-02-25 08:00', eta: '2026-02-25 18:30',
gt: '29,246', dwt: '49,999', loa: '183.0m', beam: '32.2m', built: '2018', yard: '현대미포조선',
callSign: 'HLKC', cls: '한국선급(KR)', cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
color: '#ef4444', markerType: 'tanker',
mmsi: 440123456,
imo: '9812345',
name: 'HANKUK CHEMI',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 8.2,
heading: 330,
lat: 34.6,
lng: 127.5,
draft: 5.8,
depart: '여수항',
arrive: '부산항',
etd: '2026-02-25 08:00',
eta: '2026-02-25 18:30',
gt: '29,246',
dwt: '49,999',
loa: '183.0m',
beam: '32.2m',
built: '2018',
yard: '현대미포조선',
callSign: 'HLKC',
cls: '한국선급(KR)',
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440234567, imo: '9823456', name: 'DONG-A GLAUCOS', typS: 'Cargo', flag: '🇰🇷',
status: '항해중', speed: 11.4, heading: 245, lat: 34.78, lng: 127.80,
draft: 7.2, depart: '울산항', arrive: '광양항', etd: '2026-02-25 06:30', eta: '2026-02-25 16:00',
gt: '12,450', dwt: '18,800', loa: '144.0m', beam: '22.6m', built: '2015', yard: 'STX조선',
callSign: 'HLDG', cls: '한국선급(KR)', cargo: '철강재 · 4,500t',
color: '#22c55e', markerType: 'cargo',
mmsi: 440234567,
imo: '9823456',
name: 'DONG-A GLAUCOS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.4,
heading: 245,
lat: 34.78,
lng: 127.8,
draft: 7.2,
depart: '울산항',
arrive: '광양항',
etd: '2026-02-25 06:30',
eta: '2026-02-25 16:00',
gt: '12,450',
dwt: '18,800',
loa: '144.0m',
beam: '22.6m',
built: '2015',
yard: 'STX조선',
callSign: 'HLDG',
cls: '한국선급(KR)',
cargo: '철강재 · 4,500t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440345678, imo: '9834567', name: 'HMM ALGECIRAS', typS: 'Container', flag: '🇰🇷',
status: '항해중', speed: 18.5, heading: 195, lat: 35.00, lng: 128.80,
draft: 14.5, depart: '부산항', arrive: '싱가포르', etd: '2026-02-25 04:00', eta: '2026-03-02 08:00',
gt: '228,283', dwt: '223,092', loa: '399.9m', beam: '61.0m', built: '2020', yard: '대우조선해양',
callSign: 'HLHM', cls: "Lloyd's Register", cargo: '컨테이너 · 16,420 TEU',
color: '#3b82f6', markerType: 'container',
mmsi: 440345678,
imo: '9834567',
name: 'HMM ALGECIRAS',
typS: 'Container',
flag: '🇰🇷',
status: '항해중',
speed: 18.5,
heading: 195,
lat: 35.0,
lng: 128.8,
draft: 14.5,
depart: '부산항',
arrive: '싱가포르',
etd: '2026-02-25 04:00',
eta: '2026-03-02 08:00',
gt: '228,283',
dwt: '223,092',
loa: '399.9m',
beam: '61.0m',
built: '2020',
yard: '대우조선해양',
callSign: 'HLHM',
cls: "Lloyd's Register",
cargo: '컨테이너 · 16,420 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 355678901, imo: '9756789', name: 'STELLAR DAISY', typS: 'Tanker', flag: '🇵🇦',
status: '⚠ 사고(좌초)', speed: 0.0, heading: 0, lat: 34.72, lng: 127.72,
draft: 8.1, depart: '여수항', arrive: '—', etd: '2026-01-18 12:00', eta: '—',
gt: '35,120', dwt: '58,000', loa: '190.0m', beam: '34.0m', built: '2012', yard: 'CSBC Taiwan',
callSign: '3FZA7', cls: 'NK', cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
color: '#ef4444', markerType: 'tanker',
mmsi: 355678901,
imo: '9756789',
name: 'STELLAR DAISY',
typS: 'Tanker',
flag: '🇵🇦',
status: '⚠ 사고(좌초)',
speed: 0.0,
heading: 0,
lat: 34.72,
lng: 127.72,
draft: 8.1,
depart: '여수항',
arrive: '—',
etd: '2026-01-18 12:00',
eta: '—',
gt: '35,120',
dwt: '58,000',
loa: '190.0m',
beam: '34.0m',
built: '2012',
yard: 'CSBC Taiwan',
callSign: '3FZA7',
cls: 'NK',
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440456789, imo: '—', name: '제72 금양호', typS: 'Fishing', flag: '🇰🇷',
status: '조업중', speed: 4.1, heading: 120, lat: 34.55, lng: 127.35,
draft: 2.1, depart: '여수 국동항', arrive: '여수 국동항', etd: '2026-02-25 04:30', eta: '2026-02-25 18:00',
gt: '78', dwt: '—', loa: '24.5m', beam: '6.2m', built: '2008', yard: '통영조선',
callSign: '—', cls: '한국선급', cargo: '어획물',
color: '#f97316', markerType: 'fishing',
mmsi: 440456789,
imo: '—',
name: '제72 금양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 4.1,
heading: 120,
lat: 34.55,
lng: 127.35,
draft: 2.1,
depart: '여수 국동항',
arrive: '여수 국동항',
etd: '2026-02-25 04:30',
eta: '2026-02-25 18:00',
gt: '78',
dwt: '—',
loa: '24.5m',
beam: '6.2m',
built: '2008',
yard: '통영조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440567890, imo: '9867890', name: 'PAN OCEAN GLORY', typS: 'Bulk', flag: '🇰🇷',
status: '항해중', speed: 12.8, heading: 170, lat: 35.60, lng: 126.40,
draft: 10.3, depart: '군산항', arrive: '포항항', etd: '2026-02-25 07:00', eta: '2026-02-26 04:00',
gt: '43,800', dwt: '76,500', loa: '229.0m', beam: '32.3m', built: '2019', yard: '현대삼호중공업',
callSign: 'HLPO', cls: '한국선급(KR)', cargo: '석탄 · 65,000t',
color: '#22c55e', markerType: 'cargo',
mmsi: 440567890,
imo: '9867890',
name: 'PAN OCEAN GLORY',
typS: 'Bulk',
flag: '🇰🇷',
status: '항해중',
speed: 12.8,
heading: 170,
lat: 35.6,
lng: 126.4,
draft: 10.3,
depart: '군산항',
arrive: '포항항',
etd: '2026-02-25 07:00',
eta: '2026-02-26 04:00',
gt: '43,800',
dwt: '76,500',
loa: '229.0m',
beam: '32.3m',
built: '2019',
yard: '현대삼호중공업',
callSign: 'HLPO',
cls: '한국선급(KR)',
cargo: '석탄 · 65,000t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440678901, imo: '—', name: '여수예인1호', typS: 'Tug', flag: '🇰🇷',
status: '방제지원', speed: 6.3, heading: 355, lat: 34.68, lng: 127.60,
draft: 3.2, depart: '여수항', arrive: '사고현장', etd: '2026-01-18 16:30', eta: '—',
gt: '280', dwt: '—', loa: '32.0m', beam: '9.5m', built: '2016', yard: '삼성중공업',
callSign: 'HLYT', cls: '한국선급', cargo: '방제장비 · 오일붐 500m',
color: '#06b6d4', markerType: 'tug',
mmsi: 440678901,
imo: '—',
name: '여수예인1호',
typS: 'Tug',
flag: '🇰🇷',
status: '방제지원',
speed: 6.3,
heading: 355,
lat: 34.68,
lng: 127.6,
draft: 3.2,
depart: '여수항',
arrive: '사고현장',
etd: '2026-01-18 16:30',
eta: '—',
gt: '280',
dwt: '—',
loa: '32.0m',
beam: '9.5m',
built: '2016',
yard: '삼성중공업',
callSign: 'HLYT',
cls: '한국선급',
cargo: '방제장비 · 오일붐 500m',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 235012345, imo: '9456789', name: 'QUEEN MARY', typS: 'Passenger', flag: '🇬🇧',
status: '항해중', speed: 15.2, heading: 10, lat: 33.80, lng: 127.00,
draft: 8.5, depart: '상하이', arrive: '부산항', etd: '2026-02-24 18:00', eta: '2026-02-26 06:00',
gt: '148,528', dwt: '18,000', loa: '345.0m', beam: '41.0m', built: '2004', yard: "Chantiers de l'Atlantique",
callSign: 'GBQM2', cls: "Lloyd's Register", cargo: '승객 2,620명',
color: '#a855f7', markerType: 'passenger',
mmsi: 235012345,
imo: '9456789',
name: 'QUEEN MARY',
typS: 'Passenger',
flag: '🇬🇧',
status: '항해중',
speed: 15.2,
heading: 10,
lat: 33.8,
lng: 127.0,
draft: 8.5,
depart: '상하이',
arrive: '부산항',
etd: '2026-02-24 18:00',
eta: '2026-02-26 06:00',
gt: '148,528',
dwt: '18,000',
loa: '345.0m',
beam: '41.0m',
built: '2004',
yard: "Chantiers de l'Atlantique",
callSign: 'GBQM2',
cls: "Lloyd's Register",
cargo: '승객 2,620명',
color: '#a855f7',
markerType: 'passenger',
},
{
mmsi: 353012345, imo: '9811000', name: 'EVER GIVEN', typS: 'Container', flag: '🇹🇼',
status: '항해중', speed: 14.7, heading: 220, lat: 35.20, lng: 129.20,
draft: 15.7, depart: '부산항', arrive: '카오슝', etd: '2026-02-25 02:00', eta: '2026-02-28 14:00',
gt: '220,940', dwt: '199,629', loa: '400.0m', beam: '59.0m', built: '2018', yard: '今治造船',
callSign: 'BIXE9', cls: 'ABS', cargo: '컨테이너 · 14,800 TEU',
color: '#3b82f6', markerType: 'container',
mmsi: 353012345,
imo: '9811000',
name: 'EVER GIVEN',
typS: 'Container',
flag: '🇹🇼',
status: '항해중',
speed: 14.7,
heading: 220,
lat: 35.2,
lng: 129.2,
draft: 15.7,
depart: '부산항',
arrive: '카오슝',
etd: '2026-02-25 02:00',
eta: '2026-02-28 14:00',
gt: '220,940',
dwt: '199,629',
loa: '400.0m',
beam: '59.0m',
built: '2018',
yard: '今治造船',
callSign: 'BIXE9',
cls: 'ABS',
cargo: '컨테이너 · 14,800 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440789012, imo: '—', name: '제85 대성호', typS: 'Fishing', flag: '🇰🇷',
status: '조업중', speed: 3.8, heading: 85, lat: 34.40, lng: 126.30,
draft: 1.8, depart: '목포항', arrive: '목포항', etd: '2026-02-25 03:00', eta: '2026-02-25 17:00',
gt: '65', dwt: '—', loa: '22.0m', beam: '5.8m', built: '2010', yard: '목포조선',
callSign: '—', cls: '한국선급', cargo: '어획물',
color: '#f97316', markerType: 'fishing',
mmsi: 440789012,
imo: '—',
name: '제85 대성호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 3.8,
heading: 85,
lat: 34.4,
lng: 126.3,
draft: 1.8,
depart: '목포항',
arrive: '목포항',
etd: '2026-02-25 03:00',
eta: '2026-02-25 17:00',
gt: '65',
dwt: '—',
loa: '22.0m',
beam: '5.8m',
built: '2010',
yard: '목포조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440890123, imo: '9878901', name: 'SK INNOVATION', typS: 'Chemical', flag: '🇰🇷',
status: '항해중', speed: 9.6, heading: 340, lat: 35.80, lng: 126.60,
draft: 6.5, depart: '대산항', arrive: '여수항', etd: '2026-02-25 10:00', eta: '2026-02-26 02:00',
gt: '11,200', dwt: '16,800', loa: '132.0m', beam: '20.4m', built: '2020', yard: '현대미포조선',
callSign: 'HLSK', cls: '한국선급(KR)', cargo: '톨루엔 · 8,500kL · IMO Class 3',
color: '#ef4444', markerType: 'tanker',
mmsi: 440890123,
imo: '9878901',
name: 'SK INNOVATION',
typS: 'Chemical',
flag: '🇰🇷',
status: '항해중',
speed: 9.6,
heading: 340,
lat: 35.8,
lng: 126.6,
draft: 6.5,
depart: '대산항',
arrive: '여수항',
etd: '2026-02-25 10:00',
eta: '2026-02-26 02:00',
gt: '11,200',
dwt: '16,800',
loa: '132.0m',
beam: '20.4m',
built: '2020',
yard: '현대미포조선',
callSign: 'HLSK',
cls: '한국선급(KR)',
cargo: '톨루엔 · 8,500kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440901234, imo: '9889012', name: 'KOREA EXPRESS', typS: 'Cargo', flag: '🇰🇷',
status: '항해중', speed: 10.1, heading: 190, lat: 36.20, lng: 128.50,
draft: 6.8, depart: '동해항', arrive: '포항항', etd: '2026-02-25 09:00', eta: '2026-02-25 15:00',
gt: '8,500', dwt: '12,000', loa: '118.0m', beam: '18.2m', built: '2014', yard: '대한조선',
callSign: 'HLKE', cls: '한국선급', cargo: '일반화물',
color: '#22c55e', markerType: 'cargo',
mmsi: 440901234,
imo: '9889012',
name: 'KOREA EXPRESS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 10.1,
heading: 190,
lat: 36.2,
lng: 128.5,
draft: 6.8,
depart: '동해항',
arrive: '포항항',
etd: '2026-02-25 09:00',
eta: '2026-02-25 15:00',
gt: '8,500',
dwt: '12,000',
loa: '118.0m',
beam: '18.2m',
built: '2014',
yard: '대한조선',
callSign: 'HLKE',
cls: '한국선급',
cargo: '일반화물',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440012345, imo: '—', name: 'ROKS SEJONG', typS: 'Navy', flag: '🇰🇷',
status: '작전중', speed: 16.0, heading: 270, lat: 35.30, lng: 129.50,
draft: 6.3, depart: '부산 해군기지', arrive: '—', etd: '—', eta: '—',
gt: '7,600', dwt: '—', loa: '165.9m', beam: '21.4m', built: '2008', yard: '현대중공업',
callSign: 'HLNS', cls: '군용', cargo: '군사작전',
color: '#6b7280', markerType: 'military',
mmsi: 440012345,
imo: '—',
name: 'ROKS SEJONG',
typS: 'Navy',
flag: '🇰🇷',
status: '작전중',
speed: 16.0,
heading: 270,
lat: 35.3,
lng: 129.5,
draft: 6.3,
depart: '부산 해군기지',
arrive: '—',
etd: '—',
eta: '—',
gt: '7,600',
dwt: '—',
loa: '165.9m',
beam: '21.4m',
built: '2008',
yard: '현대중공업',
callSign: 'HLNS',
cls: '군용',
cargo: '군사작전',
color: '#6b7280',
markerType: 'military',
},
{
mmsi: 440023456, imo: '—', name: '군산예인3호', typS: 'Tug', flag: '🇰🇷',
status: '대기중', speed: 5.5, heading: 140, lat: 35.90, lng: 126.90,
draft: 2.8, depart: '군산항', arrive: '군산항', etd: '—', eta: '—',
gt: '180', dwt: '—', loa: '28.0m', beam: '8.2m', built: '2019', yard: '통영조선',
callSign: 'HLGS', cls: '한국선급', cargo: '—',
color: '#06b6d4', markerType: 'tug',
mmsi: 440023456,
imo: '—',
name: '군산예인3호',
typS: 'Tug',
flag: '🇰🇷',
status: '대기중',
speed: 5.5,
heading: 140,
lat: 35.9,
lng: 126.9,
draft: 2.8,
depart: '군산항',
arrive: '군산항',
etd: '—',
eta: '—',
gt: '180',
dwt: '—',
loa: '28.0m',
beam: '8.2m',
built: '2019',
yard: '통영조선',
callSign: 'HLGS',
cls: '한국선급',
cargo: '—',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 440034567, imo: '—', name: 'JEJU WIND', typS: 'Sailing', flag: '🇰🇷',
status: '항해중', speed: 6.8, heading: 290, lat: 33.35, lng: 126.65,
draft: 2.5, depart: '제주항', arrive: '제주항', etd: '2026-02-25 10:00', eta: '2026-02-25 16:00',
gt: '45', dwt: '—', loa: '18.0m', beam: '5.0m', built: '2022', yard: '제주요트',
callSign: '—', cls: '—', cargo: '—',
color: '#fbbf24', markerType: 'sail',
mmsi: 440034567,
imo: '—',
name: 'JEJU WIND',
typS: 'Sailing',
flag: '🇰🇷',
status: '항해중',
speed: 6.8,
heading: 290,
lat: 33.35,
lng: 126.65,
draft: 2.5,
depart: '제주항',
arrive: '제주항',
etd: '2026-02-25 10:00',
eta: '2026-02-25 16:00',
gt: '45',
dwt: '—',
loa: '18.0m',
beam: '5.0m',
built: '2022',
yard: '제주요트',
callSign: '—',
cls: '—',
cargo: '—',
color: '#fbbf24',
markerType: 'sail',
},
{
mmsi: 440045678, imo: '—', name: '제33 삼양호', typS: 'Fishing', flag: '🇰🇷',
status: '조업중', speed: 2.4, heading: 55, lat: 35.10, lng: 127.40,
draft: 1.6, depart: '통영항', arrive: '통영항', etd: '2026-02-25 05:00', eta: '2026-02-25 19:00',
gt: '52', dwt: '—', loa: '20.0m', beam: '5.4m', built: '2006', yard: '거제조선',
callSign: '—', cls: '한국선급', cargo: '어획물',
color: '#f97316', markerType: 'fishing',
mmsi: 440045678,
imo: '—',
name: '제33 삼양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 2.4,
heading: 55,
lat: 35.1,
lng: 127.4,
draft: 1.6,
depart: '통영항',
arrive: '통영항',
etd: '2026-02-25 05:00',
eta: '2026-02-25 19:00',
gt: '52',
dwt: '—',
loa: '20.0m',
beam: '5.4m',
built: '2006',
yard: '거제조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 255012345, imo: '9703291', name: 'MSC OSCAR', typS: 'Container', flag: '🇨🇭',
status: '항해중', speed: 17.3, heading: 355, lat: 34.10, lng: 128.10,
draft: 14.0, depart: '카오슝', arrive: '부산항', etd: '2026-02-23 08:00', eta: '2026-02-25 22:00',
gt: '197,362', dwt: '199,272', loa: '395.4m', beam: '59.0m', built: '2015', yard: '대우조선해양',
callSign: '9HA4713', cls: 'DNV', cargo: '컨테이너 · 18,200 TEU',
color: '#3b82f6', markerType: 'container',
mmsi: 255012345,
imo: '9703291',
name: 'MSC OSCAR',
typS: 'Container',
flag: '🇨🇭',
status: '항해중',
speed: 17.3,
heading: 355,
lat: 34.1,
lng: 128.1,
draft: 14.0,
depart: '카오슝',
arrive: '부산항',
etd: '2026-02-23 08:00',
eta: '2026-02-25 22:00',
gt: '197,362',
dwt: '199,272',
loa: '395.4m',
beam: '59.0m',
built: '2015',
yard: '대우조선해양',
callSign: '9HA4713',
cls: 'DNV',
cargo: '컨테이너 · 18,200 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440056789, imo: '9890567', name: 'SAEHAN PIONEER', typS: 'Tanker', flag: '🇰🇷',
status: '항해중', speed: 7.9, heading: 310, lat: 34.90, lng: 127.10,
draft: 5.2, depart: '여수항', arrive: '대산항', etd: '2026-02-25 11:00', eta: '2026-02-26 08:00',
gt: '8,900', dwt: '14,200', loa: '120.0m', beam: '18.0m', built: '2017', yard: '현대미포조선',
callSign: 'HLSP', cls: '한국선급(KR)', cargo: '경유 · 10,000kL',
color: '#ef4444', markerType: 'tanker',
mmsi: 440056789,
imo: '9890567',
name: 'SAEHAN PIONEER',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 7.9,
heading: 310,
lat: 34.9,
lng: 127.1,
draft: 5.2,
depart: '여수항',
arrive: '대산항',
etd: '2026-02-25 11:00',
eta: '2026-02-26 08:00',
gt: '8,900',
dwt: '14,200',
loa: '120.0m',
beam: '18.0m',
built: '2017',
yard: '현대미포조선',
callSign: 'HLSP',
cls: '한국선급(KR)',
cargo: '경유 · 10,000kL',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440067890, imo: '9891678', name: 'DONGHAE STAR', typS: 'Cargo', flag: '🇰🇷',
status: '항해중', speed: 11.0, heading: 155, lat: 37.55, lng: 129.30,
draft: 6.0, depart: '속초항', arrive: '동해항', etd: '2026-02-25 12:00', eta: '2026-02-25 16:30',
gt: '6,200', dwt: '8,500', loa: '105.0m', beam: '16.5m', built: '2013', yard: '대한조선',
callSign: 'HLDS', cls: '한국선급', cargo: '일반화물 · 목재',
color: '#22c55e', markerType: 'cargo',
mmsi: 440067890,
imo: '9891678',
name: 'DONGHAE STAR',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.0,
heading: 155,
lat: 37.55,
lng: 129.3,
draft: 6.0,
depart: '속초항',
arrive: '동해항',
etd: '2026-02-25 12:00',
eta: '2026-02-25 16:30',
gt: '6,200',
dwt: '8,500',
loa: '105.0m',
beam: '16.5m',
built: '2013',
yard: '대한조선',
callSign: 'HLDS',
cls: '한국선급',
cargo: '일반화물 · 목재',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440078901, imo: '—', name: '제18 한라호', typS: 'Fishing', flag: '🇰🇷',
status: '귀항중', speed: 3.2, heading: 70, lat: 33.30, lng: 126.30,
draft: 1.9, depart: '서귀포항', arrive: '서귀포항', etd: '2026-02-25 04:00', eta: '2026-02-25 15:00',
gt: '58', dwt: '—', loa: '21.0m', beam: '5.6m', built: '2011', yard: '제주조선',
callSign: '—', cls: '한국선급', cargo: '어획물 · 갈치/고등어',
color: '#f97316', markerType: 'fishing',
mmsi: 440078901,
imo: '—',
name: '제18 한라호',
typS: 'Fishing',
flag: '🇰🇷',
status: '귀항중',
speed: 3.2,
heading: 70,
lat: 33.3,
lng: 126.3,
draft: 1.9,
depart: '서귀포항',
arrive: '서귀포항',
etd: '2026-02-25 04:00',
eta: '2026-02-25 15:00',
gt: '58',
dwt: '—',
loa: '21.0m',
beam: '5.6m',
built: '2011',
yard: '제주조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물 · 갈치/고등어',
color: '#f97316',
markerType: 'fishing',
},
]
];

파일 보기

@ -1,68 +1,68 @@
import axios from 'axios'
import axios from 'axios';
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // JWT 쿠키 자동 포함
timeout: 30000, // 30초 타임아웃
withCredentials: true, // JWT 쿠키 자동 포함
timeout: 30000, // 30초 타임아웃
maxContentLength: 10 * 1024 * 1024, // 응답 최대 10MB
maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB
})
maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB
});
// 응답 인터셉터: 민감한 에러 정보 노출 최소화 + 401 세션 만료 처리
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
const { status, data } = error.response
const { status, data } = error.response;
// 401: 인증 만료 → 로그아웃 처리 (로그인 요청 제외)
if (status === 401 && !error.config?.url?.includes('/auth/login')) {
import('../store/authStore').then(({ useAuthStore }) => {
const { isAuthenticated } = useAuthStore.getState()
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
useAuthStore.getState().logout()
useAuthStore.getState().logout();
}
})
});
}
return Promise.reject({
status,
message: data?.error || data?.message || '요청 처리 중 오류가 발생했습니다.',
})
});
}
return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' })
}
)
return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' });
},
);
export interface LayerDTO {
cmn_cd: string
up_cmn_cd: string | null
cmn_cd_full_nm: string
cmn_cd_nm: string
cmn_cd_level: number
clnm: string | null
data_tbl_nm?: string | null
icon?: string
count?: number
children?: LayerDTO[]
cmn_cd: string;
up_cmn_cd: string | null;
cmn_cd_full_nm: string;
cmn_cd_nm: string;
cmn_cd_level: number;
clnm: string | null;
data_tbl_nm?: string | null;
icon?: string;
count?: number;
children?: LayerDTO[];
}
export interface Layer {
id: string
parentId: string | null
name: string
fullName: string
level: number
wmsLayer: string | null
dataTblNm?: string | null
icon?: string
count?: number
children?: Layer[]
id: string;
parentId: string | null;
name: string;
fullName: string;
level: number;
wmsLayer: string | null;
dataTblNm?: string | null;
icon?: string;
count?: number;
children?: Layer[];
}
// DTO를 Layer로 변환 (재귀적으로 children도 변환)
@ -78,41 +78,41 @@ function convertToLayer(dto: LayerDTO): Layer {
icon: dto.icon,
count: dto.count,
children: dto.children ? dto.children.map(convertToLayer) : undefined,
}
};
}
// 모든 레이어 조회
export async function fetchAllLayers(): Promise<Layer[]> {
const response = await api.get<LayerDTO[]>('/layers')
return response.data.map(convertToLayer)
const response = await api.get<LayerDTO[]>('/layers');
return response.data.map(convertToLayer);
}
// 계층 구조 레이어 트리 조회
export async function fetchLayerTree(): Promise<Layer[]> {
const response = await api.get<LayerDTO[]>('/layers/tree/all')
return response.data.map(convertToLayer)
const response = await api.get<LayerDTO[]>('/layers/tree/all');
return response.data.map(convertToLayer);
}
// WMS 레이어만 조회
export async function fetchWMSLayers(): Promise<Layer[]> {
const response = await api.get<LayerDTO[]>('/layers/wms/all')
return response.data.map(convertToLayer)
const response = await api.get<LayerDTO[]>('/layers/wms/all');
return response.data.map(convertToLayer);
}
// 특정 레이어 조회
export async function fetchLayer(id: string): Promise<Layer> {
const response = await api.get<LayerDTO>(`/layers/${id}`)
return convertToLayer(response.data)
const response = await api.get<LayerDTO>(`/layers/${id}`);
return convertToLayer(response.data);
}
// 특정 레벨의 레이어 조회
export async function fetchLayersByLevel(level: number): Promise<Layer[]> {
const response = await api.get<LayerDTO[]>(`/layers/level/${level}`)
return response.data.map(convertToLayer)
const response = await api.get<LayerDTO[]>(`/layers/level/${level}`);
return response.data.map(convertToLayer);
}
// 특정 부모의 자식 레이어 조회
export async function fetchChildrenLayers(parentId: string): Promise<Layer[]> {
const response = await api.get<LayerDTO[]>(`/layers/children/${parentId}`)
return response.data.map(convertToLayer)
const response = await api.get<LayerDTO[]>(`/layers/children/${parentId}`);
return response.data.map(convertToLayer);
}

파일 보기

@ -1,292 +1,295 @@
import { api } from './api'
import { api } from './api';
export interface AuthUser {
id: string
account: string
name: string
rank: string | null
org: { sn: number; name: string; abbr: string } | null
roles: string[]
permissions: Record<string, string[]>
id: string;
account: string;
name: string;
rank: string | null;
org: { sn: number; name: string; abbr: string } | null;
roles: string[];
permissions: Record<string, string[]>;
}
interface LoginResponse {
success: boolean
user: AuthUser
pending?: boolean
message?: string
success: boolean;
user: AuthUser;
pending?: boolean;
message?: string;
}
export async function loginApi(account: string, password: string): Promise<AuthUser> {
const response = await api.post<LoginResponse>('/auth/login', { account, password })
return response.data.user
const response = await api.post<LoginResponse>('/auth/login', { account, password });
return response.data.user;
}
export class PendingApprovalError extends Error {
constructor(message: string) {
super(message)
this.name = 'PendingApprovalError'
super(message);
this.name = 'PendingApprovalError';
}
}
export async function googleLoginApi(credential: string): Promise<AuthUser> {
const response = await api.post<LoginResponse>('/auth/oauth/google', { credential })
const response = await api.post<LoginResponse>('/auth/oauth/google', { credential });
if (response.data.pending) {
throw new PendingApprovalError(response.data.message || '관리자 승인 후 로그인할 수 있습니다.')
throw new PendingApprovalError(response.data.message || '관리자 승인 후 로그인할 수 있습니다.');
}
return response.data.user
return response.data.user;
}
export async function logoutApi(): Promise<void> {
await api.post('/auth/logout')
await api.post('/auth/logout');
}
export async function fetchMe(): Promise<AuthUser> {
const response = await api.get<AuthUser>('/auth/me')
return response.data
const response = await api.get<AuthUser>('/auth/me');
return response.data;
}
// 사용자 관리 API (ADMIN 전용)
export interface UserListItem {
id: string
account: string
name: string
rank: string | null
orgSn: number | null
orgName: string | null
orgAbbr: string | null
status: string
failCount: number
lastLogin: string | null
roles: string[]
roleSns: number[]
regDtm: string
oauthProvider: string | null
email: string | null
id: string;
account: string;
name: string;
rank: string | null;
orgSn: number | null;
orgName: string | null;
orgAbbr: string | null;
status: string;
failCount: number;
lastLogin: string | null;
roles: string[];
roleSns: number[];
regDtm: string;
oauthProvider: string | null;
email: string | null;
}
export async function fetchUsers(search?: string, status?: string): Promise<UserListItem[]> {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (status) params.set('status', status)
const response = await api.get<UserListItem[]>(`/users?${params}`)
return response.data
const params = new URLSearchParams();
if (search) params.set('search', search);
if (status) params.set('status', status);
const response = await api.get<UserListItem[]>(`/users?${params}`);
return response.data;
}
export async function fetchUser(id: string): Promise<UserListItem> {
const response = await api.get<UserListItem>(`/users/${id}`)
return response.data
const response = await api.get<UserListItem>(`/users/${id}`);
return response.data;
}
export async function createUserApi(data: {
account: string
password: string
name: string
rank?: string
orgSn?: number
roleSns?: number[]
account: string;
password: string;
name: string;
rank?: string;
orgSn?: number;
roleSns?: number[];
}): Promise<{ id: string }> {
const response = await api.post<{ id: string }>('/users', data)
return response.data
const response = await api.post<{ id: string }>('/users', data);
return response.data;
}
export async function updateUserApi(id: string, data: {
name?: string
rank?: string
orgSn?: number | null
status?: string
}): Promise<void> {
await api.put(`/users/${id}`, data)
export async function updateUserApi(
id: string,
data: {
name?: string;
rank?: string;
orgSn?: number | null;
status?: string;
},
): Promise<void> {
await api.put(`/users/${id}`, data);
}
export async function changePasswordApi(id: string, password: string): Promise<void> {
await api.put(`/users/${id}/password`, { password })
await api.put(`/users/${id}/password`, { password });
}
export async function assignRolesApi(id: string, roleSns: number[]): Promise<void> {
await api.put(`/users/${id}/roles`, { roleSns })
await api.put(`/users/${id}/roles`, { roleSns });
}
// 조직 목록 API
export interface OrgItem {
orgSn: number
orgNm: string
orgAbbrNm: string | null
orgTpCd: string
upperOrgSn: number | null
orgSn: number;
orgNm: string;
orgAbbrNm: string | null;
orgTpCd: string;
upperOrgSn: number | null;
}
export async function fetchOrgs(): Promise<OrgItem[]> {
const response = await api.get<OrgItem[]>('/users/orgs')
return response.data
const response = await api.get<OrgItem[]>('/users/orgs');
return response.data;
}
// 역할/권한 API (ADMIN 전용)
export interface RoleWithPermissions {
sn: number
code: string
name: string
description: string | null
isDefault: boolean
sn: number;
code: string;
name: string;
description: string | null;
isDefault: boolean;
permissions: Array<{
sn: number
resourceCode: string
operationCode: string
granted: boolean
}>
sn: number;
resourceCode: string;
operationCode: string;
granted: boolean;
}>;
}
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
const response = await api.get<RoleWithPermissions[]>('/roles')
return response.data
const response = await api.get<RoleWithPermissions[]>('/roles');
return response.data;
}
// 권한 트리 구조 API
export interface PermTreeNode {
code: string
parentCode: string | null
name: string
description: string | null
icon: string | null
level: number
sortOrder: number
children: PermTreeNode[]
code: string;
parentCode: string | null;
name: string;
description: string | null;
icon: string | null;
level: number;
sortOrder: number;
children: PermTreeNode[];
}
export async function fetchPermTree(): Promise<PermTreeNode[]> {
const response = await api.get<PermTreeNode[]>('/roles/perm-tree')
return response.data
const response = await api.get<PermTreeNode[]>('/roles/perm-tree');
return response.data;
}
export async function updatePermissionsApi(
roleSn: number,
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>,
): Promise<void> {
await api.put(`/roles/${roleSn}/permissions`, { permissions })
await api.put(`/roles/${roleSn}/permissions`, { permissions });
}
export async function updateRoleDefaultApi(roleSn: number, isDefault: boolean): Promise<void> {
await api.put(`/roles/${roleSn}/default`, { isDefault })
await api.put(`/roles/${roleSn}/default`, { isDefault });
}
export async function createRoleApi(data: {
code: string
name: string
description?: string
code: string;
name: string;
description?: string;
}): Promise<RoleWithPermissions> {
const response = await api.post<RoleWithPermissions>('/roles', data)
return response.data
const response = await api.post<RoleWithPermissions>('/roles', data);
return response.data;
}
export async function updateRoleApi(
roleSn: number,
data: { name?: string; description?: string }
data: { name?: string; description?: string },
): Promise<void> {
await api.put(`/roles/${roleSn}`, data)
await api.put(`/roles/${roleSn}`, data);
}
export async function deleteRoleApi(roleSn: number): Promise<void> {
await api.delete(`/roles/${roleSn}`)
await api.delete(`/roles/${roleSn}`);
}
// 사용자 승인/거절 API (ADMIN 전용)
export async function approveUserApi(id: string): Promise<void> {
await api.put(`/users/${id}/approve`)
await api.put(`/users/${id}/approve`);
}
export async function rejectUserApi(id: string): Promise<void> {
await api.put(`/users/${id}/reject`)
await api.put(`/users/${id}/reject`);
}
// 시스템 설정 API (ADMIN 전용)
export interface RegistrationSettings {
autoApprove: boolean
defaultRole: boolean
autoApprove: boolean;
defaultRole: boolean;
}
export async function fetchRegistrationSettings(): Promise<RegistrationSettings> {
const response = await api.get<RegistrationSettings>('/settings/registration')
return response.data
const response = await api.get<RegistrationSettings>('/settings/registration');
return response.data;
}
export async function updateRegistrationSettingsApi(
settings: Partial<RegistrationSettings>
settings: Partial<RegistrationSettings>,
): Promise<RegistrationSettings> {
const response = await api.put<RegistrationSettings>('/settings/registration', settings)
return response.data
const response = await api.put<RegistrationSettings>('/settings/registration', settings);
return response.data;
}
// OAuth 설정 API (ADMIN 전용)
export interface OAuthSettings {
autoApproveDomains: string
autoApproveDomains: string;
}
export async function fetchOAuthSettings(): Promise<OAuthSettings> {
const response = await api.get<OAuthSettings>('/settings/oauth')
return response.data
const response = await api.get<OAuthSettings>('/settings/oauth');
return response.data;
}
export async function updateOAuthSettingsApi(
settings: Partial<OAuthSettings>
settings: Partial<OAuthSettings>,
): Promise<OAuthSettings> {
const response = await api.put<OAuthSettings>('/settings/oauth', settings)
return response.data
const response = await api.put<OAuthSettings>('/settings/oauth', settings);
return response.data;
}
// 메뉴 설정 API
export interface MenuConfigItem {
id: string
label: string
icon: string
enabled: boolean
order: number
id: string;
label: string;
icon: string;
enabled: boolean;
order: number;
}
export async function fetchMenuConfig(): Promise<MenuConfigItem[]> {
const response = await api.get<MenuConfigItem[]>('/menus')
return response.data
const response = await api.get<MenuConfigItem[]>('/menus');
return response.data;
}
export async function updateMenuConfigApi(menus: MenuConfigItem[]): Promise<MenuConfigItem[]> {
const response = await api.put<MenuConfigItem[]>('/menus', { menus })
return response.data
const response = await api.put<MenuConfigItem[]>('/menus', { menus });
return response.data;
}
// 감사 로그 API (ADMIN 전용)
export interface AuditLogItem {
logSn: number
userId: string
userName: string | null
userAccount: string | null
actionCd: string
actionDtl: string | null
httpMethod: string | null
crudType: string | null
reqUrl: string | null
reqDtm: string
resDtm: string | null
resStatus: number | null
resSize: number | null
ipAddr: string | null
userAgent: string | null
extra: Record<string, unknown> | null
logSn: number;
userId: string;
userName: string | null;
userAccount: string | null;
actionCd: string;
actionDtl: string | null;
httpMethod: string | null;
crudType: string | null;
reqUrl: string | null;
reqDtm: string;
resDtm: string | null;
resStatus: number | null;
resSize: number | null;
ipAddr: string | null;
userAgent: string | null;
extra: Record<string, unknown> | null;
}
export interface AuditLogListResult {
items: AuditLogItem[]
total: number
page: number
size: number
items: AuditLogItem[];
total: number;
page: number;
size: number;
}
export async function fetchAuditLogs(params?: {
page?: number
size?: number
userId?: string
actionCd?: string
from?: string
to?: string
page?: number;
size?: number;
userId?: string;
actionCd?: string;
from?: string;
to?: string;
}): Promise<AuditLogListResult> {
const response = await api.get<AuditLogListResult>('/audit/logs', { params })
return response.data
const response = await api.get<AuditLogListResult>('/audit/logs', { params });
return response.data;
}

파일 보기

@ -1,70 +1,70 @@
// 레이어 데이터베이스 - API에서 가져옴
import { fetchAllLayers } from '@common/services/api'
import { fetchAllLayers } from '@common/services/api';
export interface Layer {
id: string
parentId: string | null
name: string
fullName: string
level: number
wmsLayer: string | null
dataTblNm?: string | null
icon?: string
count?: number
children?: Layer[]
id: string;
parentId: string | null;
name: string;
fullName: string;
level: number;
wmsLayer: string | null;
dataTblNm?: string | null;
icon?: string;
count?: number;
children?: Layer[];
}
// 캐시된 레이어 데이터
let cachedLayers: Layer[] | null = null
let cachedLayers: Layer[] | null = null;
// API에서 레이어 가져오기 (캐싱 포함)
export async function getLayersFromAPI(): Promise<Layer[]> {
if (cachedLayers) {
return cachedLayers
return cachedLayers;
}
try {
const layers = await fetchAllLayers()
cachedLayers = layers
return layers
const layers = await fetchAllLayers();
cachedLayers = layers;
return layers;
} catch (error) {
console.error('레이어 데이터를 불러오는 중 오류 발생:', error)
console.error('레이어 데이터를 불러오는 중 오류 발생:', error);
// fallback to empty array if API fails
return []
return [];
}
}
// 하드코딩된 레이어 데이터 (fallback용, API 개발 중에만 사용)
export const layerDatabase: Layer[] = []
export const layerDatabase: Layer[] = [];
// 계층 구조로 변환하는 함수
export function buildLayerTree(layers: Layer[]): Layer[] {
const layerMap = new Map<string, Layer & { children: Layer[] }>()
const layerMap = new Map<string, Layer & { children: Layer[] }>();
// 모든 레이어를 맵에 추가
layers.forEach(layer => {
layerMap.set(layer.id, { ...layer, children: [] })
})
layers.forEach((layer) => {
layerMap.set(layer.id, { ...layer, children: [] });
});
const rootLayers: Layer[] = []
const rootLayers: Layer[] = [];
// 부모-자식 관계 설정
layers.forEach(layer => {
const layerNode = layerMap.get(layer.id)!
layers.forEach((layer) => {
const layerNode = layerMap.get(layer.id)!;
if (layer.parentId === null) {
rootLayers.push(layerNode)
rootLayers.push(layerNode);
} else {
const parent = layerMap.get(layer.parentId)
const parent = layerMap.get(layer.parentId);
if (parent) {
parent.children.push(layerNode)
parent.children.push(layerNode);
}
}
})
});
return rootLayers
return rootLayers;
}
// WMS 레이어만 필터링하는 함수
export function getWMSLayers(layers: Layer[]): Layer[] {
return layers.filter(layer => layer.wmsLayer !== null)
return layers.filter((layer) => layer.wmsLayer !== null);
}

파일 보기

@ -1,19 +1,25 @@
import { create } from 'zustand'
import { loginApi, googleLoginApi, logoutApi, fetchMe, PendingApprovalError } from '../services/authApi'
import type { AuthUser } from '../services/authApi'
import { create } from 'zustand';
import {
loginApi,
googleLoginApi,
logoutApi,
fetchMe,
PendingApprovalError,
} from '../services/authApi';
import type { AuthUser } from '../services/authApi';
interface AuthState {
user: AuthUser | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
pendingMessage: string | null
login: (account: string, password: string) => Promise<void>
googleLogin: (credential: string) => Promise<void>
logout: () => Promise<void>
checkSession: () => Promise<void>
hasPermission: (resource: string, operation?: string) => boolean
clearError: () => void
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
pendingMessage: string | null;
login: (account: string, password: string) => Promise<void>;
googleLogin: (credential: string) => Promise<void>;
logout: () => Promise<void>;
checkSession: () => Promise<void>;
hasPermission: (resource: string, operation?: string) => boolean;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
@ -24,68 +30,68 @@ export const useAuthStore = create<AuthState>((set, get) => ({
pendingMessage: null,
login: async (account: string, password: string) => {
set({ isLoading: true, error: null })
set({ isLoading: true, error: null });
try {
const user = await loginApi(account, password)
set({ user, isAuthenticated: true, isLoading: false })
const user = await loginApi(account, password);
set({ user, isAuthenticated: true, isLoading: false });
} catch (err) {
const message = (err as { message?: string })?.message || '로그인에 실패했습니다.'
set({ isLoading: false, error: message })
throw err
const message = (err as { message?: string })?.message || '로그인에 실패했습니다.';
set({ isLoading: false, error: message });
throw err;
}
},
googleLogin: async (credential: string) => {
set({ isLoading: true, error: null, pendingMessage: null })
set({ isLoading: true, error: null, pendingMessage: null });
try {
const user = await googleLoginApi(credential)
set({ user, isAuthenticated: true, isLoading: false })
const user = await googleLoginApi(credential);
set({ user, isAuthenticated: true, isLoading: false });
} catch (err) {
if (err instanceof PendingApprovalError) {
set({ isLoading: false, pendingMessage: err.message })
return
set({ isLoading: false, pendingMessage: err.message });
return;
}
const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.'
set({ isLoading: false, error: message })
throw err
const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
try {
await logoutApi()
await logoutApi();
} catch {
// 로그아웃 실패해도 클라이언트 상태는 초기화
}
set({ user: null, isAuthenticated: false, isLoading: false, error: null })
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
},
checkSession: async () => {
set({ isLoading: true })
set({ isLoading: true });
try {
const user = await fetchMe()
set({ user, isAuthenticated: true, isLoading: false })
const user = await fetchMe();
set({ user, isAuthenticated: true, isLoading: false });
} catch {
set({ user: null, isAuthenticated: false, isLoading: false })
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
hasPermission: (resource: string, operation?: string) => {
const { user } = get()
if (!user) return false
const op = operation ?? 'READ'
const { user } = get();
if (!user) return false;
const op = operation ?? 'READ';
// 정확한 리소스 권한 확인
const ops = user.permissions[resource]
if (ops) return ops.includes(op)
const ops = user.permissions[resource];
if (ops) return ops.includes(op);
// 'scat:survey' → 부모 'scat' 권한으로 fallback
const colonIdx = resource.indexOf(':')
const colonIdx = resource.indexOf(':');
if (colonIdx > 0) {
const parent = resource.substring(0, colonIdx)
const parentOps = user.permissions[parent]
if (parentOps) return parentOps.includes(op)
const parent = resource.substring(0, colonIdx);
const parentOps = user.permissions[parent];
if (parentOps) return parentOps.includes(op);
}
return false
return false;
},
clearError: () => set({ error: null, pendingMessage: null }),
}))
}));

파일 보기

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { api } from '../services/api'
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
import { create } from 'zustand';
import { api } from '../services/api';
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
export interface MapTypeItem {
mapKey: string;
@ -46,11 +46,11 @@ interface MapState {
}
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
]
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
];
let measureIdCounter = 0;
@ -63,20 +63,20 @@ export const useMapStore = create<MapState>((set, get) => ({
})),
loadMapTypes: async () => {
try {
const res = await api.get<MapTypeItem[]>('/map-base/active')
const types = res.data
const current = get().mapToggles
const newToggles: Partial<MapToggles> = {}
const res = await api.get<MapTypeItem[]>('/map-base/active');
const types = res.data;
const current = get().mapToggles;
const newToggles: Partial<MapToggles> = {};
for (const t of types) {
if (t.mapKey in current) {
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false;
}
}
// s57 기본값 유지
if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) {
newToggles['s57'] = true
if (newToggles['s57'] === undefined && types.find((t) => t.mapKey === 's57')) {
newToggles['s57'] = true;
}
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
} catch {
// API 실패 시 fallback 유지
}
@ -87,8 +87,7 @@ export const useMapStore = create<MapState>((set, get) => ({
measureInProgress: [],
measurements: [],
setMeasureMode: (mode) =>
set({ measureMode: mode, measureInProgress: [] }),
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
addMeasurePoint: (pt) => {
const { measureMode, measureInProgress } = get();
@ -98,7 +97,10 @@ export const useMapStore = create<MapState>((set, get) => ({
const dist = haversineDistance(next[0], next[1]);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
measurements: [
...s.measurements,
{ id, mode: 'distance', points: [next[0], next[1]], value: dist },
],
measureInProgress: [],
}));
} else {
@ -115,7 +117,10 @@ export const useMapStore = create<MapState>((set, get) => ({
const area = polygonAreaKm2(measureInProgress);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
measurements: [
...s.measurements,
{ id, mode: 'area', points: [...measureInProgress], value: area },
],
measureInProgress: [],
}));
},
@ -123,6 +128,5 @@ export const useMapStore = create<MapState>((set, get) => ({
removeMeasurement: (id) =>
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
clearAllMeasurements: () =>
set({ measurements: [], measureInProgress: [], measureMode: null }),
}))
clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }),
}));

파일 보기

@ -1,12 +1,12 @@
import { create } from 'zustand'
import { fetchMenuConfig } from '../services/authApi'
import type { MenuConfigItem } from '../services/authApi'
import { create } from 'zustand';
import { fetchMenuConfig } from '../services/authApi';
import type { MenuConfigItem } from '../services/authApi';
interface MenuState {
menuConfig: MenuConfigItem[]
isLoaded: boolean
loadMenuConfig: () => Promise<void>
setMenuConfig: (config: MenuConfigItem[]) => void
menuConfig: MenuConfigItem[];
isLoaded: boolean;
loadMenuConfig: () => Promise<void>;
setMenuConfig: (config: MenuConfigItem[]) => void;
}
export const useMenuStore = create<MenuState>((set) => ({
@ -15,14 +15,14 @@ export const useMenuStore = create<MenuState>((set) => ({
loadMenuConfig: async () => {
try {
const config = await fetchMenuConfig()
set({ menuConfig: config, isLoaded: true })
const config = await fetchMenuConfig();
set({ menuConfig: config, isLoaded: true });
} catch {
set({ isLoaded: true })
set({ isLoaded: true });
}
},
setMenuConfig: (config: MenuConfigItem[]) => {
set({ menuConfig: config })
set({ menuConfig: config });
},
}))
}));

파일 보기

@ -1,8 +1,32 @@
/* ── PretendardGOV @font-face ── */
@font-face { font-family: 'PretendardGOV'; font-weight: 400; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Regular.otf') format('opentype'); }
@font-face { font-family: 'PretendardGOV'; font-weight: 500; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Medium.otf') format('opentype'); }
@font-face { font-family: 'PretendardGOV'; font-weight: 600; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-SemiBold.otf') format('opentype'); }
@font-face { font-family: 'PretendardGOV'; font-weight: 700; font-style: normal; font-display: swap; src: url('/fonts/PretendardGOV-Bold.otf') format('opentype'); }
@font-face {
font-family: 'PretendardGOV';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/PretendardGOV-Regular.otf') format('opentype');
}
@font-face {
font-family: 'PretendardGOV';
font-weight: 500;
font-style: normal;
font-display: swap;
src: url('/fonts/PretendardGOV-Medium.otf') format('opentype');
}
@font-face {
font-family: 'PretendardGOV';
font-weight: 600;
font-style: normal;
font-display: swap;
src: url('/fonts/PretendardGOV-SemiBold.otf') format('opentype');
}
@font-face {
font-family: 'PretendardGOV';
font-weight: 700;
font-style: normal;
font-display: swap;
src: url('/fonts/PretendardGOV-Bold.otf') format('opentype');
}
@layer base {
:root {
@ -30,8 +54,14 @@
--color-boom: #f59e0b;
--color-boom-hover: #fbbf24;
/* font */
--font-korean: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
--font-mono: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
--font-korean:
'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic',
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
--font-mono:
'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic',
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
/* radius */
--radius-sm: 6px;
--radius-md: 8px;
@ -45,6 +75,10 @@
--font-size-heading-3: 1.375rem;
--font-size-title-1: 1.125rem;
--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;
@ -60,6 +94,12 @@
--line-height-snug: 1.4;
--line-height-normal: 1.5;
--line-height-relaxed: 1.6;
/* typography — letter-spacing */
--letter-spacing-display: 0.06em;
--letter-spacing-heading: 0.02em;
--letter-spacing-body: 0em;
--letter-spacing-navigation: 0.02em;
--letter-spacing-label: 0.04em;
/* === Design Token System === */
@ -144,7 +184,7 @@
}
/* ── Light theme overrides ── */
[data-theme="light"] {
[data-theme='light'] {
--bg-base: #f8fafc;
--bg-surface: #ffffff;
--bg-elevated: #f1f5f9;
@ -168,7 +208,21 @@
}
body {
font-family: 'PretendardGOV', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard, Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
font-family:
'PretendardGOV',
-apple-system,
BlinkMacSystemFont,
'Apple SD Gothic Neo',
'Pretendard Variable',
Pretendard,
Roboto,
'Noto Sans KR',
'Segoe UI',
'Malgun Gothic',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
sans-serif;
background: var(--bg-base);
color: var(--fg-default);
height: 100vh;
@ -180,22 +234,22 @@
}
/* Date input calendar icon — white for dark theme */
input[type="date"]::-webkit-calendar-picker-indicator {
input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
opacity: 0.7;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover {
input[type='date']::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* Light theme: calendar icon reset */
[data-theme="light"] input[type="date"]::-webkit-calendar-picker-indicator {
[data-theme='light'] input[type='date']::-webkit-calendar-picker-indicator {
filter: none;
}
/* Light theme: invert white logos */
[data-theme="light"] .wing-logo {
[data-theme='light'] .wing-logo {
filter: brightness(0) saturate(100%);
}
}

파일 보기

@ -152,9 +152,13 @@
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: var(--bg-base) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E") no-repeat right 8px center;
background: var(--bg-base)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E")
no-repeat right 8px center;
background-size: 10px;
transition: border-color 0.2s, background-image 0.2s;
transition:
border-color 0.2s,
background-image 0.2s;
}
select.prd-i:hover {
@ -381,7 +385,7 @@
padding: 5px 14px;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: rgba(255,255,255,0.7);
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
z-index: 20;
display: flex;
@ -429,7 +433,7 @@
.wii-label {
font-size: 0.625rem;
color: rgba(255,255,255,0.55);
color: rgba(255, 255, 255, 0.55);
font-weight: 400;
font-family: var(--font-korean);
}
@ -745,7 +749,7 @@
.toggle-slider:before {
position: absolute;
content: "";
content: '';
height: 12px;
width: 12px;
left: 2px;
@ -852,7 +856,7 @@
margin: 0;
}
.boom-setting-input[type=number] {
.boom-setting-input[type='number'] {
-moz-appearance: textfield;
}
@ -901,8 +905,15 @@
}
@keyframes bt-collision-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.6; }
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3);
opacity: 0.6;
}
}
.bt-collision-marker {
@ -917,7 +928,9 @@
border: 1px solid var(--stroke-default);
background: var(--bg-card);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
transition:
border-color 0.15s,
background 0.15s;
}
.hns-scn-card:hover {
@ -943,7 +956,7 @@
cursor: pointer;
text-align: center;
border-bottom: 2px solid transparent;
transition: .2s;
transition: 0.2s;
}
.rsc-atab:hover {
@ -953,7 +966,7 @@
.rsc-atab.on {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
background: rgba(6, 182, 212, .04);
background: rgba(6, 182, 212, 0.04);
}
}
@ -968,23 +981,47 @@
/* ═══ Animations ═══ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
@keyframes pulse-border {
0%, 100% { border-color: rgba(239, 68, 68, 0.2); }
50% { border-color: rgba(239, 68, 68, 0.5); }
0%,
100% {
border-color: rgba(239, 68, 68, 0.2);
}
50% {
border-color: rgba(239, 68, 68, 0.5);
}
}
@keyframes fadeSlideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
@ -1114,7 +1151,9 @@
cursor: pointer;
font-size: 11px;
color: var(--fg-sub);
transition: color 0.15s, background 0.15s;
transition:
color 0.15s,
background 0.15s;
font-family: var(--font-korean);
border-radius: 3px;
}
@ -1142,15 +1181,28 @@
border: 1.5px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
position: relative;
}
.lyr-csw::after {
content: '';
position: absolute;
top: 2px; left: 2px; right: 2px; bottom: 2px;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
border-radius: 1px;
background: linear-gradient(135deg, #ef4444 25%, #3b82f6 25%, #3b82f6 50%, #22c55e 50%, #22c55e 75%, #eab308 75%);
background: linear-gradient(
135deg,
#ef4444 25%,
#3b82f6 25%,
#3b82f6 50%,
#22c55e 50%,
#22c55e 75%,
#eab308 75%
);
opacity: 0.6;
}
.lyr-csw.has-color::after {
@ -1183,8 +1235,14 @@
}
@keyframes lyrPopIn {
from { opacity: 0; transform: translateY(-4px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
from {
opacity: 0;
transform: translateY(-4px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.lyr-cpr {
@ -1226,7 +1284,7 @@
font-family: var(--font-korean);
}
.lyr-ccustom input[type="color"] {
.lyr-ccustom input[type='color'] {
width: 24px;
height: 20px;
border: 1px solid var(--stroke-default);
@ -1251,7 +1309,7 @@
font-family: var(--font-korean);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: .3px;
letter-spacing: 0.3px;
}
.lyr-style-row {
display: flex;
@ -1337,87 +1395,87 @@
/* ═══ Light Theme Overrides ═══ */
/* CCTV popup */
[data-theme="light"] .cctv-dark-popup .maplibregl-popup-content {
[data-theme='light'] .cctv-dark-popup .maplibregl-popup-content {
background: #ffffff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border: 1px solid var(--stroke-default);
}
[data-theme="light"] .cctv-dark-popup .maplibregl-popup-tip {
[data-theme='light'] .cctv-dark-popup .maplibregl-popup-tip {
border-top-color: #ffffff;
border-bottom-color: #ffffff;
}
[data-theme="light"] .cctv-dark-popup .maplibregl-popup-close-button {
[data-theme='light'] .cctv-dark-popup .maplibregl-popup-close-button {
color: var(--fg-disabled);
}
[data-theme="light"] .cctv-dark-popup .maplibregl-popup-close-button:hover {
[data-theme='light'] .cctv-dark-popup .maplibregl-popup-close-button:hover {
color: var(--fg-default);
}
/* Date/Time picker color-scheme */
[data-theme="light"] .prd-date-input,
[data-theme="light"] .prd-time-input {
[data-theme='light'] .prd-date-input,
[data-theme='light'] .prd-time-input {
color-scheme: light;
}
[data-theme="light"] select.prd-i.prd-time-select {
[data-theme='light'] select.prd-i.prd-time-select {
color-scheme: light;
}
/* Select option */
[data-theme="light"] select.prd-i option {
[data-theme='light'] select.prd-i option {
background: #ffffff;
}
[data-theme="light"] select.prd-i option:checked {
[data-theme='light'] select.prd-i option:checked {
background: linear-gradient(0deg, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.08) 100%);
}
/* Select hover border */
[data-theme="light"] select.prd-i:hover {
[data-theme='light'] select.prd-i:hover {
border-color: var(--stroke-light);
}
/* Model chip text */
[data-theme="light"] .prd-mc {
[data-theme='light'] .prd-mc {
color: var(--fg-disabled);
}
[data-theme="light"] .prd-mc.on {
[data-theme='light'] .prd-mc.on {
color: var(--fg-default);
}
/* Coordinate display */
[data-theme="light"] .cod {
[data-theme='light'] .cod {
background: rgba(255, 255, 255, 0.85);
color: var(--fg-sub);
}
[data-theme="light"] .cov {
[data-theme='light'] .cov {
color: var(--fg-default);
}
/* Weather info panel */
[data-theme="light"] .wip {
[data-theme='light'] .wip {
background: rgba(255, 255, 255, 0.85);
}
[data-theme="light"] .wii-value {
[data-theme='light'] .wii-value {
color: var(--fg-default);
}
[data-theme="light"] .wii-label {
[data-theme='light'] .wii-label {
color: var(--fg-sub);
}
/* Timeline control panel */
[data-theme="light"] .tlb {
[data-theme='light'] .tlb {
background: rgba(255, 255, 255, 0.95);
}
/* Timeline boom tooltip */
[data-theme="light"] .tlbm .tlbt {
[data-theme='light'] .tlbm .tlbt {
background: rgba(255, 255, 255, 0.95);
}
/* Combo item border */
[data-theme="light"] .combo-item {
[data-theme='light'] .combo-item {
border-bottom: 1px solid var(--stroke-light);
}
[data-theme="light"] .combo-list {
[data-theme='light'] .combo-list {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
}

파일 보기

@ -1,61 +1,61 @@
export interface BacktrackVessel {
rank: number
name: string
imo: string
type: string
flag: string
flagCountry: string
probability: number
closestTime: string
closestDistance: number
speedChange: string
aisStatus: string
description: string
color: string
rank: number;
name: string;
imo: string;
type: string;
flag: string;
flagCountry: string;
probability: number;
closestTime: string;
closestDistance: number;
speedChange: string;
aisStatus: string;
description: string;
color: string;
}
export interface BacktrackConditions {
estimatedSpillTime: string
analysisRange: string
searchRadius: string
spillLocation: { lat: number; lon: number }
totalVessels: number
estimatedSpillTime: string;
analysisRange: string;
searchRadius: string;
spillLocation: { lat: number; lon: number };
totalVessels: number;
}
export interface ReplayPathPoint {
lat: number
lon: number
lat: number;
lon: number;
}
export interface ReplayShip {
vesselName: string
color: string
path: ReplayPathPoint[]
speedLabels: string[]
vesselName: string;
color: string;
path: ReplayPathPoint[];
speedLabels: string[];
}
export interface CollisionEvent {
position: { lat: number; lon: number }
timeLabel: string
progressPercent: number
position: { lat: number; lon: number };
timeLabel: string;
progressPercent: number;
}
export type BacktrackPhase = 'conditions' | 'analyzing' | 'results' | 'replay'
export type BacktrackPhase = 'conditions' | 'analyzing' | 'results' | 'replay';
export const TOTAL_REPLAY_FRAMES = 120
export const TOTAL_REPLAY_FRAMES = 120;
// 역추적 분석 실행 시 사용자가 입력하는 조건
export interface BacktrackInputConditions {
estimatedSpillTime: string // datetime-local input 값 (YYYY-MM-DDTHH:mm)
analysisRange: string // '6', '12', '24' (시간 단위)
searchRadius: number // NM 단위
estimatedSpillTime: string; // datetime-local input 값 (YYYY-MM-DDTHH:mm)
analysisRange: string; // '6', '12', '24' (시간 단위)
searchRadius: number; // NM 단위
}
// RSLT_DATA에 저장되는 분석 결과 타임 레인지
export interface BacktrackTimeRange {
start: string // ISO 문자열
end: string // ISO 문자열
start: string; // ISO 문자열
end: string; // ISO 문자열
}
// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열
export type BackwardParticleStep = [number, number][]
export type BackwardParticleStep = [number, number][];

파일 보기

@ -1,41 +1,41 @@
export type BoomPriority = 'CRITICAL' | 'HIGH' | 'MEDIUM'
export type BoomStatus = 'PLANNED' | 'DEPLOYING' | 'DEPLOYED' | 'REMOVED'
export type BoomType = '고강도 차단형' | '외해용 중형 포위망' | '연안 경량형' | '기타'
export type BoomPriority = 'CRITICAL' | 'HIGH' | 'MEDIUM';
export type BoomStatus = 'PLANNED' | 'DEPLOYING' | 'DEPLOYED' | 'REMOVED';
export type BoomType = '고강도 차단형' | '외해용 중형 포위망' | '연안 경량형' | '기타';
export interface BoomLineCoord {
lat: number
lon: number
lat: number;
lon: number;
}
export interface BoomLine {
id: string
name: string
priority: BoomPriority
type: BoomType
coords: BoomLineCoord[]
length: number // meters
angle: number // bearing degrees
efficiency: number // 0-100
status: BoomStatus
id: string;
name: string;
priority: BoomPriority;
type: BoomType;
coords: BoomLineCoord[];
length: number; // meters
angle: number; // bearing degrees
efficiency: number; // 0-100
status: BoomStatus;
}
export interface AlgorithmSettings {
currentOrthogonalCorrection: number // degrees (default 15)
safetyMarginMinutes: number // minutes (default 60)
minContainmentEfficiency: number // percent (default 80)
waveHeightCorrectionFactor: number // multiplier (default 1.0)
currentOrthogonalCorrection: number; // degrees (default 15)
safetyMarginMinutes: number; // minutes (default 60)
minContainmentEfficiency: number; // percent (default 80)
waveHeightCorrectionFactor: number; // multiplier (default 1.0)
}
export interface ContainmentResult {
totalParticles: number
blockedParticles: number
passedParticles: number
overallEfficiency: number
totalParticles: number;
blockedParticles: number;
passedParticles: number;
overallEfficiency: number;
perLineResults: Array<{
boomLineId: string
boomLineName: string
blocked: number
passed: number
efficiency: number
}>
boomLineId: string;
boomLineName: string;
blocked: number;
passed: number;
efficiency: number;
}>;
}

파일 보기

@ -1,67 +1,67 @@
/* HNS 물질 검색 데이터 타입 */
export interface HNSSearchSubstance {
id: number
abbreviation: string // 약자/제품명 (화물적부도 코드)
nameKr: string // 국문명
nameEn: string // 영문명
synonymsEn: string // 영문 동의어
synonymsKr: string // 국문 동의어/용도
unNumber: string // UN번호
casNumber: string // CAS번호
transportMethod: string // 운송방법
sebc: string // SEBC 거동분류
id: number;
abbreviation: string; // 약자/제품명 (화물적부도 코드)
nameKr: string; // 국문명
nameEn: string; // 영문명
synonymsEn: string; // 영문 동의어
synonymsKr: string; // 국문 동의어/용도
unNumber: string; // UN번호
casNumber: string; // CAS번호
transportMethod: string; // 운송방법
sebc: string; // SEBC 거동분류
/* 물리·화학적 특성 */
usage: string
state: string
color: string
odor: string
flashPoint: string
autoIgnition: string
boilingPoint: string
density: string // 비중 (물=1)
solubility: string
vaporPressure: string
vaporDensity: string // 증기밀도 (공기=1)
explosionRange: string // 폭발범위
usage: string;
state: string;
color: string;
odor: string;
flashPoint: string;
autoIgnition: string;
boilingPoint: string;
density: string; // 비중 (물=1)
solubility: string;
vaporPressure: string;
vaporDensity: string; // 증기밀도 (공기=1)
explosionRange: string; // 폭발범위
/* 위험등급·농도기준 */
nfpa: { health: number; fire: number; reactivity: number; special: string }
hazardClass: string
ergNumber: string
idlh: string
aegl2: string
erpg2: string
nfpa: { health: number; fire: number; reactivity: number; special: string };
hazardClass: string;
ergNumber: string;
idlh: string;
aegl2: string;
erpg2: string;
/* 방제거리 */
responseDistanceFire: string
responseDistanceSpillDay: string
responseDistanceSpillNight: string
marineResponse: string
responseDistanceFire: string;
responseDistanceSpillDay: string;
responseDistanceSpillNight: string;
marineResponse: string;
/* PPE */
ppeClose: string
ppeFar: string
ppeClose: string;
ppeFar: string;
/* MSDS 요약 */
msds: {
hazard: string
firstAid: string
fireFighting: string
spillResponse: string
exposure: string
regulation: string
}
hazard: string;
firstAid: string;
fireFighting: string;
spillResponse: string;
exposure: string;
regulation: string;
};
/* IBC CODE */
ibcHazard: string
ibcShipType: string
ibcTankType: string
ibcDetection: string
ibcFireFighting: string
ibcMinRequirement: string
ibcHazard: string;
ibcShipType: string;
ibcTankType: string;
ibcDetection: string;
ibcFireFighting: string;
ibcMinRequirement: string;
/* EmS */
emsCode: string
emsFire: string
emsSpill: string
emsFirstAid: string
emsCode: string;
emsFire: string;
emsSpill: string;
emsFirstAid: string;
/* 화물적부도 코드 */
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
/* 항구별 반입 */
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
}

파일 보기

@ -1 +1,13 @@
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitor' | 'admin';
export type MainTab =
| 'prediction'
| 'hns'
| 'rescue'
| 'reports'
| 'aerial'
| 'assets'
| 'scat'
| 'incidents'
| 'board'
| 'weather'
| 'monitor'
| 'admin';

파일 보기

@ -1,28 +1,33 @@
// 십진법(DD)을 도분초(DMS)로 변환
export function decimalToDMS(decimal: number, isLatitude: boolean): string {
const absolute = Math.abs(decimal)
const degrees = Math.floor(absolute)
const minutesDecimal = (absolute - degrees) * 60
const minutes = Math.floor(minutesDecimal)
const seconds = ((minutesDecimal - minutes) * 60).toFixed(2)
const absolute = Math.abs(decimal);
const degrees = Math.floor(absolute);
const minutesDecimal = (absolute - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = ((minutesDecimal - minutes) * 60).toFixed(2);
let direction = ''
let direction = '';
if (isLatitude) {
direction = decimal >= 0 ? 'N' : 'S'
direction = decimal >= 0 ? 'N' : 'S';
} else {
direction = decimal >= 0 ? 'E' : 'W'
direction = decimal >= 0 ? 'E' : 'W';
}
return `${degrees}° ${minutes}' ${seconds}" ${direction}`
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
}
// 도분초를 십진법으로 변환
export function dmsToDecimal(degrees: number, minutes: number, seconds: number, direction: 'N' | 'S' | 'E' | 'W'): number {
let decimal = degrees + minutes / 60 + seconds / 3600
export function dmsToDecimal(
degrees: number,
minutes: number,
seconds: number,
direction: 'N' | 'S' | 'E' | 'W',
): number {
let decimal = degrees + minutes / 60 + seconds / 3600;
if (direction === 'S' || direction === 'W') {
decimal = -decimal
decimal = -decimal;
}
return decimal
return decimal;
}

파일 보기

@ -1,94 +1,102 @@
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '../types/boomLine'
import type {
BoomLine,
BoomLineCoord,
AlgorithmSettings,
ContainmentResult,
} from '../types/boomLine';
const DEG2RAD = Math.PI / 180
const RAD2DEG = 180 / Math.PI
const EARTH_RADIUS = 6371000 // meters
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const EARTH_RADIUS = 6371000; // meters
// ============================================================
// Convex Hull + 면적 계산
// ============================================================
interface LatLon { lat: number; lon: number }
interface LatLon {
lat: number;
lon: number;
}
/** Convex Hull (Graham Scan) — 입자 좌표 배열 → 외곽 다각형 좌표 반환 */
export function convexHull(points: LatLon[]): LatLon[] {
if (points.length < 3) return [...points]
if (points.length < 3) return [...points];
// 가장 아래(lat 최소) 점 찾기 (동일하면 lon 최소)
const sorted = [...points].sort((a, b) => a.lat - b.lat || a.lon - b.lon)
const pivot = sorted[0]
const sorted = [...points].sort((a, b) => a.lat - b.lat || a.lon - b.lon);
const pivot = sorted[0];
// pivot 기준 극각으로 정렬
const rest = sorted.slice(1).sort((a, b) => {
const angleA = Math.atan2(a.lon - pivot.lon, a.lat - pivot.lat)
const angleB = Math.atan2(b.lon - pivot.lon, b.lat - pivot.lat)
if (angleA !== angleB) return angleA - angleB
const angleA = Math.atan2(a.lon - pivot.lon, a.lat - pivot.lat);
const angleB = Math.atan2(b.lon - pivot.lon, b.lat - pivot.lat);
if (angleA !== angleB) return angleA - angleB;
// 같은 각도면 거리 순
const dA = (a.lat - pivot.lat) ** 2 + (a.lon - pivot.lon) ** 2
const dB = (b.lat - pivot.lat) ** 2 + (b.lon - pivot.lon) ** 2
return dA - dB
})
const dA = (a.lat - pivot.lat) ** 2 + (a.lon - pivot.lon) ** 2;
const dB = (b.lat - pivot.lat) ** 2 + (b.lon - pivot.lon) ** 2;
return dA - dB;
});
const hull: LatLon[] = [pivot]
const hull: LatLon[] = [pivot];
for (const p of rest) {
while (hull.length >= 2) {
const a = hull[hull.length - 2]
const b = hull[hull.length - 1]
const cross = (b.lon - a.lon) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lon - a.lon)
if (cross <= 0) hull.pop()
else break
const a = hull[hull.length - 2];
const b = hull[hull.length - 1];
const cross = (b.lon - a.lon) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lon - a.lon);
if (cross <= 0) hull.pop();
else break;
}
hull.push(p)
hull.push(p);
}
return hull
return hull;
}
/** Shoelace 공식으로 다각형 면적 계산 (km²) — 위경도 좌표를 미터 변환 후 계산 */
export function polygonAreaKm2(polygon: LatLon[]): number {
if (polygon.length < 3) return 0
if (polygon.length < 3) return 0;
// 중심 기준 위경도 → 미터 변환
const centerLat = polygon.reduce((s, p) => s + p.lat, 0) / polygon.length
const mPerDegLat = 111320
const mPerDegLon = 111320 * Math.cos(centerLat * DEG2RAD)
const centerLat = polygon.reduce((s, p) => s + p.lat, 0) / polygon.length;
const mPerDegLat = 111320;
const mPerDegLon = 111320 * Math.cos(centerLat * DEG2RAD);
const pts = polygon.map(p => ({
const pts = polygon.map((p) => ({
x: (p.lon - polygon[0].lon) * mPerDegLon,
y: (p.lat - polygon[0].lat) * mPerDegLat,
}))
}));
// Shoelace
let area = 0
let area = 0;
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length
area += pts[i].x * pts[j].y
area -= pts[j].x * pts[i].y
const j = (i + 1) % pts.length;
area += pts[i].x * pts[j].y;
area -= pts[j].x * pts[i].y;
}
return Math.abs(area) / 2 / 1_000_000 // m² → km²
return Math.abs(area) / 2 / 1_000_000; // m² → km²
}
/** 오일 궤적 입자 → Convex Hull 외곽 다각형 + 면적 + 둘레 계산 */
export function analyzeSpillPolygon(trajectory: LatLon[]): {
hull: LatLon[]
areaKm2: number
perimeterKm: number
particleCount: number
hull: LatLon[];
areaKm2: number;
perimeterKm: number;
particleCount: number;
} {
if (trajectory.length < 3) {
return { hull: [], areaKm2: 0, perimeterKm: 0, particleCount: trajectory.length }
return { hull: [], areaKm2: 0, perimeterKm: 0, particleCount: trajectory.length };
}
const hull = convexHull(trajectory)
const areaKm2 = polygonAreaKm2(hull)
const hull = convexHull(trajectory);
const areaKm2 = polygonAreaKm2(hull);
// 둘레 계산
let perimeter = 0
let perimeter = 0;
for (let i = 0; i < hull.length; i++) {
const j = (i + 1) % hull.length
const j = (i + 1) % hull.length;
perimeter += haversineDistance(
{ lat: hull[i].lat, lon: hull[i].lon },
{ lat: hull[j].lat, lon: hull[j].lon },
)
);
}
return {
@ -96,144 +104,167 @@ export function analyzeSpillPolygon(trajectory: LatLon[]): {
areaKm2,
perimeterKm: perimeter / 1000,
particleCount: trajectory.length,
}
};
}
/** 두 좌표 간 Haversine 거리 (m) */
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
const dLat = (p2.lat - p1.lat) * DEG2RAD
const dLon = (p2.lon - p1.lon) * DEG2RAD
const dLat = (p2.lat - p1.lat) * DEG2RAD;
const dLon = (p2.lon - p1.lon) * DEG2RAD;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1.lat * DEG2RAD) * Math.cos(p2.lat * DEG2RAD) * Math.sin(dLon / 2) ** 2
return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
Math.cos(p1.lat * DEG2RAD) * Math.cos(p2.lat * DEG2RAD) * Math.sin(dLon / 2) ** 2;
return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** 두 좌표 간 방위각 (degrees, 0=북, 시계방향) */
export function computeBearing(from: BoomLineCoord, to: BoomLineCoord): number {
const dLon = (to.lon - from.lon) * DEG2RAD
const fromLat = from.lat * DEG2RAD
const toLat = to.lat * DEG2RAD
const y = Math.sin(dLon) * Math.cos(toLat)
const x = Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(dLon)
return ((Math.atan2(y, x) * RAD2DEG) + 360) % 360
const dLon = (to.lon - from.lon) * DEG2RAD;
const fromLat = from.lat * DEG2RAD;
const toLat = to.lat * DEG2RAD;
const y = Math.sin(dLon) * Math.cos(toLat);
const x =
Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(dLon);
return (Math.atan2(y, x) * RAD2DEG + 360) % 360;
}
/** 기준점에서 방위각+거리로 목적점 계산 */
export function destinationPoint(origin: BoomLineCoord, bearingDeg: number, distanceM: number): BoomLineCoord {
const brng = bearingDeg * DEG2RAD
const lat1 = origin.lat * DEG2RAD
const lon1 = origin.lon * DEG2RAD
const d = distanceM / EARTH_RADIUS
export function destinationPoint(
origin: BoomLineCoord,
bearingDeg: number,
distanceM: number,
): BoomLineCoord {
const brng = bearingDeg * DEG2RAD;
const lat1 = origin.lat * DEG2RAD;
const lon1 = origin.lon * DEG2RAD;
const d = distanceM / EARTH_RADIUS;
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng))
const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2))
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng),
);
const lon2 =
lon1 +
Math.atan2(
Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
);
return { lat: lat2 * RAD2DEG, lon: lon2 * RAD2DEG }
return { lat: lat2 * RAD2DEG, lon: lon2 * RAD2DEG };
}
/** 폴리라인 총 길이 (m) */
export function computePolylineLength(coords: BoomLineCoord[]): number {
let total = 0
let total = 0;
for (let i = 1; i < coords.length; i++) {
total += haversineDistance(coords[i - 1], coords[i])
total += haversineDistance(coords[i - 1], coords[i]);
}
return total
return total;
}
/** 두 선분 교차 판정 (2D) */
export function segmentsIntersect(
a1: BoomLineCoord, a2: BoomLineCoord,
b1: BoomLineCoord, b2: BoomLineCoord
a1: BoomLineCoord,
a2: BoomLineCoord,
b1: BoomLineCoord,
b2: BoomLineCoord,
): boolean {
const cross = (o: BoomLineCoord, a: BoomLineCoord, b: BoomLineCoord) =>
(a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon)
(a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon);
const d1 = cross(b1, b2, a1)
const d2 = cross(b1, b2, a2)
const d3 = cross(a1, a2, b1)
const d4 = cross(a1, a2, b2)
const d1 = cross(b1, b2, a1);
const d2 = cross(b1, b2, a2);
const d3 = cross(a1, a2, b1);
const d4 = cross(a1, a2, b2);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true;
}
// Collinear cases
const onSegment = (p: BoomLineCoord, q: BoomLineCoord, r: BoomLineCoord) =>
Math.min(p.lon, r.lon) <= q.lon && q.lon <= Math.max(p.lon, r.lon) &&
Math.min(p.lat, r.lat) <= q.lat && q.lat <= Math.max(p.lat, r.lat)
Math.min(p.lon, r.lon) <= q.lon &&
q.lon <= Math.max(p.lon, r.lon) &&
Math.min(p.lat, r.lat) <= q.lat &&
q.lat <= Math.max(p.lat, r.lat);
if (d1 === 0 && onSegment(b1, a1, b2)) return true
if (d2 === 0 && onSegment(b1, a2, b2)) return true
if (d3 === 0 && onSegment(a1, b1, a2)) return true
if (d4 === 0 && onSegment(a1, b2, a2)) return true
if (d1 === 0 && onSegment(b1, a1, b2)) return true;
if (d2 === 0 && onSegment(b1, a2, b2)) return true;
if (d3 === 0 && onSegment(a1, b1, a2)) return true;
if (d4 === 0 && onSegment(a1, b2, a2)) return true;
return false
return false;
}
/** AI 자동 배치 생성 */
export function generateAIBoomLines(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
incident: BoomLineCoord,
settings: AlgorithmSettings
settings: AlgorithmSettings,
): BoomLine[] {
if (trajectory.length === 0) return []
if (trajectory.length === 0) return [];
// 1. 최종 시간 입자들의 중심점 → 주요 확산 방향
const maxTime = Math.max(...trajectory.map(p => p.time))
const finalPoints = trajectory.filter(p => p.time === maxTime)
if (finalPoints.length === 0) return []
const maxTime = Math.max(...trajectory.map((p) => p.time));
const finalPoints = trajectory.filter((p) => p.time === maxTime);
if (finalPoints.length === 0) return [];
const centroid: BoomLineCoord = {
lat: finalPoints.reduce((s, p) => s + p.lat, 0) / finalPoints.length,
lon: finalPoints.reduce((s, p) => s + p.lon, 0) / finalPoints.length,
}
};
const mainBearing = computeBearing(incident, centroid)
const totalDist = haversineDistance(incident, centroid)
const mainBearing = computeBearing(incident, centroid);
const totalDist = haversineDistance(incident, centroid);
// 입자 분산 폭 계산 (최종 시간 기준)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perpBearing = (mainBearing + 90) % 360
let maxSpread = 0
const perpBearing = (mainBearing + 90) % 360;
let maxSpread = 0;
for (const p of finalPoints) {
const bearing = computeBearing(incident, p)
const dist = haversineDistance(incident, p)
const perpDist = dist * Math.abs(Math.sin((bearing - mainBearing) * DEG2RAD))
if (perpDist > maxSpread) maxSpread = perpDist
const bearing = computeBearing(incident, p);
const dist = haversineDistance(incident, p);
const perpDist = dist * Math.abs(Math.sin((bearing - mainBearing) * DEG2RAD));
if (perpDist > maxSpread) maxSpread = perpDist;
}
const correction = settings.currentOrthogonalCorrection
const waveFactor = settings.waveHeightCorrectionFactor
const correction = settings.currentOrthogonalCorrection;
const waveFactor = settings.waveHeightCorrectionFactor;
// 2. 1차 방어선 (긴급) - 30% 지점, 해류 직교
const dist1 = totalDist * 0.3
const center1 = destinationPoint(incident, mainBearing, dist1)
const halfLen1 = Math.max(600, maxSpread * 0.4)
const line1Left = destinationPoint(center1, (mainBearing + 90 + correction) % 360, halfLen1)
const line1Right = destinationPoint(center1, (mainBearing - 90 + correction + 360) % 360, halfLen1)
const coords1 = [line1Left, line1Right]
const eff1 = Math.min(95, Math.max(60, 92 / waveFactor))
const dist1 = totalDist * 0.3;
const center1 = destinationPoint(incident, mainBearing, dist1);
const halfLen1 = Math.max(600, maxSpread * 0.4);
const line1Left = destinationPoint(center1, (mainBearing + 90 + correction) % 360, halfLen1);
const line1Right = destinationPoint(
center1,
(mainBearing - 90 + correction + 360) % 360,
halfLen1,
);
const coords1 = [line1Left, line1Right];
const eff1 = Math.min(95, Math.max(60, 92 / waveFactor));
// 3. 2차 방어선 (중요) - 50% 지점, U형 3포인트
const dist2 = totalDist * 0.5
const center2 = destinationPoint(incident, mainBearing, dist2)
const halfLen2 = Math.max(450, maxSpread * 0.5)
const line2Left = destinationPoint(center2, (mainBearing + 90 + correction) % 360, halfLen2)
const line2Right = destinationPoint(center2, (mainBearing - 90 + correction + 360) % 360, halfLen2)
const line2Front = destinationPoint(center2, mainBearing, halfLen2 * 0.5)
const coords2 = [line2Left, line2Front, line2Right]
const eff2 = Math.min(90, Math.max(55, 85 / waveFactor))
const dist2 = totalDist * 0.5;
const center2 = destinationPoint(incident, mainBearing, dist2);
const halfLen2 = Math.max(450, maxSpread * 0.5);
const line2Left = destinationPoint(center2, (mainBearing + 90 + correction) % 360, halfLen2);
const line2Right = destinationPoint(
center2,
(mainBearing - 90 + correction + 360) % 360,
halfLen2,
);
const line2Front = destinationPoint(center2, mainBearing, halfLen2 * 0.5);
const coords2 = [line2Left, line2Front, line2Right];
const eff2 = Math.min(90, Math.max(55, 85 / waveFactor));
// 4. 3차 방어선 (보통) - 80% 지점, 해안선 평행
const dist3 = totalDist * 0.8
const center3 = destinationPoint(incident, mainBearing, dist3)
const halfLen3 = Math.max(350, maxSpread * 0.6)
const line3Left = destinationPoint(center3, (mainBearing + 90) % 360, halfLen3)
const line3Right = destinationPoint(center3, (mainBearing - 90 + 360) % 360, halfLen3)
const coords3 = [line3Left, line3Right]
const eff3 = Math.min(85, Math.max(50, 78 / waveFactor))
const dist3 = totalDist * 0.8;
const center3 = destinationPoint(incident, mainBearing, dist3);
const halfLen3 = Math.max(350, maxSpread * 0.6);
const line3Left = destinationPoint(center3, (mainBearing + 90) % 360, halfLen3);
const line3Right = destinationPoint(center3, (mainBearing - 90 + 360) % 360, halfLen3);
const coords3 = [line3Left, line3Right];
const eff3 = Math.min(85, Math.max(50, 78 / waveFactor));
const boomLines: BoomLine[] = [
{
@ -269,40 +300,41 @@ export function generateAIBoomLines(
efficiency: Math.round(eff3),
status: 'PLANNED',
},
]
];
// 최소 효율 필터 경고 (라인은 유지하되 표시용)
for (const line of boomLines) {
if (line.efficiency < settings.minContainmentEfficiency) {
line.name += ' ⚠'
line.name += ' ⚠';
}
}
return boomLines
return boomLines;
}
/** Ray casting — 점이 다각형 내부인지 판정 */
export function pointInPolygon(
point: { lat: number; lon: number },
polygon: { lat: number; lon: number }[]
polygon: { lat: number; lon: number }[],
): boolean {
if (polygon.length < 3) return false
let inside = false
const x = point.lon
const y = point.lat
if (polygon.length < 3) return false;
let inside = false;
const x = point.lon;
const y = point.lat;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lon, yi = polygon[i].lat
const xj = polygon[j].lon, yj = polygon[j].lat
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
if (intersect) inside = !inside
const xi = polygon[i].lon,
yi = polygon[i].lat;
const xj = polygon[j].lon,
yj = polygon[j].lat;
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside
return inside;
}
/** 원 면적 (km²) */
export function circleAreaKm2(radiusM: number): number {
return Math.PI * (radiusM / 1000) ** 2
return Math.PI * (radiusM / 1000) ** 2;
}
/** 거리 포맷 (m → "234 m" or "1.23 km") */
@ -321,7 +353,7 @@ export function formatArea(km2: number): string {
/** 차단 시뮬레이션 실행 */
export function runContainmentAnalysis(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
boomLines: BoomLine[]
boomLines: BoomLine[],
): ContainmentResult {
if (trajectory.length === 0 || boomLines.length === 0) {
return {
@ -330,64 +362,64 @@ export function runContainmentAnalysis(
passedParticles: 0,
overallEfficiency: 0,
perLineResults: [],
}
};
}
// 입자별 그룹핑
const particleMap = new Map<number, Array<{ lat: number; lon: number; time: number }>>()
const particleMap = new Map<number, Array<{ lat: number; lon: number; time: number }>>();
for (const pt of trajectory) {
const pid = pt.particle ?? 0
if (!particleMap.has(pid)) particleMap.set(pid, [])
particleMap.get(pid)!.push(pt)
const pid = pt.particle ?? 0;
if (!particleMap.has(pid)) particleMap.set(pid, []);
particleMap.get(pid)!.push(pt);
}
// 입자별 시간 정렬
for (const points of particleMap.values()) {
points.sort((a, b) => a.time - b.time)
points.sort((a, b) => a.time - b.time);
}
const perLineBlocked = new Map<string, Set<number>>()
const perLineBlocked = new Map<string, Set<number>>();
for (const line of boomLines) {
perLineBlocked.set(line.id, new Set())
perLineBlocked.set(line.id, new Set());
}
const blockedParticleIds = new Set<number>()
const blockedParticleIds = new Set<number>();
// 각 입자의 이동 경로와 오일펜스 라인 교차 판정
for (const [pid, points] of particleMap) {
for (let i = 0; i < points.length - 1; i++) {
const segA1: BoomLineCoord = { lat: points[i].lat, lon: points[i].lon }
const segA2: BoomLineCoord = { lat: points[i + 1].lat, lon: points[i + 1].lon }
const segA1: BoomLineCoord = { lat: points[i].lat, lon: points[i].lon };
const segA2: BoomLineCoord = { lat: points[i + 1].lat, lon: points[i + 1].lon };
for (const line of boomLines) {
for (let j = 0; j < line.coords.length - 1; j++) {
if (segmentsIntersect(segA1, segA2, line.coords[j], line.coords[j + 1])) {
blockedParticleIds.add(pid)
perLineBlocked.get(line.id)!.add(pid)
blockedParticleIds.add(pid);
perLineBlocked.get(line.id)!.add(pid);
}
}
}
}
}
const totalParticles = particleMap.size
const blocked = blockedParticleIds.size
const passed = totalParticles - blocked
const totalParticles = particleMap.size;
const blocked = blockedParticleIds.size;
const passed = totalParticles - blocked;
return {
totalParticles,
blockedParticles: blocked,
passedParticles: passed,
overallEfficiency: totalParticles > 0 ? Math.round((blocked / totalParticles) * 100) : 0,
perLineResults: boomLines.map(line => {
const lineBlocked = perLineBlocked.get(line.id)!.size
perLineResults: boomLines.map((line) => {
const lineBlocked = perLineBlocked.get(line.id)!.size;
return {
boomLineId: line.id,
boomLineName: line.name,
blocked: lineBlocked,
passed: totalParticles - lineBlocked,
efficiency: totalParticles > 0 ? Math.round((lineBlocked / totalParticles) * 100) : 0,
}
};
}),
}
};
}

파일 보기

@ -19,8 +19,8 @@ export function escapeHtml(str: string): string {
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
}
return str.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char] || char)
};
return str.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char] || char);
}
/**
@ -28,7 +28,7 @@ export function escapeHtml(str: string): string {
* dangerouslySetInnerHTML
*/
export function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, '')
return html.replace(/<[^>]*>/g, '');
}
/**
@ -37,33 +37,63 @@ export function stripHtmlTags(html: string): string {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ALLOWED_TAGS = new Set([
'b', 'i', 'u', 'strong', 'em', 'br', 'p', 'span',
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'a', 'img', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'sup', 'sub', 'hr',
'blockquote', 'pre', 'code',
])
'b',
'i',
'u',
'strong',
'em',
'br',
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'sup',
'sub',
'hr',
'blockquote',
'pre',
'code',
]);
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;
export function sanitizeHtml(html: string): string {
// 1. script 태그 완전 제거
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// 2. 위험한 이벤트 핸들러 속성 제거 (onclick, onerror 등)
sanitized = sanitized.replace(DANGEROUS_ATTRS, '')
sanitized = sanitized.replace(DANGEROUS_ATTRS, '');
// 3. data: URI 스킴 제거 (base64 인코딩된 스크립트 방지)
sanitized = sanitized.replace(/\bdata\s*:\s*text\/html/gi, '')
sanitized = sanitized.replace(/\bdata\s*:\s*text\/html/gi, '');
// 4. style 속성에서 expression() 제거 (IE CSS XSS)
sanitized = sanitized.replace(/style\s*=\s*["'][^"']*expression\s*\([^)]*\)[^"']*["']/gi, '')
sanitized = sanitized.replace(/style\s*=\s*["'][^"']*expression\s*\([^)]*\)[^"']*["']/gi, '');
// 5. iframe, object, embed 태그 제거
sanitized = sanitized.replace(/<\s*(iframe|object|embed|form|input|textarea|button)\b[^>]*>/gi, '')
sanitized = sanitized.replace(/<\/\s*(iframe|object|embed|form|input|textarea|button)\s*>/gi, '')
sanitized = sanitized.replace(
/<\s*(iframe|object|embed|form|input|textarea|button)\b[^>]*>/gi,
'',
);
sanitized = sanitized.replace(/<\/\s*(iframe|object|embed|form|input|textarea|button)\s*>/gi, '');
return sanitized
return sanitized;
}
/**
@ -71,18 +101,18 @@ export function sanitizeHtml(html: string): string {
* ,
*/
export function sanitizeInput(input: string, maxLength = 1000): string {
if (typeof input !== 'string') return ''
if (typeof input !== 'string') return '';
return input
.trim()
.slice(0, maxLength)
.replace(/[<>"'`;]/g, '') // 위험 특수문자 제거
.replace(/[<>"'`;]/g, ''); // 위험 특수문자 제거
}
/**
* URL
*/
export function sanitizeUrlParam(param: string): string {
return encodeURIComponent(param)
return encodeURIComponent(param);
}
/**
@ -91,11 +121,11 @@ export function sanitizeUrlParam(param: string): string {
*/
export function safeJsonParse<T>(json: string, defaultValue: T): T {
try {
const parsed = JSON.parse(json)
if (parsed === null || parsed === undefined) return defaultValue
return parsed as T
const parsed = JSON.parse(json);
if (parsed === null || parsed === undefined) return defaultValue;
return parsed as T;
} catch {
return defaultValue
return defaultValue;
}
}
@ -104,11 +134,11 @@ export function safeJsonParse<T>(json: string, defaultValue: T): T {
*/
export function safeGetLocalStorage<T>(key: string, defaultValue: T): T {
try {
const raw = localStorage.getItem(key)
if (raw === null) return defaultValue
return safeJsonParse(raw, defaultValue)
const raw = localStorage.getItem(key);
if (raw === null) return defaultValue;
return safeJsonParse(raw, defaultValue);
} catch {
return defaultValue
return defaultValue;
}
}
@ -117,16 +147,16 @@ export function safeGetLocalStorage<T>(key: string, defaultValue: T): T {
*/
export function safeSetLocalStorage(key: string, value: unknown, maxSizeKB = 5120): boolean {
try {
const json = JSON.stringify(value)
const json = JSON.stringify(value);
// 크기 제한 검사 (기본 5MB)
if (json.length > maxSizeKB * 1024) {
console.warn(`localStorage 저장 크기 초과: ${key}`)
return false
console.warn(`localStorage 저장 크기 초과: ${key}`);
return false;
}
localStorage.setItem(key, json)
return true
localStorage.setItem(key, json);
return true;
} catch {
return false
return false;
}
}
@ -136,7 +166,7 @@ export function safeSetLocalStorage(key: string, value: unknown, maxSizeKB = 512
*/
export function safePrintHtml(htmlContent: string, title: string, styles: string = ''): void {
// HTML 콘텐츠에서 위험 요소 살균
const sanitizedContent = sanitizeHtml(htmlContent)
const sanitizedContent = sanitizeHtml(htmlContent);
const fullHtml = `<!DOCTYPE html>
<html><head>
@ -144,20 +174,20 @@ export function safePrintHtml(htmlContent: string, title: string, styles: string
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;">
<title>${escapeHtml(title)}</title>
${styles}
</head><body>${sanitizedContent}</body></html>`
</head><body>${sanitizedContent}</body></html>`;
// Blob URL 사용 (document.write 대체)
const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' })
const url = URL.createObjectURL(blob)
const win = window.open(url, '_blank')
const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
// Blob URL 정리 (메모리 누수 방지)
if (win) {
win.addEventListener('afterprint', () => URL.revokeObjectURL(url))
win.addEventListener('afterprint', () => URL.revokeObjectURL(url));
// 30초 후 자동 정리
setTimeout(() => URL.revokeObjectURL(url), 30000)
setTimeout(() => URL.revokeObjectURL(url), 30000);
} else {
URL.revokeObjectURL(url)
URL.revokeObjectURL(url);
}
}
@ -166,5 +196,5 @@ ${styles}
* dangerouslySetInnerHTML React
*/
export function splitByNewline(text: string): string[] {
return escapeHtml(text).split(/\n/)
return escapeHtml(text).split(/\n/);
}

파일 보기

@ -83559,4 +83559,4 @@
],
"portFrequency": []
}
]
]

파일 보기

@ -2,69 +2,69 @@
/* 화물적부도 277종 / 해양시설 56종 / 용선자 화물코드 983종 = 총 1,316종 기반 */
export interface HNSSearchSubstance {
id: number
abbreviation: string // 약자/제품명 (화물적부도 코드)
nameKr: string // 국문명
nameEn: string // 영문명
synonymsEn: string // 영문 동의어
synonymsKr: string // 국문 동의어/용도
unNumber: string // UN번호
casNumber: string // CAS번호
transportMethod: string // 운송방법
sebc: string // SEBC 거동분류
id: number;
abbreviation: string; // 약자/제품명 (화물적부도 코드)
nameKr: string; // 국문명
nameEn: string; // 영문명
synonymsEn: string; // 영문 동의어
synonymsKr: string; // 국문 동의어/용도
unNumber: string; // UN번호
casNumber: string; // CAS번호
transportMethod: string; // 운송방법
sebc: string; // SEBC 거동분류
/* 물리·화학적 특성 */
usage: string
state: string
color: string
odor: string
flashPoint: string
autoIgnition: string
boilingPoint: string
density: string // 비중 (물=1)
solubility: string
vaporPressure: string
vaporDensity: string // 증기밀도 (공기=1)
explosionRange: string // 폭발범위
usage: string;
state: string;
color: string;
odor: string;
flashPoint: string;
autoIgnition: string;
boilingPoint: string;
density: string; // 비중 (물=1)
solubility: string;
vaporPressure: string;
vaporDensity: string; // 증기밀도 (공기=1)
explosionRange: string; // 폭발범위
/* 위험등급·농도기준 */
nfpa: { health: number; fire: number; reactivity: number; special: string }
hazardClass: string
ergNumber: string
idlh: string
aegl2: string
erpg2: string
nfpa: { health: number; fire: number; reactivity: number; special: string };
hazardClass: string;
ergNumber: string;
idlh: string;
aegl2: string;
erpg2: string;
/* 방제거리 */
responseDistanceFire: string
responseDistanceSpillDay: string
responseDistanceSpillNight: string
marineResponse: string
responseDistanceFire: string;
responseDistanceSpillDay: string;
responseDistanceSpillNight: string;
marineResponse: string;
/* PPE */
ppeClose: string
ppeFar: string
ppeClose: string;
ppeFar: string;
/* MSDS 요약 */
msds: {
hazard: string
firstAid: string
fireFighting: string
spillResponse: string
exposure: string
regulation: string
}
hazard: string;
firstAid: string;
fireFighting: string;
spillResponse: string;
exposure: string;
regulation: string;
};
/* IBC CODE */
ibcHazard: string
ibcShipType: string
ibcTankType: string
ibcDetection: string
ibcFireFighting: string
ibcMinRequirement: string
ibcHazard: string;
ibcShipType: string;
ibcTankType: string;
ibcDetection: string;
ibcFireFighting: string;
ibcMinRequirement: string;
/* EmS */
emsCode: string
emsFire: string
emsSpill: string
emsFirstAid: string
emsCode: string;
emsFire: string;
emsSpill: string;
emsFirstAid: string;
/* 화물적부도 코드 */
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
/* 항구별 반입 */
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
}
export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
@ -109,7 +109,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무, CO₂, 건조분말',
spillResponse: '점화원 제거, 환기, 가스검지기 작동',
exposure: 'TWA 2 ppm, STEL 없음',
regulation: '고압가스안전관리법, 산업안전보건법'
regulation: '고압가스안전관리법, 산업안전보건법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -118,7 +118,8 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
ibcFireFighting: '건조분말, CO₂',
ibcMinRequirement: 'IMO 2TYPE 이상, 냉각설비',
emsCode: 'F-D, S-U',
emsFire: '가연성 극히 높음. 물분무로 냉각, 소규모 건조분말/CO₂. 대규모 화재 시 안전거리 확보 후 냉각 주수.',
emsFire:
'가연성 극히 높음. 물분무로 냉각, 소규모 건조분말/CO₂. 대규모 화재 시 안전거리 확보 후 냉각 주수.',
emsSpill: '기체 유출 시 환기 확보. 점화원 제거. 증기운 형성 시 대피.',
emsFirstAid: '흡입: 신선한 공기 이동. 동상(액화가스): 미온수로 해동. 의료조치.',
cargoCodes: [
@ -173,7 +174,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '알코올 내성 포말, CO₂, 건조분말',
spillResponse: '오일펜스 설치, 흡착제 회수',
exposure: 'TWA 20 ppm, STEL 40 ppm',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -237,7 +238,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무, 내알코올 포말',
spillResponse: '흡수제(모래, 흙)로 회수, 배수구 차단',
exposure: 'TWA 없음, STEL 없음',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '오염상의 위험성(P)',
ibcShipType: 'IMO 3TYPE',
@ -252,7 +253,12 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
cargoCodes: [
{ code: '14BD', name: '1,4-BUTANEDIOL', company: '국제공통', source: '적부도' },
{ code: 'BDO', name: 'BUTANEDIOL', company: 'BASF', source: '용선자' },
{ code: 'BUTYLENE GLYCOL', name: '1,4-BUTYLENE GLYCOL', company: '일본선사', source: '제품명' },
{
code: 'BUTYLENE GLYCOL',
name: '1,4-BUTYLENE GLYCOL',
company: '일본선사',
source: '제품명',
},
],
portFrequency: [
{ port: '울산항', portCode: 'KRULS', lastImport: '2025-03-28', frequency: '높음' },
@ -301,7 +307,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '포말, CO₂, 건조분말',
spillResponse: '오일펜스 설치, 유흡착제 회수',
exposure: 'TWA 5 mg/m³ (오일미스트)',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '오염상의 위험성(P)',
ibcShipType: 'IMO 3TYPE',
@ -364,7 +370,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '알코올 내성 포말, CO₂, 건조분말',
spillResponse: '오일펜스 설치, 흡착제 회수, 증기 억제 포말',
exposure: 'TWA 100 ppm, STEL 150 ppm',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -428,7 +434,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '흡수제 회수, 해수 희석 방지',
exposure: 'TWA 200 ppm, STEL 250 ppm',
regulation: '위험물안전관리법 제4류, 산업안전보건법'
regulation: '위험물안전관리법 제4류, 산업안전보건법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -492,7 +498,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '오일펜스 설치, 흡착제 회수',
exposure: 'TWA 50 ppm, STEL 150 ppm',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -555,7 +561,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무(냉각), 건조분말',
spillResponse: '물분무로 가스운 제어, 환기 확보',
exposure: 'TWA 25 ppm, STEL 35 ppm',
regulation: '고압가스안전관리법, 산업안전보건법, 화학물질관리법'
regulation: '고압가스안전관리법, 산업안전보건법, 화학물질관리법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F), 독성(T)',
ibcShipType: 'IMO 1TYPE',
@ -618,7 +624,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '오일펜스 설치, 흡착제 회수, 환기',
exposure: 'TWA 1 ppm, STEL 5 ppm',
regulation: '위험물안전관리법 제4류, 산업안전보건법(발암물질)'
regulation: '위험물안전관리법 제4류, 산업안전보건법(발암물질)',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -680,7 +686,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무(냉각), 주변 소화',
spillResponse: '풍하측 대피, 물분무로 가스운 억제',
exposure: 'TWA 0.5 ppm, STEL 1 ppm',
regulation: '고압가스안전관리법, 화학물질관리법(사고대비물질)'
regulation: '고압가스안전관리법, 화학물질관리법(사고대비물질)',
},
ibcHazard: '안전상의 위험성(S), 독성(T), 부식성(C)',
ibcShipType: 'IMO 1TYPE',
@ -692,9 +698,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
emsFire: '비가연성이나 산화성. 주변 화재 시 탱크 냉각 필수. 독성가스 확산 주의.',
emsSpill: '공기보다 무거움 → 저지대 체류. 물분무로 가스운 억제. 풍하측 대피.',
emsFirstAid: '흡입: 즉시 대피, 산소 공급. IDLH 10ppm 극히 낮음 — 미량에도 위험.',
cargoCodes: [
{ code: 'CL2', name: 'CHLORINE', company: '국제공통', source: '적부도' },
],
cargoCodes: [{ code: 'CL2', name: 'CHLORINE', company: '국제공통', source: '적부도' }],
portFrequency: [
{ port: '울산항', portCode: 'KRULS', lastImport: '2025-03-15', frequency: '낮음' },
{ port: '여수항', portCode: 'KRYOS', lastImport: '2025-02-28', frequency: '낮음' },
@ -741,7 +745,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '건조분말, CO₂, 물분무(냉각)',
spillResponse: '점화원 제거, 환기, 가스검지기',
exposure: 'TWA 없음 (단순 질식제)',
regulation: '고압가스안전관리법'
regulation: '고압가스안전관리법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 1TYPE (특수)',
@ -803,7 +807,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '건조분말, CO₂, 물분무(냉각)',
spillResponse: '점화원 제거, 환기, 가스검지기',
exposure: 'TWA 1,000 ppm',
regulation: '고압가스안전관리법, 액화석유가스법'
regulation: '고압가스안전관리법, 액화석유가스법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -866,7 +870,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '흡수제 회수, 배수구 차단, 수중 모니터링',
exposure: 'TWA 10 ppm, STEL 없음',
regulation: '위험물안전관리법 제4류, 산업안전보건법(발암물질)'
regulation: '위험물안전관리법 제4류, 산업안전보건법(발암물질)',
},
ibcHazard: '안전상의 위험성(S), 인화성(F), 독성(T)',
ibcShipType: 'IMO 2TYPE',
@ -927,7 +931,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '흡수제 회수, 환기, 점화원 제거',
exposure: 'TWA 500 ppm, STEL 750 ppm',
regulation: '위험물안전관리법 제4류'
regulation: '위험물안전관리법 제4류',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -989,7 +993,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무, 내알코올 포말, CO₂',
spillResponse: '흡수제 회수, 저층 오염 모니터링',
exposure: 'TWA 5 ppm, STEL 없음',
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질)'
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질)',
},
ibcHazard: '안전상의 위험성(S), 독성(T), 부식성(C)',
ibcShipType: 'IMO 2TYPE',
@ -1051,7 +1055,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '건조분말, CO₂',
spillResponse: '점화원 제거, 환기',
exposure: 'TWA 없음',
regulation: '고압가스안전관리법'
regulation: '고압가스안전관리법',
},
ibcHazard: '안전상의 위험성(S), 인화성(F)',
ibcShipType: 'IMO 2TYPE',
@ -1113,7 +1117,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '물분무(희석 주의), 건조분말',
spillResponse: '중화제(석회, 소다회), 배수구 차단',
exposure: 'TWA 0.2 mg/m³',
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질)'
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질)',
},
ibcHazard: '부식성(C), 반응성(R)',
ibcShipType: 'IMO 2TYPE',
@ -1176,7 +1180,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '비가연, 주변 소화',
spillResponse: '중화제(산성용액), 배수구 차단',
exposure: 'TWA 2 mg/m³',
regulation: '위험물안전관리법, 화학물질관리법'
regulation: '위험물안전관리법, 화학물질관리법',
},
ibcHazard: '부식성(C)',
ibcShipType: 'IMO 3TYPE',
@ -1239,7 +1243,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '내알코올 포말, CO₂, 건조분말',
spillResponse: '흡수제 회수, 환기, 독성가스 경보',
exposure: 'TWA 2 ppm, STEL 없음',
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질), 산업안전보건법(발암물질)'
regulation: '위험물안전관리법, 화학물질관리법(사고대비물질), 산업안전보건법(발암물질)',
},
ibcHazard: '안전상의 위험성(S), 인화성(F), 독성(T)',
ibcShipType: 'IMO 2TYPE',
@ -1301,7 +1305,7 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
fireFighting: '건조분말, CO₂, 물분무(냉각)',
spillResponse: '환기, 점화원 제거, 가스검지기',
exposure: 'TWA 1 ppm',
regulation: '고압가스안전관리법, 산업안전보건법(발암물질)'
regulation: '고압가스안전관리법, 산업안전보건법(발암물질)',
},
ibcHazard: '안전상의 위험성(S), 인화성(F), 독성(T)',
ibcShipType: 'IMO 2TYPE',
@ -1322,4 +1326,4 @@ export const HNS_SEARCH_DB: HNSSearchSubstance[] = [
{ port: '울산항', portCode: 'KRULS', lastImport: '2025-03-28', frequency: '중간' },
],
},
]
];

파일 보기

@ -1,9 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App.tsx';
// React Query 클라이언트 생성
const queryClient = new QueryClient({
@ -14,7 +14,7 @@ const queryClient = new QueryClient({
staleTime: 1000 * 60 * 5, // 5분
},
},
})
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
@ -24,4 +24,4 @@ createRoot(document.getElementById('root')!).render(
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
)
);

파일 보기

@ -41,34 +41,34 @@ const VARIANTS = ['Accent', 'Primary', 'Secondary', 'Tertiary', 'Tertiary (fille
const getDarkStateRows = (): ButtonStateRow[] => [
{
state: 'Default',
accent: { bg: '#ef4444', text: '#fff' },
primary: { bg: '#1a1a2e', text: '#fff' },
secondary: { bg: '#6b7280', text: '#fff' },
tertiary: { bg: 'transparent', text: '#c2c6d6', border: '#6b7280' },
accent: { bg: '#ef4444', text: '#fff' },
primary: { bg: '#1a1a2e', text: '#fff' },
secondary: { bg: '#6b7280', text: '#fff' },
tertiary: { bg: 'transparent', text: '#c2c6d6', border: '#6b7280' },
tertiaryFilled: { bg: '#374151', text: '#c2c6d6' },
},
{
state: 'Hover',
accent: { bg: '#dc2626', text: '#fff' },
primary: { bg: '#2d2d44', text: '#fff' },
secondary: { bg: '#7c8393', text: '#fff' },
tertiary: { bg: 'rgba(255,255,255,0.05)', text: '#c2c6d6', border: '#9ca3af' },
accent: { bg: '#dc2626', text: '#fff' },
primary: { bg: '#2d2d44', text: '#fff' },
secondary: { bg: '#7c8393', text: '#fff' },
tertiary: { bg: 'rgba(255,255,255,0.05)', text: '#c2c6d6', border: '#9ca3af' },
tertiaryFilled: { bg: '#4b5563', text: '#c2c6d6' },
},
{
state: 'Pressed',
accent: { bg: '#b91c1c', text: '#fff' },
primary: { bg: '#3d3d5c', text: '#fff' },
secondary: { bg: '#9ca3af', text: '#fff' },
tertiary: { bg: 'rgba(255,255,255,0.1)', text: '#c2c6d6', border: '#9ca3af' },
accent: { bg: '#b91c1c', text: '#fff' },
primary: { bg: '#3d3d5c', text: '#fff' },
secondary: { bg: '#9ca3af', text: '#fff' },
tertiary: { bg: 'rgba(255,255,255,0.1)', text: '#c2c6d6', border: '#9ca3af' },
tertiaryFilled: { bg: '#6b7280', text: '#c2c6d6' },
},
{
state: 'Disabled',
accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' },
primary: { bg: 'rgba(26,26,46,0.5)', text: 'rgba(255,255,255,0.4)' },
secondary: { bg: 'rgba(107,114,128,0.3)', text: 'rgba(255,255,255,0.4)' },
tertiary: { bg: 'transparent', text: 'rgba(255,255,255,0.3)', border: 'rgba(107,114,128,0.3)' },
accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' },
primary: { bg: 'rgba(26,26,46,0.5)', text: 'rgba(255,255,255,0.4)' },
secondary: { bg: 'rgba(107,114,128,0.3)', text: 'rgba(255,255,255,0.4)' },
tertiary: { bg: 'transparent', text: 'rgba(255,255,255,0.3)', border: 'rgba(107,114,128,0.3)' },
tertiaryFilled: { bg: 'rgba(55,65,81,0.3)', text: 'rgba(255,255,255,0.3)' },
},
];
@ -76,34 +76,34 @@ const getDarkStateRows = (): ButtonStateRow[] => [
const getLightStateRows = (): ButtonStateRow[] => [
{
state: 'Default',
accent: { bg: '#ef4444', text: '#fff' },
primary: { bg: '#1a1a2e', text: '#fff' },
secondary: { bg: '#d1d5db', text: '#374151' },
tertiary: { bg: 'transparent', text: '#374151', border: '#d1d5db' },
accent: { bg: '#ef4444', text: '#fff' },
primary: { bg: '#1a1a2e', text: '#fff' },
secondary: { bg: '#d1d5db', text: '#374151' },
tertiary: { bg: 'transparent', text: '#374151', border: '#d1d5db' },
tertiaryFilled: { bg: '#e5e7eb', text: '#374151' },
},
{
state: 'Hover',
accent: { bg: '#dc2626', text: '#fff' },
primary: { bg: '#2d2d44', text: '#fff' },
secondary: { bg: '#bcc0c7', text: '#374151' },
tertiary: { bg: 'rgba(0,0,0,0.03)', text: '#374151', border: '#9ca3af' },
accent: { bg: '#dc2626', text: '#fff' },
primary: { bg: '#2d2d44', text: '#fff' },
secondary: { bg: '#bcc0c7', text: '#374151' },
tertiary: { bg: 'rgba(0,0,0,0.03)', text: '#374151', border: '#9ca3af' },
tertiaryFilled: { bg: '#d1d5db', text: '#374151' },
},
{
state: 'Pressed',
accent: { bg: '#b91c1c', text: '#fff' },
primary: { bg: '#3d3d5c', text: '#fff' },
secondary: { bg: '#9ca3af', text: '#374151' },
tertiary: { bg: 'rgba(0,0,0,0.06)', text: '#374151', border: '#6b7280' },
accent: { bg: '#b91c1c', text: '#fff' },
primary: { bg: '#3d3d5c', text: '#fff' },
secondary: { bg: '#9ca3af', text: '#374151' },
tertiary: { bg: 'rgba(0,0,0,0.06)', text: '#374151', border: '#6b7280' },
tertiaryFilled: { bg: '#bcc0c7', text: '#374151' },
},
{
state: 'Disabled',
accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' },
primary: { bg: 'rgba(26,26,46,0.3)', text: 'rgba(255,255,255,0.5)' },
secondary: { bg: 'rgba(209,213,219,0.5)', text: 'rgba(55,65,81,0.4)' },
tertiary: { bg: 'transparent', text: 'rgba(55,65,81,0.3)', border: 'rgba(209,213,219,0.5)' },
accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' },
primary: { bg: 'rgba(26,26,46,0.3)', text: 'rgba(255,255,255,0.5)' },
secondary: { bg: 'rgba(209,213,219,0.5)', text: 'rgba(55,65,81,0.4)' },
tertiary: { bg: 'transparent', text: 'rgba(55,65,81,0.3)', border: 'rgba(209,213,219,0.5)' },
tertiaryFilled: { bg: 'rgba(229,231,235,0.5)', text: 'rgba(55,65,81,0.3)' },
},
];
@ -145,54 +145,34 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
return (
<div className="p-12" style={{ color: t.textPrimary }}>
<div style={{ maxWidth: '64rem' }}>
{/* ── 섹션 1: 헤더 ── */}
<div
className="pb-10 mb-12 border-b border-solid"
style={{ borderColor: dividerColor }}
>
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Components
</p>
<h1
className="text-4xl font-bold mb-4"
style={{ color: t.textPrimary }}
>
<h1 className="text-4xl font-bold mb-4" style={{ color: t.textPrimary }}>
Button
</h1>
<p
className="text-lg mb-1"
style={{ color: t.textSecondary }}
>
<p className="text-lg mb-1" style={{ color: t.textSecondary }}>
, .
</p>
<p
className="text-lg"
style={{ color: t.textSecondary }}
>
<p className="text-lg" style={{ color: t.textSecondary }}>
.
</p>
</div>
{/* ── 섹션 2: Anatomy ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
Anatomy
</h2>
{/* Anatomy 카드 */}
<div
className="rounded-xl p-10 mb-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-10 mb-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-row items-start gap-16 justify-center">
{/* 왼쪽: 텍스트 + 아이콘 버튼 */}
<div className="flex flex-col items-center gap-6">
<div className="relative">
@ -264,10 +244,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span
className="text-xs font-mono"
style={{ color: t.textMuted }}
>
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
+
</span>
</div>
@ -291,9 +268,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
border: `1.5px dashed ${isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.20)'}`,
}}
/>
{/* 번호 뱃지 — Container (1) */}
{/* 번호 뱃지 — Container (1) */}
<span
className="absolute flex items-center justify-center rounded-full text-white font-bold"
style={{
@ -307,7 +282,6 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
>
1
</span>
{/* 번호 뱃지 — Icon (3) */}
<span
className="absolute flex items-center justify-center rounded-full text-white font-bold"
@ -324,10 +298,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span
className="text-xs font-mono"
style={{ color: t.textMuted }}
>
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
</span>
</div>
@ -344,8 +315,8 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
<li key={item.label} style={{ color: t.textSecondary }}>
<span className="font-bold" style={{ color: t.textPrimary }}>
{item.label}
</span>
{' '} {item.desc}
</span>{' '}
{item.desc}
</li>
))}
</ol>
@ -353,25 +324,16 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{/* ── 섹션 3: Spec ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-10"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-10" style={{ color: t.textPrimary }}>
Spec
</h2>
{/* 3-1. Size */}
<div className="mb-12">
<h3
className="text-xl font-semibold mb-6"
style={{ color: t.textPrimary }}
>
<h3 className="text-xl font-semibold mb-6" style={{ color: t.textPrimary }}>
1. Size
</h3>
<div
className="rounded-xl p-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col gap-5">
{BUTTON_SIZES.map((size) => (
<div key={size.label} className="flex items-center justify-between gap-8">
@ -396,7 +358,8 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
color: buttonDarkText,
border: 'none',
cursor: 'default',
fontSize: size.heightPx <= 24 ? '11px' : size.heightPx <= 32 ? '12px' : '14px',
fontSize:
size.heightPx <= 24 ? '11px' : size.heightPx <= 32 ? '12px' : '14px',
}}
>
@ -410,24 +373,14 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{/* 3-2. Container */}
<div className="mb-12">
<h3
className="text-xl font-semibold mb-6"
style={{ color: t.textPrimary }}
>
<h3 className="text-xl font-semibold mb-6" style={{ color: t.textPrimary }}>
2. Container
</h3>
<div
className="rounded-xl p-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<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-sm font-bold" style={{ color: t.textPrimary }}>
Flexible
</span>
<div className="flex items-center gap-4">
@ -508,10 +461,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</span>
</div>
</div>
<span
className="font-mono text-xs"
style={{ color: t.textSecondary }}
>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
.
</span>
</div>
@ -519,10 +469,7 @@ 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-sm font-bold" style={{ color: t.textPrimary }}>
Fixed
</span>
<div className="flex items-center gap-4">
@ -564,10 +511,7 @@ 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-xs ml-4" style={{ color: t.textSecondary }}>
.
</span>
</div>
@ -578,16 +522,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
{/* 3-3. Label */}
<div className="mb-12">
<h3
className="text-xl font-semibold mb-6"
style={{ color: t.textPrimary }}
>
<h3 className="text-xl font-semibold mb-6" style={{ color: t.textPrimary }}>
3. Label
</h3>
<div
className="rounded-xl p-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col gap-8">
{[
{ resolution: '해상도 430', width: '100%', maxWidth: '390px', padding: 16 },
@ -595,10 +533,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-sm" style={{ color: t.textSecondary }}>
{item.resolution}
</span>
<div className="flex items-center gap-6">
@ -644,14 +579,8 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</div>
{/* ── 섹션 4: Style (변형 × 상태 매트릭스) ── */}
<div
className="pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<div className="pt-12 border-t border-solid" style={{ borderColor: dividerColor }}>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
Style
</h2>
@ -703,7 +632,11 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
return (
<td
key={vIdx}
style={{ padding: '8px 12px', verticalAlign: 'middle', textAlign: 'center' }}
style={{
padding: '8px 12px',
verticalAlign: 'middle',
textAlign: 'center',
}}
>
<button
type="button"
@ -732,7 +665,6 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
</table>
</div>
</div>
</div>
</div>
);

파일 보기

@ -40,53 +40,55 @@ interface ColorTokenGroup {
// ---------- 데이터 ----------
const TRANSPARENCY_STEPS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100];
const TRANSPARENCY_STEPS = [
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100,
];
const GRAY_STEPS: ColorStep[] = [
{ step: 0, color: '#f8fafc' },
{ step: 5, color: '#f1f5f9' },
{ step: 10, color: '#e2e8f0' },
{ step: 20, color: '#cbd5e1' },
{ step: 30, color: '#94a3b8' },
{ step: 40, color: '#64748b' },
{ step: 50, color: '#475569' },
{ step: 60, color: '#334155' },
{ step: 70, color: '#1e293b' },
{ step: 80, color: '#0f172a' },
{ step: 90, color: '#0c1322' },
{ step: 95, color: '#070d19' },
{ step: 0, color: '#f8fafc' },
{ step: 5, color: '#f1f5f9' },
{ step: 10, color: '#e2e8f0' },
{ step: 20, color: '#cbd5e1' },
{ step: 30, color: '#94a3b8' },
{ step: 40, color: '#64748b' },
{ step: 50, color: '#475569' },
{ step: 60, color: '#334155' },
{ step: 70, color: '#1e293b' },
{ step: 80, color: '#0f172a' },
{ step: 90, color: '#0c1322' },
{ step: 95, color: '#070d19' },
{ step: 100, color: '#020617' },
];
const PRIMARY_STEPS: ColorStep[] = [
{ step: 0, color: '#ecfeff' },
{ step: 5, color: '#cffafe' },
{ step: 10, color: '#a5f3fc' },
{ step: 20, color: '#67e8f9' },
{ step: 30, color: '#22d3ee' },
{ step: 40, color: '#06b6d4' },
{ step: 50, color: '#0891b2' },
{ step: 60, color: '#0e7490' },
{ step: 70, color: '#155e75' },
{ step: 80, color: '#164e63' },
{ step: 90, color: '#134152' },
{ step: 95, color: '#0c3140' },
{ step: 0, color: '#ecfeff' },
{ step: 5, color: '#cffafe' },
{ step: 10, color: '#a5f3fc' },
{ step: 20, color: '#67e8f9' },
{ step: 30, color: '#22d3ee' },
{ step: 40, color: '#06b6d4' },
{ step: 50, color: '#0891b2' },
{ step: 60, color: '#0e7490' },
{ step: 70, color: '#155e75' },
{ step: 80, color: '#164e63' },
{ step: 90, color: '#134152' },
{ step: 95, color: '#0c3140' },
{ step: 100, color: '#042f2e' },
];
const SECONDARY_STEPS: ColorStep[] = [
{ step: 0, color: '#f0f4fa' },
{ step: 5, color: '#e1e8f4' },
{ step: 10, color: '#c3d1e8' },
{ step: 20, color: '#8da4c8' },
{ step: 30, color: '#5a7eb0' },
{ step: 40, color: '#3d6399' },
{ step: 50, color: '#2d4f80' },
{ step: 60, color: '#1e3a66' },
{ step: 70, color: '#1a2236' },
{ step: 80, color: '#121929' },
{ step: 90, color: '#0f1524' },
{ step: 95, color: '#0a0e1a' },
{ step: 0, color: '#f0f4fa' },
{ step: 5, color: '#e1e8f4' },
{ step: 10, color: '#c3d1e8' },
{ step: 20, color: '#8da4c8' },
{ step: 30, color: '#5a7eb0' },
{ step: 40, color: '#3d6399' },
{ step: 50, color: '#2d4f80' },
{ step: 60, color: '#1e3a66' },
{ step: 70, color: '#1a2236' },
{ step: 80, color: '#121929' },
{ step: 90, color: '#0f1524' },
{ step: 95, color: '#0a0e1a' },
{ step: 100, color: '#050811' },
];
@ -239,9 +241,7 @@ const ColorScaleBar = ({
{steps.map(({ step, color }, idx) => {
const isFirst = idx === 0;
const isLast = idx === steps.length - 1;
const textColor = step < 50
? (darkBg ? '#e2e8f0' : '#1e293b')
: '#e2e8f0';
const textColor = step < 50 ? (darkBg ? '#e2e8f0' : '#1e293b') : '#e2e8f0';
const rating = getContrastRating(step);
@ -253,7 +253,9 @@ const ColorScaleBar = ({
flex: 1,
backgroundColor: color,
height: '60px',
borderLeft: isFirst ? 'none' : `1px solid ${darkBg ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`,
borderLeft: isFirst
? 'none'
: `1px solid ${darkBg ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`,
borderTopLeftRadius: isFirst ? '8px' : undefined,
borderBottomLeftRadius: isFirst ? '8px' : undefined,
borderTopRightRadius: isLast ? '8px' : undefined,
@ -348,10 +350,7 @@ const TransparencyRow = ({
<span className="font-bold text-sm" style={{ color: isDark ? '#dfe2f3' : '#0f172a' }}>
{label}
</span>
<div
className="rounded-xl p-4"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-4" style={{ backgroundColor: sectionCardBg }}>
{/* 체크보드 + 색상 오버레이 */}
<div
className="relative rounded-lg overflow-hidden"
@ -366,8 +365,12 @@ const TransparencyRow = ({
const color = `rgba(${rgbBase},${alpha})`;
const isBlack = rgbBase === '0,0,0';
const textColor = isBlack
? (step < 50 ? '#333' : '#fff')
: (step < 50 ? '#333' : '#aaa');
? step < 50
? '#333'
: '#fff'
: step < 50
? '#333'
: '#aaa';
return (
<div
@ -380,9 +383,7 @@ const TransparencyRow = ({
paddingBottom: '4px',
}}
>
<span style={{ fontSize: '8px', color: textColor, lineHeight: 1 }}>
{step}
</span>
<span style={{ fontSize: '8px', color: textColor, lineHeight: 1 }}>{step}</span>
</div>
);
})}
@ -449,29 +450,20 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
return (
<div className="p-12" style={{ color: t.textPrimary }}>
<div style={{ maxWidth: '64rem' }}>
{/* ── 섹션 1: 헤더 ── */}
<div
className="pb-10 mb-12 border-b border-solid"
style={{ borderColor: dividerColor }}
>
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Foundations
</p>
<h1
className="text-4xl font-bold mb-4"
style={{ color: t.textPrimary }}
>
<h1 className="text-4xl font-bold mb-4" style={{ color: t.textPrimary }}>
Color
</h1>
<p
className="text-lg"
style={{ color: t.textSecondary }}
>
. <br/> .
<p className="text-lg" style={{ color: t.textSecondary }}>
. <br />
.
</p>
</div>
@ -506,13 +498,18 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</h2>
<div
className="rounded-lg px-6 py-5 font-mono text-lg font-bold mb-4"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5', color: t.textAccent }}
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5',
color: t.textAccent,
}}
>
--{'{property}'}-{'{role}'}[-{'{variant}'}]
</div>
<p className="text-sm leading-relaxed" style={{ color: t.textSecondary }}>
<strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3 .
Property는 CSS , Role은 , Variant는 .
{' '}
<strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3
. Property는 CSS , Role은 , Variant는
.
</p>
</div>
@ -521,11 +518,29 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<h2 className="text-xl font-bold mb-4" style={{ color: t.textPrimary }}>
Property ( )
</h2>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${dividerColor}` }}
>
{[
{ prop: 'fg', desc: '텍스트, 아이콘 등 UI의 전경 요소에 사용하는 색상입니다.', css: 'color', example: '--fg-default, --fg-sub' },
{ prop: 'bg', desc: '전체 화면 또는 UI의 배경에 사용하는 색상입니다.', css: 'background', example: '--bg-surface, --bg-card' },
{ prop: 'stroke', desc: '경계를 구분하는 선 또는 UI 요소의 윤곽선에 사용하는 색상입니다.', css: 'border, outline', example: '--stroke-default, --stroke-light' },
{
prop: 'fg',
desc: '텍스트, 아이콘 등 UI의 전경 요소에 사용하는 색상입니다.',
css: 'color',
example: '--fg-default, --fg-sub',
},
{
prop: 'bg',
desc: '전체 화면 또는 UI의 배경에 사용하는 색상입니다.',
css: 'background',
example: '--bg-surface, --bg-card',
},
{
prop: 'stroke',
desc: '경계를 구분하는 선 또는 UI 요소의 윤곽선에 사용하는 색상입니다.',
css: 'border, outline',
example: '--stroke-default, --stroke-light',
},
].map((row, i) => (
<div
key={row.prop}
@ -535,11 +550,20 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-sm font-bold shrink-0 w-16" style={{ color: t.textAccent }}>{row.prop}</span>
<span
className="font-mono text-sm 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 }}>{row.desc}</div>
<div className="text-sm" style={{ color: t.textSecondary }}>
{row.desc}
</div>
</div>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.example}</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
{row.example}
</span>
</div>
))}
</div>
@ -552,8 +576,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</h2>
<div className="grid grid-cols-2 gap-6">
{/* Role */}
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<div className="px-5 py-3 text-xs font-bold uppercase tracking-wider" style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
Role
</div>
{[
@ -574,15 +607,31 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-sm font-semibold" style={{ color: t.textPrimary }}>{row.name}</span>
<span className="text-xs" style={{ color: t.textMuted }}>{row.desc}</span>
<span
className="font-mono text-sm font-semibold"
style={{ color: t.textPrimary }}
>
{row.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
{row.desc}
</span>
</div>
))}
</div>
{/* Variant */}
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<div className="px-5 py-3 text-xs font-bold uppercase tracking-wider" style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${dividerColor}` }}
>
<div
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
Variant ()
</div>
{[
@ -600,8 +649,15 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-sm font-semibold" style={{ color: t.textPrimary }}>{row.name}</span>
<span className="text-xs" style={{ color: t.textMuted }}>{row.desc}</span>
<span
className="font-mono text-sm font-semibold"
style={{ color: t.textPrimary }}
>
{row.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
{row.desc}
</span>
</div>
))}
</div>
@ -625,11 +681,36 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{
title: 'bg — Background',
tokens: [
{ legacy: '--bg0', name: '--bg-base', value: '#0a0e1a', desc: '페이지 최하단 배경' },
{ legacy: '--bg1', name: '--bg-surface', value: '#0f1524', desc: '패널, 사이드바' },
{ legacy: '--bg2', name: '--bg-elevated', value: '#121929', desc: '테이블 헤더, 섹션' },
{ legacy: '--bg3', name: '--bg-card', value: '#1a2236', desc: '카드, 플로팅 요소' },
{ legacy: '--bgH', name: '--bg-surface-hover', value: '#1e2844', desc: '호버 인터랙션' },
{
legacy: '--bg0',
name: '--bg-base',
value: '#0a0e1a',
desc: '페이지 최하단 배경',
},
{
legacy: '--bg1',
name: '--bg-surface',
value: '#0f1524',
desc: '패널, 사이드바',
},
{
legacy: '--bg2',
name: '--bg-elevated',
value: '#121929',
desc: '테이블 헤더, 섹션',
},
{
legacy: '--bg3',
name: '--bg-card',
value: '#1a2236',
desc: '카드, 플로팅 요소',
},
{
legacy: '--bgH',
name: '--bg-surface-hover',
value: '#1e2844',
desc: '호버 인터랙션',
},
],
},
{
@ -637,40 +718,95 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
tokens: [
{ legacy: '--t1', name: '--fg-default', value: '#edf0f7', desc: '기본 텍스트' },
{ legacy: '--t2', name: '--fg-sub', value: '#c0c8dc', desc: '보조 텍스트' },
{ legacy: '--t3', name: '--fg-disabled', value: '#9ba3b8', desc: '비활성, 플레이스홀더' },
{
legacy: '--t3',
name: '--fg-disabled',
value: '#9ba3b8',
desc: '비활성, 플레이스홀더',
},
],
},
{
title: 'stroke — Border',
tokens: [
{ legacy: '--bd', name: '--stroke-default', value: '#1e2a42', desc: '기본 구분선' },
{ legacy: '--bdL', name: '--stroke-light', value: '#2a3a5c', desc: '연한 구분선' },
{
legacy: '--bd',
name: '--stroke-default',
value: '#1e2a42',
desc: '기본 구분선',
},
{
legacy: '--bdL',
name: '--stroke-light',
value: '#2a3a5c',
desc: '연한 구분선',
},
],
},
].map((group) => (
<div key={group.title} className="mb-8">
<h3 className="text-sm font-bold mb-3" style={{ color: t.textAccent }}>{group.title}</h3>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<h3 className="text-sm font-bold mb-3" style={{ color: t.textAccent }}>
{group.title}
</h3>
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${dividerColor}` }}
>
{/* 헤더 */}
<div
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Legacy</span><span>New</span><span></span><span>Value</span><span>Preview</span>
<span>Legacy</span>
<span>New</span>
<span></span>
<span>Value</span>
<span>Preview</span>
</div>
{group.tokens.map((tk) => (
<div
key={tk.name}
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
style={{
borderTop: `1px solid ${dividerColor}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>{tk.value}</span>
<span
className="font-mono text-xs line-through"
style={{ color: t.textMuted }}
>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
{tk.desc}
</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
{tk.value}
</span>
<div className="flex items-center gap-2">
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: tk.value, border: `1px solid ${dividerColor}`, flexShrink: 0 }} />
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{hexToRgb(tk.value)}</span>
<div
style={{
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: tk.value,
border: `1px solid ${dividerColor}`,
flexShrink: 0,
}}
/>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
{hexToRgb(tk.value)}
</span>
</div>
</div>
))}
@ -688,15 +824,29 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
Palette Tokens
</h2>
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
fg · bg · stroke . Property <code className="font-mono" style={{ color: t.textAccent }}>--color-*</code> .
fg · bg · stroke . Property {' '}
<code className="font-mono" style={{ color: t.textAccent }}>
--color-*
</code>{' '}
.
</p>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<div
className="rounded-lg overflow-hidden"
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"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Legacy</span><span>New</span><span>Value</span><span>Preview</span><span></span>
<span>Legacy</span>
<span>New</span>
<span>Value</span>
<span>Preview</span>
<span></span>
</div>
{[
{ legacy: '--cyan', name: '--color-accent', value: '#06b6d4', desc: '주요 강조' },
@ -705,23 +855,63 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{ legacy: '--orange', name: '--color-warning', value: '#f97316', desc: '주의' },
{ legacy: '--yellow', name: '--color-caution', value: '#eab308', desc: '경고' },
{ legacy: '--green', name: '--color-success', value: '#22c55e', desc: '성공' },
{ legacy: '--purple', name: '--color-tertiary', value: '#a855f7', desc: '3차 강조' },
{ legacy: '--boom', name: '--color-boom', value: '#f59e0b', desc: '오일붐 (도메인)' },
{ legacy: '--boomH', name: '--color-boom-hover', value: '#fbbf24', desc: '오일붐 호버' },
{
legacy: '--purple',
name: '--color-tertiary',
value: '#a855f7',
desc: '3차 강조',
},
{
legacy: '--boom',
name: '--color-boom',
value: '#f59e0b',
desc: '오일붐 (도메인)',
},
{
legacy: '--boomH',
name: '--color-boom-hover',
value: '#fbbf24',
desc: '오일붐 호버',
},
].map((tk) => (
<div
key={tk.name}
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
style={{
borderTop: `1px solid ${dividerColor}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>{tk.value}</span>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
{tk.value}
</span>
<div className="flex items-center gap-2">
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: tk.value, border: `1px solid ${dividerColor}`, flexShrink: 0 }} />
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{hexToRgb(tk.value)}</span>
<div
style={{
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: tk.value,
border: `1px solid ${dividerColor}`,
flexShrink: 0,
}}
/>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
{hexToRgb(tk.value)}
</span>
</div>
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
<span className="text-xs" style={{ color: t.textMuted }}>
{tk.desc}
</span>
</div>
))}
</div>
@ -739,28 +929,77 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
, .
</p>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${dividerColor}` }}>
<div
className="rounded-lg overflow-hidden"
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"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Legacy</span><span>New</span><span>Category</span><span></span>
<span>Legacy</span>
<span>New</span>
<span>Category</span>
<span></span>
</div>
{[
{ legacy: '--fK', name: '--font-korean', category: 'Typography', desc: '한국어 UI 폰트 스택' },
{ legacy: '--fM', name: '--font-mono', category: 'Typography', desc: '수치/데이터 폰트 스택' },
{ legacy: '--rS', name: '--radius-sm', category: 'Radius', desc: '작은 라운딩 (6px)' },
{ legacy: '--rM', name: '--radius-md', category: 'Radius', desc: '중간 라운딩 (8px)' },
{
legacy: '--fK',
name: '--font-korean',
category: 'Typography',
desc: '한국어 UI 폰트 스택',
},
{
legacy: '--fM',
name: '--font-mono',
category: 'Typography',
desc: '수치/데이터 폰트 스택',
},
{
legacy: '--rS',
name: '--radius-sm',
category: 'Radius',
desc: '작은 라운딩 (6px)',
},
{
legacy: '--rM',
name: '--radius-md',
category: 'Radius',
desc: '중간 라운딩 (8px)',
},
].map((tk) => (
<div
key={tk.name}
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${dividerColor}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}
style={{
borderTop: `1px solid ${dividerColor}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>{tk.legacy}</span>
<span className="font-mono text-xs font-semibold" style={{ color: t.textPrimary }}>{tk.name}</span>
<span className="text-xs 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)' }}>{tk.category}</span>
<span className="text-xs" style={{ color: t.textMuted }}>{tk.desc}</span>
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
{tk.legacy}
</span>
<span
className="font-mono text-xs font-semibold"
style={{ color: t.textPrimary }}
>
{tk.name}
</span>
<span
className="text-xs 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)',
}}
>
{tk.category}
</span>
<span className="text-xs" style={{ color: t.textMuted }}>
{tk.desc}
</span>
</div>
))}
</div>
@ -773,22 +1012,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<>
{/* ── 섹션 2: Primary color ── */}
<div className="mb-16">
<h2
className="text-xl font-bold mb-3"
style={{ color: t.textPrimary }}
>
<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 }}>
Primary . Cyan~Blue .
Primary . Cyan~Blue
.
</p>
<div className="flex flex-col gap-6">
{/* Light Mode */}
<div
className="rounded-xl p-6"
style={{ backgroundColor: '#f5f5f5' }}
>
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
Light Mode
</p>
@ -806,10 +1040,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</div>
{/* Dark Mode */}
<div
className="rounded-xl p-6"
style={{ backgroundColor: '#1a1a2e' }}
>
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
Dark Mode
</p>
@ -833,22 +1064,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
className="mb-16 pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-xl font-bold mb-3"
style={{ color: t.textPrimary }}
>
<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 }}>
Secondary UI의 . Navy .
Secondary UI의 . Navy
.
</p>
<div className="flex flex-col gap-6">
{/* Light Mode */}
<div
className="rounded-xl p-6"
style={{ backgroundColor: '#f5f5f5' }}
>
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
Light Mode
</p>
@ -866,10 +1092,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</div>
{/* Dark Mode */}
<div
className="rounded-xl p-6"
style={{ backgroundColor: '#1a1a2e' }}
>
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
Dark Mode
</p>
@ -893,27 +1116,20 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
className="mb-16 pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-xl font-bold mb-3"
style={{ color: t.textPrimary }}
>
<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 }}>
Gray , , , .
Gray , , ,
.
</p>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
.
.
</p>
<div
className="rounded-xl p-6"
style={{ backgroundColor: sectionCardBg }}
>
<ColorScaleBar
steps={GRAY_STEPS}
isDark={isDark}
/>
<div className="rounded-xl p-6" style={{ backgroundColor: sectionCardBg }}>
<ColorScaleBar steps={GRAY_STEPS} isDark={isDark} />
</div>
</div>
@ -922,14 +1138,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
className="mb-16 pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-2xl font-bold mb-2"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
Transparent
</h2>
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
. 65% .
. 65%
.
</p>
<div className="flex flex-col gap-8">
@ -1007,16 +1221,10 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</span>
{/* HEX + RGB */}
<div className="ml-auto text-right">
<div
className="font-mono text-sm"
style={{ color: t.textPrimary }}
>
<div className="font-mono text-sm" style={{ color: t.textPrimary }}>
{token.hex}
</div>
<div
className="font-mono text-xs"
style={{ color: t.textMuted }}
>
<div className="font-mono text-xs" style={{ color: t.textMuted }}>
{hexToRgb(token.hex)}
</div>
</div>
@ -1025,10 +1233,8 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
</div>
</div>
))}
</div>
)}
</div>
</div>
);

파일 보기

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

파일 보기

@ -71,7 +71,12 @@ const TextInputsThumbnail = ({ isDark }: { isDark: boolean }) => {
>
<div
className="rounded"
style={{ height: '8px', width: '70px', backgroundColor: placeholderColor, opacity: 0.5 }}
style={{
height: '8px',
width: '70px',
backgroundColor: placeholderColor,
opacity: 0.5,
}}
/>
</div>
</div>
@ -94,7 +99,12 @@ const TextInputsThumbnail = ({ isDark }: { isDark: boolean }) => {
>
<div
className="rounded"
style={{ height: '8px', width: '90px', backgroundColor: isDark ? 'rgba(223,226,243,0.50)' : '#475569', opacity: 0.7 }}
style={{
height: '8px',
width: '90px',
backgroundColor: isDark ? 'rgba(223,226,243,0.50)' : '#475569',
opacity: 0.7,
}}
/>
</div>
</div>
@ -136,7 +146,6 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
return (
<div className="pt-24 px-12 pb-16 max-w-5xl flex flex-col gap-12">
{/* ── 헤더 영역 ── */}
<div className="flex flex-col gap-3">
<span
@ -145,25 +154,16 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
>
Components
</span>
<h1
className="font-sans text-4xl font-bold leading-tight"
style={{ color: t.textPrimary }}
>
<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-sm leading-6" style={{ color: t.textSecondary }}>
UI .
</p>
</div>
{/* ── 3열 카드 그리드 ── */}
<div
className="grid gap-5"
style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}
>
<div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
{OVERVIEW_CARDS.map((card) => (
<div
key={card.id}
@ -179,9 +179,7 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
el.style.boxShadow = isDark
? '0 8px 24px rgba(0,0,0,0.35)'
: '0 6px 18px rgba(0,0,0,0.10)';
el.style.borderColor = isDark
? 'rgba(76,215,246,0.22)'
: 'rgba(6,182,212,0.28)';
el.style.borderColor = isDark ? 'rgba(76,215,246,0.22)' : 'rgba(6,182,212,0.28)';
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
@ -202,10 +200,7 @@ 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-sm font-semibold" style={{ color: t.textPrimary }}>
{card.label}
</span>
</div>

파일 보기

@ -46,14 +46,18 @@ const TYPO_ROWS: TypoRow[] = [
{
size: '9px / Meta',
sampleNode: (t) => (
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}> Meta info</span>
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}>
Meta info
</span>
),
properties: 'Regular / 400',
},
{
size: '10px / Table',
sampleNode: (t) => (
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}> Table data</span>
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}>
Table data
</span>
),
properties: 'Medium / 500',
},
@ -93,8 +97,12 @@ const TYPO_ROWS: TypoRow[] = [
size: 'Data / Mono',
sampleNode: (t) => (
<div className="flex flex-col gap-1">
<span className="font-mono text-[11px]" style={{ color: t.typoDataText }}>1,234.56 km²</span>
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>35° 06' 12&quot; N</span>
<span className="font-mono text-[11px]" style={{ color: t.typoDataText }}>
1,234.56 km²
</span>
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>
35° 06' 12&quot; N
</span>
</div>
),
properties: 'PretendardGOV / 11px',
@ -125,7 +133,10 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
</h1>
<p className="font-korean text-base leading-6 font-light" style={{ color: t.textSecondary }}>
<p
className="font-korean text-base leading-6 font-light"
style={{ color: t.textSecondary }}
>
Comprehensive design token reference for the WING-OPS operational interface.
</p>
</div>
@ -181,11 +192,19 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
/>
{/* 정보 */}
<div className="flex flex-col gap-1">
<span className="font-mono text-xs leading-4" style={{ color: t.textAccent }}>{item.token}</span>
<span className="font-korean text-sm leading-5 font-bold" style={{ color: t.textPrimary }}>
<span className="font-mono text-xs leading-4" style={{ color: t.textAccent }}>
{item.token}
</span>
<span
className="font-korean text-sm leading-5 font-bold"
style={{ color: t.textPrimary }}
>
{item.hex}
</span>
<span className="font-korean text-[11px] leading-[16.5px]" style={{ color: t.textSecondary }}>
<span
className="font-korean text-[11px] leading-[16.5px]"
style={{ color: t.textSecondary }}
>
{item.desc}
</span>
</div>
@ -210,11 +229,19 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
boxShadow: t.borderCardShadow,
}}
>
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>{item.token}</span>
<span className="font-korean text-sm font-bold pb-2" style={{ color: t.textPrimary }}>
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
{item.token}
</span>
<span
className="font-korean text-sm font-bold pb-2"
style={{ color: t.textPrimary }}
>
{item.hex}
</span>
<div className="h-1 self-stretch rounded-sm" style={{ backgroundColor: item.barBg }} />
<div
className="h-1 self-stretch rounded-sm"
style={{ backgroundColor: item.barBg }}
/>
</div>
))}
</div>
@ -232,7 +259,9 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
{t.textTokens.map((item) => (
<div key={item.token} className="flex flex-col gap-[3px]">
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>{item.token}</span>
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
{item.token}
</span>
<span className={item.sampleClass}>{item.sampleText}</span>
<span className="font-korean text-xs" style={{ color: item.descColor }}>
{item.desc}
@ -267,7 +296,12 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
}}
/>
<div className="flex flex-col gap-1">
<span className="font-korean text-sm font-bold" style={{ color: t.textPrimary }}>{item.name}</span>
<span
className="font-korean text-sm font-bold"
style={{ color: t.textPrimary }}
>
{item.name}
</span>
<span className="font-mono text-[10px]" style={{ color: t.textMuted }}>
{item.token} / {item.color}
</span>
@ -378,13 +412,17 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
{/* Size */}
<div className="flex-1 py-4 px-8">
<span className="font-mono text-[10px]" style={{ color: t.typoSizeText }}>{row.size}</span>
<span className="font-mono text-[10px]" style={{ color: t.typoSizeText }}>
{row.size}
</span>
</div>
{/* Sample */}
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
{/* Properties */}
<div className="flex-1 py-4 px-8 text-right" style={{ opacity: 0.5 }}>
<span className="font-mono text-[10px]" style={{ color: t.typoPropertiesText }}>{row.properties}</span>
<span className="font-mono text-[10px]" style={{ color: t.typoPropertiesText }}>
{row.properties}
</span>
</div>
</div>
))}
@ -397,7 +435,9 @@ 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 }}>{t.radiusSmLabel}</span>
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
{t.radiusSmLabel}
</span>
<div
className="rounded-md border border-solid p-6 h-32 flex flex-col justify-end"
style={{
@ -412,14 +452,20 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
Small Elements
</span>
<p className="font-korean text-xs leading-[19.5px] mt-1" style={{ color: t.radiusDescText }}>
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp industrial feel.
<p
className="font-korean text-xs leading-[19.5px] mt-1"
style={{ color: t.radiusDescText }}
>
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp
industrial feel.
</p>
</div>
</div>
{/* radius-md */}
<div className="flex flex-col gap-2">
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>{t.radiusMdLabel}</span>
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
{t.radiusMdLabel}
</span>
<div
className="rounded-[10px] border border-solid p-6 h-32 flex flex-col justify-end"
style={{
@ -434,8 +480,12 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
>
Structural Panels
</span>
<p className="font-korean text-xs leading-[19.5px] mt-1" style={{ color: t.radiusDescText }}>
Applied to telemetry cards, floating modals, and primary operational panels to soften high-density data.
<p
className="font-korean text-xs leading-[19.5px] mt-1"
style={{ color: t.radiusDescText }}
>
Applied to telemetry cards, floating modals, and primary operational panels to
soften high-density data.
</p>
</div>
</div>

파일 보기

@ -15,7 +15,12 @@ const TABS: { label: string; id: DesignTab }[] = [
{ label: 'Components', id: 'components' },
];
export const DesignHeader = ({ activeTab, onTabChange, theme, onThemeToggle }: DesignHeaderProps) => {
export const DesignHeader = ({
activeTab,
onTabChange,
theme,
onThemeToggle,
}: DesignHeaderProps) => {
const isDark = theme.mode === 'dark';
const navigate = useNavigate();

파일 보기

@ -37,7 +37,12 @@ export const DesignPage = () => {
if (activeTab === 'foundations') {
switch (sidebarItem) {
case 'overview':
return <FoundationsOverview theme={theme} onNavigate={(id) => setSidebarItem(id as MenuItemId)} />;
return (
<FoundationsOverview
theme={theme}
onNavigate={(id) => setSidebarItem(id as MenuItemId)}
/>
);
case 'color':
return <ColorPaletteContent theme={theme} />;
case 'typography':
@ -47,12 +52,19 @@ export const DesignPage = () => {
case 'layout':
return <LayoutContent theme={theme} />;
default:
return <FoundationsOverview theme={theme} onNavigate={(id) => setSidebarItem(id as MenuItemId)} />;
return (
<FoundationsOverview
theme={theme}
onNavigate={(id) => setSidebarItem(id as MenuItemId)}
/>
);
}
}
switch (sidebarItem) {
case 'overview':
return <ComponentsOverview theme={theme} onNavigate={(id) => setSidebarItem(id as MenuItemId)} />;
return (
<ComponentsOverview theme={theme} onNavigate={(id) => setSidebarItem(id as MenuItemId)} />
);
case 'buttons':
return <ButtonContent theme={theme} />;
case 'text-field':
@ -67,12 +79,20 @@ export const DesignPage = () => {
className="h-screen w-screen overflow-hidden flex flex-col"
style={{ backgroundColor: theme.pageBg }}
>
<DesignHeader activeTab={activeTab} onTabChange={handleTabChange} theme={theme} onThemeToggle={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} />
<DesignHeader
activeTab={activeTab}
onTabChange={handleTabChange}
theme={theme}
onThemeToggle={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
/>
<div className="flex flex-1 overflow-hidden">
<DesignSidebar theme={theme} activeTab={activeTab} activeItem={sidebarItem} onItemChange={setSidebarItem} />
<main className="flex-1 overflow-y-auto">
{renderContent()}
</main>
<DesignSidebar
theme={theme}
activeTab={activeTab}
activeItem={sidebarItem}
onItemChange={setSidebarItem}
/>
<main className="flex-1 overflow-y-auto">{renderContent()}</main>
</div>
</div>
);

파일 보기

@ -35,9 +35,7 @@ const ColorThumbnail = ({ isDark }: { isDark: boolean }) => {
width: '28px',
height: '28px',
backgroundColor: color,
boxShadow: isDark
? `0 0 8px ${color}55`
: `0 1px 3px rgba(0,0,0,0.15)`,
boxShadow: isDark ? `0 0 8px ${color}55` : `0 1px 3px rgba(0,0,0,0.15)`,
}}
/>
)),
@ -113,36 +111,18 @@ const LayoutThumbnail = ({ isDark }: { isDark: boolean }) => {
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-2 px-8">
{/* 헤더 바 */}
<div
className="w-full rounded"
style={{ height: '14px', backgroundColor: accentStrong }}
/>
<div className="w-full rounded" style={{ height: '14px', backgroundColor: accentStrong }} />
{/* 2열 바디 */}
<div className="w-full flex gap-2" style={{ flex: 1, maxHeight: '52px' }}>
<div
className="rounded"
style={{ width: '28%', backgroundColor: accent }}
/>
<div className="rounded" style={{ width: '28%', backgroundColor: accent }} />
<div className="flex flex-col gap-1.5" style={{ flex: 1 }}>
<div
className="w-full rounded"
style={{ height: '12px', backgroundColor: faint }}
/>
<div
className="w-3/4 rounded"
style={{ height: '12px', backgroundColor: faint }}
/>
<div
className="w-full rounded"
style={{ height: '12px', backgroundColor: faint }}
/>
<div className="w-full rounded" style={{ height: '12px', backgroundColor: faint }} />
<div className="w-3/4 rounded" style={{ height: '12px', backgroundColor: faint }} />
<div className="w-full rounded" style={{ height: '12px', backgroundColor: faint }} />
</div>
</div>
{/* 푸터 바 */}
<div
className="w-full rounded"
style={{ height: '10px', backgroundColor: accent }}
/>
<div className="w-full rounded" style={{ height: '10px', backgroundColor: accent }} />
</div>
);
};
@ -191,7 +171,6 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
return (
<div className="pt-24 px-12 pb-16 max-w-5xl flex flex-col gap-12">
{/* ── 헤더 영역 ── */}
<div className="flex flex-col gap-3">
<span
@ -200,25 +179,16 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
>
Foundations
</span>
<h1
className="font-sans text-4xl font-bold leading-tight"
style={{ color: t.textPrimary }}
>
<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-sm leading-6" style={{ color: t.textSecondary }}>
.
</p>
</div>
{/* ── 3열 카드 그리드 ── */}
<div
className="grid gap-5"
style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}
>
<div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
{OVERVIEW_CARDS.map((card) => (
<div
key={card.id}
@ -234,9 +204,7 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
el.style.boxShadow = isDark
? '0 8px 24px rgba(0,0,0,0.35)'
: '0 6px 18px rgba(0,0,0,0.10)';
el.style.borderColor = isDark
? 'rgba(76,215,246,0.22)'
: 'rgba(6,182,212,0.28)';
el.style.borderColor = isDark ? 'rgba(76,215,246,0.22)' : 'rgba(6,182,212,0.28)';
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
@ -257,10 +225,7 @@ 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-sm font-semibold" style={{ color: t.textPrimary }}>
{card.label}
</span>
</div>

파일 보기

@ -47,15 +47,35 @@ const BREAKPOINTS: Breakpoint[] = [
{ name: 'sm', prefix: 'sm:', minWidth: '640px', inUse: false },
{ name: 'md', prefix: 'md:', minWidth: '768px', inUse: false },
{ name: 'lg', prefix: 'lg:', minWidth: '1024px', inUse: false },
{ name: 'xl', prefix: 'xl:', minWidth: '1280px', inUse: true, note: 'TopBar 탭 레이블/아이콘 토글' },
{
name: 'xl',
prefix: 'xl:',
minWidth: '1280px',
inUse: true,
note: 'TopBar 탭 레이블/아이콘 토글',
},
{ name: '2xl', prefix: '2xl:', minWidth: '1536px', inUse: false },
];
// ---------- Device Specs ----------
const DEVICE_SPECS: DeviceSpec[] = [
{ device: 'Desktop', width: '≥ 1280px', columns: 'flex 기반 가변', gutter: 'gap-2 ~ gap-6', margin: 'px-5 ~ px-8', supported: true },
{ device: 'Tablet', width: '768px ~ 1279px', columns: '-', gutter: '-', margin: '-', supported: false },
{
device: 'Desktop',
width: '≥ 1280px',
columns: 'flex 기반 가변',
gutter: 'gap-2 ~ gap-6',
margin: 'px-5 ~ px-8',
supported: true,
},
{
device: 'Tablet',
width: '768px ~ 1279px',
columns: '-',
gutter: '-',
margin: '-',
supported: false,
},
{ device: 'Mobile', width: '< 768px', columns: '-', gutter: '-', margin: '-', supported: false },
];
@ -90,9 +110,21 @@ const Z_LAYERS: ZLayer[] = [
// ---------- App Shell Classes ----------
const SHELL_CLASSES: ShellClass[] = [
{ className: '.wing-panel', role: '탭 콘텐츠 패널', styles: 'flex flex-col h-full overflow-hidden' },
{ className: '.wing-panel-scroll', role: '패널 내 스크롤 영역', styles: 'flex-1 overflow-y-auto' },
{ className: '.wing-header-bar', role: '패널 헤더', styles: 'flex items-center justify-between shrink-0 px-5 border-b' },
{
className: '.wing-panel',
role: '탭 콘텐츠 패널',
styles: 'flex flex-col h-full overflow-hidden',
},
{
className: '.wing-panel-scroll',
role: '패널 내 스크롤 영역',
styles: 'flex-1 overflow-y-auto',
},
{
className: '.wing-header-bar',
role: '패널 헤더',
styles: 'flex items-center justify-between shrink-0 px-5 border-b',
},
{ className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-stroke' },
];
@ -110,31 +142,30 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
return (
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
{/* ── 섹션 1: 헤더 + 개요 ── */}
<div
className="w-full border-b border-solid pb-8 flex flex-col gap-4"
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
>
<div className="flex flex-col gap-2">
<h1
className="font-sans text-3xl leading-9 font-bold"
style={{ color: t.textPrimary }}
>
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
Layout
</h1>
<p
className="font-korean text-sm leading-5"
style={{ color: t.textSecondary }}
>
WING-OPS는 . (100vh), flex .
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
WING-OPS는 .
(100vh), flex .
</p>
</div>
<ul
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
style={{ color: t.textSecondary }}
>
<li> : <code style={{ color: t.textAccent, fontSize: '12px' }}>body {'{'} height: 100vh; overflow: hidden {'}'}</code></li>
<li>
:{' '}
<code style={{ color: t.textAccent, fontSize: '12px' }}>
body {'{'} height: 100vh; overflow: hidden {'}'}
</code>
</li>
<li> 수단: flex (2,243) &gt; grid (~120) flex가 </li>
<li>Tailwind CSS breakpoints/spacing을 , </li>
</ul>
@ -143,17 +174,13 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{/* ── 섹션 2: Breakpoints ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Breakpoints
</h2>
<p
className="font-korean text-sm leading-5"
style={{ color: t.textSecondary }}
>
Tailwind CSS breakpoints를 . , <code style={{ color: t.textAccent, fontSize: '12px' }}>xl:</code> .
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
Tailwind CSS breakpoints를 . ,{' '}
<code style={{ color: t.textAccent, fontSize: '12px' }}>xl:</code>
.
</p>
</div>
@ -195,27 +222,46 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
}}
>
<div className="py-3 px-4">
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{bp.name}</span>
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
{bp.name}
</span>
</div>
<div className="py-3 px-4">
<span
className="font-mono rounded border border-solid px-2 py-0.5"
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
style={{
fontSize: '11px',
color: t.textAccent,
backgroundColor: t.cardBg,
borderColor: t.cardBorder,
}}
>
{bp.prefix}
</span>
</div>
<div className="py-3 px-4">
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{bp.minWidth}</span>
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
{bp.minWidth}
</span>
</div>
<div className="py-3 px-4">
<span
className="font-mono text-[9px] rounded px-1.5 py-0.5"
style={{
color: bp.inUse ? (isDark ? '#22c55e' : '#047857') : (isDark ? '#9ba3b8' : '#94a3b8'),
color: bp.inUse
? isDark
? '#22c55e'
: '#047857'
: isDark
? '#9ba3b8'
: '#94a3b8',
backgroundColor: bp.inUse
? (isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)')
: (isDark ? 'rgba(134,144,166,0.10)' : 'rgba(148,163,184,0.08)'),
? isDark
? 'rgba(34,197,94,0.10)'
: 'rgba(34,197,94,0.08)'
: isDark
? 'rgba(134,144,166,0.10)'
: 'rgba(148,163,184,0.08)',
}}
>
{bp.inUse ? '사용 중' : '미사용'}
@ -234,16 +280,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{/* ── 섹션 3: Device Grid & Spacing ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Device Grid & Spacing
</h2>
<p
className="font-korean text-sm leading-5"
style={{ color: t.textSecondary }}
>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
. Desktop만 .
</p>
</div>
@ -255,16 +295,17 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
className="rounded-lg border border-solid px-5 py-5 flex flex-col gap-4"
style={{
backgroundColor: t.cardBg,
borderColor: spec.supported ? t.cardBorder : (isDark ? 'rgba(66,71,84,0.10)' : '#f1f5f9'),
borderColor: spec.supported
? t.cardBorder
: isDark
? 'rgba(66,71,84,0.10)'
: '#f1f5f9',
boxShadow: t.cardShadow,
opacity: spec.supported ? 1 : 0.5,
}}
>
<div className="flex flex-row items-center justify-between">
<span
className="font-sans text-lg font-bold"
style={{ color: t.textPrimary }}
>
<span className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>
{spec.device}
</span>
{!spec.supported && (
@ -288,7 +329,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{ label: 'Margin', value: spec.margin },
].map((row) => (
<div key={row.label} className="flex flex-row justify-between items-center">
<span className="font-mono text-[10px] uppercase" style={{ letterSpacing: '0.5px', color: t.textMuted }}>
<span
className="font-mono text-[10px] uppercase"
style={{ letterSpacing: '0.5px', color: t.textMuted }}
>
{row.label}
</span>
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
@ -305,16 +349,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{/* ── 섹션 4: Spacing Scale ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Spacing Scale
</h2>
<p
className="font-korean text-sm leading-5"
style={{ color: t.textSecondary }}
>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
Tailwind CSS spacing . gap, padding, margin에 .
</p>
</div>
@ -359,16 +397,25 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
<div className="py-3 px-4">
<span
className="font-mono rounded border border-solid px-2 py-0.5"
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
style={{
fontSize: '11px',
color: t.textAccent,
backgroundColor: t.cardBg,
borderColor: t.cardBorder,
}}
>
{token.className}
</span>
</div>
<div className="py-3 px-4">
<span className="font-mono text-[11px]" style={{ color: t.textSecondary }}>{token.rem}</span>
<span className="font-mono text-[11px]" style={{ color: t.textSecondary }}>
{token.rem}
</span>
</div>
<div className="py-3 px-4">
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{token.px}</span>
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
{token.px}
</span>
</div>
<div className="py-3 px-4 flex items-center">
<div
@ -382,7 +429,9 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
/>
</div>
<div className="py-3 px-4">
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>{token.usage}</span>
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
{token.usage}
</span>
</div>
</div>
))}
@ -392,16 +441,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{/* ── 섹션 5: Z-Index Layers ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Z-Index Layers
</h2>
<p
className="font-korean text-sm leading-5"
style={{ color: t.textSecondary }}
>
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
UI . z-index가 .
</p>
</div>
@ -449,16 +492,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
{/* ── 섹션 6: App Shell 구조 ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<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-sm leading-5" style={{ color: t.textSecondary }}>
WING-OPS .
</p>
</div>
@ -482,8 +519,12 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.15)',
}}
>
<span className="font-mono text-[10px] font-bold" style={{ color: '#06b6d4' }}>TopBar</span>
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>h-[52px] / shrink-0</span>
<span className="font-mono text-[10px] font-bold" style={{ color: '#06b6d4' }}>
TopBar
</span>
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>
h-[52px] / shrink-0
</span>
</div>
{/* SubMenuBar */}
@ -495,8 +536,12 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
borderColor: isDark ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.10)',
}}
>
<span className="font-mono text-[10px] font-bold" style={{ color: '#3b82f6' }}>SubMenuBar</span>
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>shrink-0 / </span>
<span className="font-mono text-[10px] font-bold" style={{ color: '#3b82f6' }}>
SubMenuBar
</span>
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>
shrink-0 /
</span>
</div>
{/* Content Area */}
@ -510,8 +555,12 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
borderColor: isDark ? 'rgba(168,85,247,0.15)' : 'rgba(168,85,247,0.10)',
}}
>
<span className="font-mono text-[10px] font-bold" style={{ color: '#a855f7' }}>Sidebar</span>
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}> </span>
<span className="font-mono text-[10px] font-bold" style={{ color: '#a855f7' }}>
Sidebar
</span>
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}>
</span>
</div>
{/* Main Content */}
@ -521,18 +570,19 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
backgroundColor: isDark ? 'rgba(34,197,94,0.04)' : 'rgba(34,197,94,0.03)',
}}
>
<span className="font-mono text-[10px] font-bold" style={{ color: '#22c55e' }}>Content</span>
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}>flex-1 / overflow-y-auto</span>
<span className="font-mono text-[10px] font-bold" style={{ color: '#22c55e' }}>
Content
</span>
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}>
flex-1 / overflow-y-auto
</span>
</div>
</div>
</div>
{/* wing.css 레이아웃 클래스 */}
<div className="flex flex-col gap-3" style={{ maxWidth: '700px' }}>
<h3
className="font-korean text-sm font-bold"
style={{ color: t.textPrimary }}
>
<h3 className="font-korean text-sm font-bold" style={{ color: t.textPrimary }}>
</h3>
{SHELL_CLASSES.map((cls) => (
@ -546,7 +596,12 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
>
<span
className="font-mono rounded border border-solid px-2 py-0.5 shrink-0"
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
style={{
fontSize: '11px',
color: t.textAccent,
backgroundColor: t.cardBg,
borderColor: t.cardBorder,
}}
>
{cls.className}
</span>

파일 보기

@ -32,9 +32,17 @@ const RADIUS_TOKENS: RadiusToken[] = [
// ---------- 컴포넌트 매핑 데이터 ----------
const COMPONENT_RADIUS: ComponentRadius[] = [
{ className: 'rounded-sm (6px)', radius: '6px', components: ['.wing-btn', '.wing-input', '.wing-card-sm'] },
{
className: 'rounded-sm (6px)',
radius: '6px',
components: ['.wing-btn', '.wing-input', '.wing-card-sm'],
},
{ className: 'rounded (4px)', radius: '4px', components: ['.wing-badge'] },
{ className: 'rounded-md (10px)', radius: '10px', components: ['.wing-card', '.wing-section', '.wing-tab'] },
{
className: 'rounded-md (10px)',
radius: '10px',
components: ['.wing-card', '.wing-section', '.wing-tab'],
},
{ className: 'rounded-lg (8px)', radius: '8px', components: ['.wing-tab-bar'] },
{ className: 'rounded-xl (12px)', radius: '12px', components: ['.wing-modal'] },
];
@ -53,31 +61,23 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
return (
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
{/* ── 섹션 1: 헤더 ── */}
<div
className="w-full border-b border-solid pb-8 flex flex-col gap-4"
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
>
<div className="flex flex-col gap-2">
<h1
className="font-sans text-3xl leading-9 font-bold"
style={{ color: t.textPrimary }}
>
<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-sm leading-5" style={{ color: t.textSecondary }}>
Radius는 .
</p>
</div>
<p
className="font-korean text-sm leading-6"
style={{ color: t.textSecondary }}
>
Radius는 UI . Radius , , .
<p className="font-korean text-sm 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"
@ -85,7 +85,8 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
>
<li>
<code style={{ color: t.textAccent, fontSize: '12px' }}>rounded-sm</code>(6px){' '}
<code style={{ color: t.textAccent, fontSize: '12px' }}>rounded-md</code>(10px) Tailwind .
<code style={{ color: t.textAccent, fontSize: '12px' }}>rounded-md</code>(10px)
Tailwind .
</li>
<li> Tailwind CSS border-radius .</li>
</ul>
@ -93,10 +94,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{/* ── 섹션 2: Radius Tokens 테이블 ── */}
<div className="w-full flex flex-col gap-8">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
Radius Tokens
</h2>
@ -168,10 +166,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{/* 값 */}
<div className="py-4 px-4">
<span
className="font-mono text-[11px]"
style={{ color: t.textPrimary }}
>
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
{token.value}
</span>
</div>
@ -205,16 +200,10 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
{/* ── 섹션 3: 컴포넌트 매핑 ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2
className="font-sans text-2xl leading-8 font-bold"
style={{ color: t.textPrimary }}
>
<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-sm leading-5" style={{ color: t.textSecondary }}>
wing.css Radius .
</p>
</div>
@ -244,10 +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-xs font-bold" style={{ color: t.textPrimary }}>
{item.className}
</span>
<div className="flex flex-row flex-wrap gap-2">

파일 보기

@ -38,7 +38,13 @@ const getDarkStateRows = (): StateRow[] => [
badge: 'Focused',
placeholder: '플레이스홀더',
hasCursor: true,
style: { bg: '#1e293b', border: '#e2e8f0', textColor: '#e2e8f0', placeholderColor: '#64748b', borderWidth: '2px' },
style: {
bg: '#1e293b',
border: '#e2e8f0',
textColor: '#e2e8f0',
placeholderColor: '#64748b',
borderWidth: '2px',
},
},
{
state: 'Error',
@ -52,21 +58,38 @@ const getDarkStateRows = (): StateRow[] => [
badge: 'Error Focused',
placeholder: '플레이스홀더',
hasCursor: true,
style: { bg: '#1e293b', border: '#ef4444', textColor: '#e2e8f0', placeholderColor: '#64748b', borderWidth: '2px' },
style: {
bg: '#1e293b',
border: '#ef4444',
textColor: '#e2e8f0',
placeholderColor: '#64748b',
borderWidth: '2px',
},
},
{
state: 'Disabled',
badge: 'Disabled',
placeholder: '플레이스홀더',
hasCursor: false,
style: { bg: 'rgba(255,255,255,0.02)', border: '#1e293b', textColor: '#e2e8f0', placeholderColor: '#64748b', opacity: '0.4' },
style: {
bg: 'rgba(255,255,255,0.02)',
border: '#1e293b',
textColor: '#e2e8f0',
placeholderColor: '#64748b',
opacity: '0.4',
},
},
{
state: 'Read Only',
badge: 'Read Only',
placeholder: '플레이스홀더',
hasCursor: false,
style: { bg: 'rgba(255,255,255,0.02)', border: '#334155', textColor: '#e2e8f0', placeholderColor: '#64748b' },
style: {
bg: 'rgba(255,255,255,0.02)',
border: '#334155',
textColor: '#e2e8f0',
placeholderColor: '#64748b',
},
},
{
state: 'AI Loading',
@ -74,7 +97,12 @@ const getDarkStateRows = (): StateRow[] => [
placeholder: '단서를 모아서 추리 중...',
hasCursor: false,
showSparkle: true,
style: { bg: 'rgba(255,255,255,0.02)', border: '#334155', textColor: '#e2e8f0', placeholderColor: '#64748b' },
style: {
bg: 'rgba(255,255,255,0.02)',
border: '#334155',
textColor: '#e2e8f0',
placeholderColor: '#64748b',
},
},
];
@ -91,7 +119,13 @@ const getLightStateRows = (): StateRow[] => [
badge: 'Focused',
placeholder: '플레이스홀더',
hasCursor: true,
style: { bg: '#fff', border: '#1f2937', textColor: '#1f2937', placeholderColor: '#9ca3af', borderWidth: '2px' },
style: {
bg: '#fff',
border: '#1f2937',
textColor: '#1f2937',
placeholderColor: '#9ca3af',
borderWidth: '2px',
},
},
{
state: 'Error',
@ -105,14 +139,26 @@ const getLightStateRows = (): StateRow[] => [
badge: 'Error Focused',
placeholder: '플레이스홀더',
hasCursor: true,
style: { bg: '#fff', border: '#ef4444', textColor: '#1f2937', placeholderColor: '#9ca3af', borderWidth: '2px' },
style: {
bg: '#fff',
border: '#ef4444',
textColor: '#1f2937',
placeholderColor: '#9ca3af',
borderWidth: '2px',
},
},
{
state: 'Disabled',
badge: 'Disabled',
placeholder: '플레이스홀더',
hasCursor: false,
style: { bg: '#f9fafb', border: '#e5e7eb', textColor: '#1f2937', placeholderColor: '#9ca3af', opacity: '0.4' },
style: {
bg: '#f9fafb',
border: '#e5e7eb',
textColor: '#1f2937',
placeholderColor: '#9ca3af',
opacity: '0.4',
},
},
{
state: 'Read Only',
@ -158,37 +204,24 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
return (
<div className="p-12" style={{ color: t.textPrimary }}>
<div style={{ maxWidth: '64rem' }}>
{/* ── 섹션 1: 헤더 ── */}
<div
className="pb-10 mb-12 border-b border-solid"
style={{ borderColor: dividerColor }}
>
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
<p
className="font-mono text-sm uppercase tracking-widest mb-3"
style={{ color: t.textAccent }}
>
Components
</p>
<h1
className="text-4xl font-bold mb-4"
style={{ color: t.textPrimary }}
>
<h1 className="text-4xl font-bold mb-4" style={{ color: t.textPrimary }}>
Text Field
</h1>
<p
className="text-lg"
style={{ color: t.textSecondary }}
>
<p className="text-lg" style={{ color: t.textSecondary }}>
.
</p>
</div>
{/* ── Input Field 소제목 ── */}
<div
className="pb-6 mb-10 border-b border-solid"
style={{ borderColor: dividerColor }}
>
<div className="pb-6 mb-10 border-b border-solid" style={{ borderColor: dividerColor }}>
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
Input Field
</h2>
@ -199,23 +232,15 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* ── 섹션 2: Anatomy ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
Anatomy
</h2>
{/* Anatomy 카드 */}
<div
className="rounded-xl p-10 mb-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-10 mb-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col items-center gap-12">
{/* 입력 필드 구조 분해도 */}
<div className="relative flex flex-col items-center" style={{ width: '480px' }}>
{/* 상단 주석 라벨: Prefix, Input, Suffix */}
<div className="flex items-end justify-between w-full mb-1 px-2">
{/* Prefix label */}
@ -226,14 +251,13 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
Prefix
</span>
<span
className="font-mono"
style={{ fontSize: '9px', color: annotationColor }}
>
<span className="font-mono" style={{ fontSize: '9px', color: annotationColor }}>
(Optional)
</span>
{/* 아래 화살표 선 */}
<div style={{ width: '1px', height: '10px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -254,7 +278,9 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
Input
</span>
{/* 아래 화살표 선 */}
<div style={{ width: '1px', height: '18px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '18px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -274,14 +300,13 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
Suffix
</span>
<span
className="font-mono"
style={{ fontSize: '9px', color: annotationColor }}
>
<span className="font-mono" style={{ fontSize: '9px', color: annotationColor }}>
(Optional)
</span>
{/* 아래 화살표 선 */}
<div style={{ width: '1px', height: '10px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -315,7 +340,15 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="5" cy="5" r="3.5" stroke={fieldPlaceholder} strokeWidth="1.5" />
<line x1="7.5" y1="7.5" x2="10.5" y2="10.5" stroke={fieldPlaceholder} strokeWidth="1.5" strokeLinecap="round" />
<line
x1="7.5"
y1="7.5"
x2="10.5"
y2="10.5"
stroke={fieldPlaceholder}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
@ -400,17 +433,16 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
borderBottom: `5px solid ${annotationColor}`,
}}
/>
<div style={{ width: '1px', height: '10px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<span
className="font-mono text-xs font-semibold"
style={{ color: annotationColor }}
>
Clear Button
</span>
<span
className="font-mono"
style={{ fontSize: '9px', color: annotationColor }}
>
<span className="font-mono" style={{ fontSize: '9px', color: annotationColor }}>
(Optional)
</span>
</div>
@ -422,20 +454,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* ── 섹션 3: Guideline ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-10"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-10" style={{ color: t.textPrimary }}>
Guideline
</h2>
{/* 3-1. Container */}
<div className="mb-12">
<div className="flex items-baseline gap-3 mb-2">
<span
className="font-mono text-lg font-bold"
style={{ color: annotationColor }}
>
<span className="font-mono text-lg font-bold" style={{ color: annotationColor }}>
1
</span>
<h3 className="text-xl font-bold" style={{ color: t.textPrimary }}>
@ -448,14 +474,25 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex items-center gap-8">
{/* 컨테이너 박스 + 치수선 */}
<div className="relative" style={{ marginLeft: '20px', marginTop: '20px', marginBottom: '24px' }}>
<div
className="relative"
style={{ marginLeft: '20px', marginTop: '20px', marginBottom: '24px' }}
>
{/* 높이 치수선 (오른쪽) */}
<div
className="absolute flex flex-col items-center"
style={{ right: '-32px', top: 0, bottom: 0, justifyContent: 'center' }}
>
<div style={{ width: '1px', flex: 1, backgroundColor: '#ef4444' }} />
<span className="font-mono" style={{ fontSize: '9px', color: '#ef4444', writingMode: 'vertical-rl', padding: '2px 0' }}>
<span
className="font-mono"
style={{
fontSize: '9px',
color: '#ef4444',
writingMode: 'vertical-rl',
padding: '2px 0',
}}
>
44px
</span>
<div style={{ width: '1px', flex: 1, backgroundColor: '#ef4444' }} />
@ -567,10 +604,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<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-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
</span>
<div
@ -591,8 +625,7 @@ 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 style={{ color: '#ef4444' }}>*</span>
<span style={{ color: '#ef4444' }}>*</span>
</span>
<div
className="flex items-center rounded-md px-3"
@ -784,33 +817,18 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
{/* ── 섹션 4: State (Input Field) ── */}
<div
className="pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<div className="pt-12 border-t border-solid" style={{ borderColor: dividerColor }}>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
State
</h2>
<div
className="rounded-xl p-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col gap-5">
{stateRows.map((row) => (
<div
key={row.state}
className="flex items-center gap-8"
>
<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-xs" style={{ color: t.textSecondary }}>
State
</span>
<span
@ -839,13 +857,8 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
fontSize: '14px',
}}
>
{row.showSparkle && (
<span style={{ fontSize: '16px' }}></span>
)}
<span
className="flex-1"
style={{ color: row.style.placeholderColor }}
>
{row.showSparkle && <span style={{ fontSize: '16px' }}></span>}
<span className="flex-1" style={{ color: row.style.placeholderColor }}>
{row.placeholder}
{row.hasCursor && (
<span
@ -885,22 +898,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* ── Text Area Anatomy ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
Anatomy
</h2>
<div
className="rounded-xl p-10 mb-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-10 mb-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col items-center gap-12">
{/* TextArea 구조 분해도 */}
<div className="relative flex flex-col items-center" style={{ width: '480px' }}>
{/* 상단 주석 라벨: Input Area, Character Counter */}
<div className="flex items-end justify-between w-full mb-1 px-2">
{/* Input Area label */}
@ -911,7 +916,9 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
Input Area
</span>
<div style={{ width: '1px', height: '18px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '18px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -931,7 +938,9 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
Placeholder
</span>
<div style={{ width: '1px', height: '18px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '18px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -951,13 +960,12 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
>
Character Counter
</span>
<span
className="font-mono"
style={{ fontSize: '9px', color: annotationColor }}
>
<span className="font-mono" style={{ fontSize: '9px', color: annotationColor }}>
(Optional)
</span>
<div style={{ width: '1px', height: '10px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<div
style={{
width: 0,
@ -991,9 +999,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
{/* 우하단: 문자 수 카운터 + resize 핸들 */}
<div
className="absolute bottom-2 right-2 flex items-center gap-2"
>
<div className="absolute bottom-2 right-2 flex items-center gap-2">
<span
className="font-mono"
style={{ fontSize: '11px', color: fieldPlaceholder }}
@ -1065,7 +1071,9 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
borderBottom: `5px solid ${annotationColor}`,
}}
/>
<div style={{ width: '1px', height: '10px', backgroundColor: annotationColor }} />
<div
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
/>
<span
className="font-mono text-xs font-semibold"
style={{ color: annotationColor }}
@ -1081,10 +1089,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
{/* ── Text Area Guideline ── */}
<div className="mb-16">
<h2
className="text-2xl font-bold mb-10"
style={{ color: t.textPrimary }}
>
<h2 className="text-2xl font-bold mb-10" style={{ color: t.textPrimary }}>
Guideline
</h2>
@ -1099,18 +1104,30 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</h3>
</div>
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
. 112px이며 .
. 112px이며
.
</p>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex items-start gap-8">
<div className="relative" style={{ marginLeft: '20px', marginTop: '20px', marginBottom: '24px' }}>
<div
className="relative"
style={{ marginLeft: '20px', marginTop: '20px', marginBottom: '24px' }}
>
{/* 높이 치수선 (오른쪽) */}
<div
className="absolute flex flex-col items-center"
style={{ right: '-36px', top: 0, bottom: 0, justifyContent: 'center' }}
>
<div style={{ width: '1px', flex: 1, backgroundColor: '#ef4444' }} />
<span className="font-mono" style={{ fontSize: '9px', color: '#ef4444', writingMode: 'vertical-rl', padding: '2px 0' }}>
<span
className="font-mono"
style={{
fontSize: '9px',
color: '#ef4444',
writingMode: 'vertical-rl',
padding: '2px 0',
}}
>
112px
</span>
<div style={{ width: '1px', flex: 1, backgroundColor: '#ef4444' }} />
@ -1226,10 +1243,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
<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-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
</span>
<div
@ -1251,8 +1265,7 @@ 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 style={{ color: '#ef4444' }}>*</span>
<span style={{ color: '#ef4444' }}>*</span>
</span>
<div
className="rounded-md p-3"
@ -1456,33 +1469,18 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
{/* ── Text Area State ── */}
<div
className="pt-12 border-t border-solid"
style={{ borderColor: dividerColor }}
>
<h2
className="text-2xl font-bold mb-8"
style={{ color: t.textPrimary }}
>
<div className="pt-12 border-t border-solid" style={{ borderColor: dividerColor }}>
<h2 className="text-2xl font-bold mb-8" style={{ color: t.textPrimary }}>
State
</h2>
<div
className="rounded-xl p-8"
style={{ backgroundColor: sectionCardBg }}
>
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
<div className="flex flex-col gap-5">
{stateRows.map((row) => (
<div
key={`ta-${row.state}`}
className="flex items-start gap-8"
>
<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-xs" style={{ color: t.textSecondary }}>
State
</span>
<span
@ -1513,9 +1511,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
}}
>
<div className="flex items-start gap-2">
{row.showSparkle && (
<span style={{ fontSize: '16px' }}></span>
)}
{row.showSparkle && <span style={{ fontSize: '16px' }}></span>}
<span style={{ color: row.style.placeholderColor }}>
{row.placeholder}
{row.hasCursor && (
@ -1537,7 +1533,6 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
</div>
</div>
</div>
</div>
</div>
);

파일 보기

@ -4,28 +4,287 @@ import type { DesignTheme } from './designTheme';
// ---------- 데이터 타입 ----------
interface FontFamily {
name: string;
className: string;
stack: string;
usage: string;
sampleText: string;
// interface FontFamily {
// name: string;
// className: string;
// stack: string;
// usage: string;
// sampleText: string;
// }
interface TypeScaleItem {
token: string;
cssVar: string;
tailwind: string;
size: string;
px: string;
weight: number;
lineHeight: number;
letterSpacing: string;
role: string;
sample: string;
}
interface TypeCategory {
name: string;
description: string;
items: TypeScaleItem[];
}
// ---------- Font Family 데이터 ----------
const FONT_FAMILIES: FontFamily[] = [
// const FONT_FAMILIES: FontFamily[] = [
// {
// name: 'PretendardGOV',
// className: 'font-sans / font-korean / font-mono',
// stack: "'PretendardGOV', sans-serif",
// usage: '프로젝트 전체 통합 폰트. 한국어/영문 UI 텍스트, 수치 데이터, 좌표 표시 등 모든 콘텐츠에 사용됩니다.',
// sampleText: '해양 방제 운영 지원 시스템 WING-OPS 35.1284° N',
// },
// ];
// ---------- Typography Categories ----------
const TYPE_CATEGORIES: TypeCategory[] = [
{
name: 'PretendardGOV',
className: 'font-sans / font-korean / font-mono',
stack: "'PretendardGOV', sans-serif",
usage: '프로젝트 전체 통합 폰트. 한국어/영문 UI 텍스트, 수치 데이터, 좌표 표시 등 모든 콘텐츠에 사용됩니다.',
sampleText: '해양 방제 운영 지원 시스템 WING-OPS 35.1284° N',
name: 'Display',
description:
'화면에서 가장 큰 텍스트. 배너, 마케팅, 랜딩 영역에 사용됩니다. 자유롭게 변형 가능하나 과도한 크기는 지양합니다.',
items: [
{
token: 'Display 1',
cssVar: '--font-size-display-1',
tailwind: 'text-display-1',
size: '3.75rem',
px: '60px',
weight: 700,
lineHeight: 1.3,
letterSpacing: '0.06em',
role: '최상위 헤드라인',
sample: '해양 방제 운영 지원',
},
{
token: 'Display 2',
cssVar: '--font-size-display-2',
tailwind: 'text-display-2',
size: '2.5rem',
px: '40px',
weight: 700,
lineHeight: 1.3,
letterSpacing: '0.06em',
role: '메인 타이틀',
sample: '확산 예측 시뮬레이션',
},
{
token: 'Display 3',
cssVar: '--font-size-display-3',
tailwind: 'text-display-3',
size: '2.25rem',
px: '36px',
weight: 500,
lineHeight: 1.4,
letterSpacing: '0.06em',
role: '강조형 헤드라인',
sample: '오염 종합 상황판',
},
],
},
{
name: 'Heading',
description:
'페이지 수준의 제목과 모듈 수준의 역할 강조에 사용합니다. 시각적 계층 구조를 수립합니다.',
items: [
{
token: 'Heading 1',
cssVar: '--font-size-heading-1',
tailwind: 'text-heading-1',
size: '2rem',
px: '32px',
weight: 700,
lineHeight: 1.4,
letterSpacing: '0.02em',
role: '페이지 상위 제목',
sample: '사고 현황 분석',
},
{
token: 'Heading 2',
cssVar: '--font-size-heading-2',
tailwind: 'text-heading-2',
size: '1.5rem',
px: '24px',
weight: 700,
lineHeight: 1.4,
letterSpacing: '0.02em',
role: '하위 제목',
sample: '유출유 풍화 상태',
},
{
token: 'Heading 3',
cssVar: '--font-size-heading-3',
tailwind: 'text-heading-3',
size: '1.375rem',
px: '22px',
weight: 500,
lineHeight: 1.4,
letterSpacing: '0.02em',
role: '소제목, 그룹 제목',
sample: '기상 데이터 요약',
},
],
},
{
name: 'Body',
description: '제목과 특수 역할을 제외한 본문/콘텐츠 텍스트에 적용됩니다.',
items: [
{
token: 'Body 1',
cssVar: '--font-size-body-1',
tailwind: 'text-body-1',
size: '0.875rem',
px: '14px',
weight: 400,
lineHeight: 1.6,
letterSpacing: '0em',
role: '기본 본문',
sample: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.',
},
{
token: 'Body 2',
cssVar: '--font-size-body-2',
tailwind: 'text-body-2',
size: '0.8125rem',
px: '13px',
weight: 400,
lineHeight: 1.6,
letterSpacing: '0em',
role: '보조 본문',
sample: '최근 업데이트: 2026-03-24 09:00 KST',
},
{
token: 'Caption',
cssVar: '--font-size-caption',
tailwind: 'text-caption',
size: '0.6875rem',
px: '11px',
weight: 400,
lineHeight: 1.5,
letterSpacing: '0em',
role: '캡션, 메타 정보',
sample: 'v2.1 | 해양환경공단',
},
],
},
{
name: 'Navigation',
description:
'사이트 내 길잡이 역할을 하는 컴포넌트(패널 제목, 사이드바 헤더, 탭 레이블 등)에 사용되는 특수 역할 토큰입니다.',
items: [
{
token: 'Title 1',
cssVar: '--font-size-title-1',
tailwind: 'text-title-1',
size: '1.125rem',
px: '18px',
weight: 700,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '컴포넌트 제목',
sample: '확산 예측 시뮬레이션',
},
{
token: 'Title 2',
cssVar: '--font-size-title-2',
tailwind: 'text-title-2',
size: '1rem',
px: '16px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '패널 제목',
sample: '기본 정보 입력',
},
{
token: 'Title 3',
cssVar: '--font-size-title-3',
tailwind: 'text-title-3',
size: '0.875rem',
px: '14px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '서브 네비게이션',
sample: '예측 결과 보기',
},
{
token: 'Title 4',
cssVar: '--font-size-title-4',
tailwind: 'text-title-4',
size: '0.8125rem',
px: '13px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '탭 버튼',
sample: '확산 예측',
},
{
token: 'Title 5',
cssVar: '--font-size-title-5',
tailwind: 'text-title-5',
size: '0.75rem',
px: '12px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '메뉴 항목',
sample: '거리 재기',
},
{
token: 'Title 6',
cssVar: '--font-size-title-6',
tailwind: 'text-title-6',
size: '0.6875rem',
px: '11px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.02em',
role: '소형 네비게이션',
sample: '유출량 (kL)',
},
],
},
{
name: 'Label',
description: '컴포넌트의 레이블, 플레이스홀더, 버튼 텍스트 등에 사용되는 특수 역할 토큰입니다.',
items: [
{
token: 'Label 1',
cssVar: '--font-size-label-1',
tailwind: 'text-label-1',
size: '0.75rem',
px: '12px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.04em',
role: '레이블, 버튼',
sample: '시뮬레이션 실행',
},
{
token: 'Label 2',
cssVar: '--font-size-label-2',
tailwind: 'text-label-2',
size: '0.6875rem',
px: '11px',
weight: 500,
lineHeight: 1.5,
letterSpacing: '0.04em',
role: '소형 레이블, 데이터',
sample: '유출량 (kL)',
},
],
},
];
// ---------- Props ----------
interface TypographyContentProps {
@ -39,80 +298,70 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
const isDark = t.mode === 'dark';
return (
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
{/* ── 섹션 1: 헤더 + 개요 ── */}
<div
className="w-full border-b border-solid pb-8 flex flex-col gap-6"
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
>
<div className="flex flex-col gap-2">
<h1
className="font-sans text-3xl leading-9 font-bold"
style={{ color: t.textPrimary }}
>
<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 }}
>
WING-OPS . , , .
<p className="font-korean text-sm 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-sm font-bold" style={{ color: t.textPrimary }}>
</h3>
<ul
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
style={{ color: t.textSecondary }}
>
<li> , , .</li>
<li>
Display, Heading, Body, Navigation, Label의 5
.
</li>
<li> , , , .</li>
<li> .</li>
</ul>
</div>
</div>
{/* ── 글꼴 (Font Family) ── */}
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<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 }}
>
, . UI에 .
</p>
</div>
<div className="w-full flex flex-col gap-8">
<div className="flex flex-col gap-2">
<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 }}>
, .
UI에 .
</p>
</div>
{/* body 기본 폰트 스택 코드 블록 */}
<div
className="rounded-lg border border-solid px-5 py-4 overflow-x-auto"
style={{
backgroundColor: isDark ? '#0f1524' : '#f1f5f9',
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0',
}}
>
<pre
className="font-mono text-sm leading-6"
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
>
<span style={{ color: t.textAccent }}>font-family</span>
{`: 'PretendardGOV', -apple-system, BlinkMacSystemFont,\n 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard,\n Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic',\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',\n sans-serif;`}
</pre>
</div>
{/* body 기본 폰트 스택 코드 블록 */}
<div
className="rounded-lg border border-solid px-5 py-4 overflow-x-auto"
style={{
backgroundColor: isDark ? '#0f1524' : '#f1f5f9',
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0',
}}
>
<pre
className="font-mono text-sm leading-6"
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
>
<span style={{ color: t.textAccent }}>font-family</span>
{`: 'PretendardGOV', -apple-system, BlinkMacSystemFont,\n 'Apple SD Gothic Neo', 'Pretendard Variable', Pretendard,\n Roboto, 'Noto Sans KR', 'Segoe UI', 'Malgun Gothic',\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',\n sans-serif;`}
</pre>
</div>
{/* 폰트 카드 */}
<div className="flex flex-col gap-6">
{/* 폰트 카드 */}
{/* <div className="flex flex-col gap-6">
{FONT_FAMILIES.map((font) => (
<div
key={font.name}
@ -163,151 +412,378 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
</div>
</div>
))}
</div>
</div>
</div> */}
</div>
<div className="w-full flex flex-col gap-12">
{/* Font Size */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}> </h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
0.6875rem(11px). rem .
{/* ── 카테고리별 타입 스케일 ── */}
<div className="w-full flex flex-col gap-16">
<div className="flex flex-col gap-2">
<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 }}>
Display, Heading, Body, Navigation, Label의 5
.
</p>
</div>
{TYPE_CATEGORIES.map((category) => (
<div key={category.name} className="flex flex-col gap-6">
{/* 카테고리 헤더 */}
<div className="flex flex-col gap-1">
<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 }}>
{category.description}
</p>
</div>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}>
<div className="grid grid-cols-[1fr_100px_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
<span>Token</span><span>rem</span><span>px</span><span></span>
{/* 토큰 테이블 */}
<div
className="rounded-lg overflow-hidden"
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"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Style</span>
<span>Size</span>
<span>Weight</span>
<span>Line-H</span>
<span>Spacing</span>
<span>Tailwind</span>
</div>
{[
{ token: '--font-size-display-1', rem: '3.75rem', px: '60px', desc: '최상위 헤드라인' },
{ token: '--font-size-display-2', rem: '2.5rem', px: '40px', desc: '메인 타이틀' },
{ token: '--font-size-display-3', rem: '2.25rem', px: '36px', desc: '강조형 헤드라인' },
{ token: '--font-size-heading-1', rem: '2rem', px: '32px', desc: '페이지 상위 제목' },
{ token: '--font-size-heading-2', rem: '1.5rem', px: '24px', desc: '하위 제목' },
{ token: '--font-size-heading-3', rem: '1.375rem', px: '22px', desc: '소제목, 그룹 제목' },
{ token: '--font-size-title-1', rem: '1.125rem', px: '18px', desc: '컴포넌트 제목' },
{ token: '--font-size-title-2', rem: '1rem', px: '16px', desc: '패널 제목' },
{ token: '--font-size-body-1', rem: '0.875rem', px: '14px', desc: '기본 본문' },
{ token: '--font-size-body-2', rem: '0.8125rem', px: '13px', desc: '보조 본문' },
{ token: '--font-size-label-1', rem: '0.75rem', px: '12px', desc: '레이블, 버튼' },
{ token: '--font-size-label-2', rem: '0.6875rem', px: '11px', desc: '소형 레이블, 데이터' },
{ token: '--font-size-caption', rem: '0.6875rem', px: '11px', desc: '캡션, 메타 정보' },
].map((row) => (
<div key={row.token} className="grid grid-cols-[1fr_100px_80px_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.rem}</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.px}</span>
<span className="text-xs" style={{ color: t.textSecondary }}>{row.desc}</span>
{category.items.map((row) => (
<div
key={row.token}
className="grid grid-cols-[1fr_80px_80px_80px_90px_140px] gap-2 items-center px-4 py-3"
style={{
borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{row.px}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{row.weight}
</span>
<span
className="font-mono text-xs"
style={{ color: t.textMuted }}
>{`${(row.lineHeight * 100).toFixed(0)}%`}</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
{row.letterSpacing}
</span>
<span
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
style={{
color: t.textAccent,
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.25)',
}}
>
{row.tailwind}
</span>
</div>
))}
</div>
</div>
{/* Font Weight */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}> </h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
Regular(400), Bold(700). Medium(500) , Thin(300) .
</p>
</div>
<div className="rounded-lg overflow-hidden" 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"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
<span>Token</span><span>Value</span><span>Name</span><span>Preview</span>
</div>
{[
{ token: '--font-weight-thin', value: '300', name: 'Thin', preview: '해양 방제 운영 WING-OPS' },
{ token: '--font-weight-regular', value: '400', name: 'Regular', preview: '해양 방제 운영 WING-OPS' },
{ token: '--font-weight-medium', value: '500', name: 'Medium', preview: '해양 방제 운영 WING-OPS' },
{ token: '--font-weight-bold', value: '700', name: 'Bold', preview: '해양 방제 운영 WING-OPS' },
].map((row) => (
<div key={row.token} className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.value}</span>
<span className="text-xs" style={{ color: t.textSecondary }}>{row.name}</span>
<span className="font-korean text-sm" style={{ color: t.textPrimary, fontWeight: Number(row.value) }}>{row.preview}</span>
</div>
))}
</div>
</div>
{/* Line Height */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}> </h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
(1.3), (1.6). .
</p>
</div>
<div className="rounded-lg overflow-hidden" 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"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9', color: t.textMuted }}>
<span>Token</span><span>Value</span><span>Percentage</span><span></span>
</div>
{[
{ token: '--line-height-tight', value: '1.3', pct: '130%', desc: 'Display (대형 텍스트)' },
{ token: '--line-height-snug', value: '1.4', pct: '140%', desc: 'Heading' },
{ token: '--line-height-normal', value: '1.5', pct: '150%', desc: 'Title, Label, Caption' },
{ token: '--line-height-relaxed', value: '1.6', pct: '160%', desc: 'Body (본문)' },
].map((row) => (
<div key={row.token} className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 items-center px-4 py-3"
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`, backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa' }}>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.token}</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>{row.value}</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>{row.pct}</span>
<span className="text-xs" style={{ color: t.textSecondary }}>{row.desc}</span>
</div>
))}
</div>
</div>
{/* Type Scale */}
<div className="flex flex-col gap-4" style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}`, paddingTop: '3rem' }}>
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}> </h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
Display, Heading, Title, Body, Label, Caption으로 6 .
</p>
</div>
{/* 실물 프리뷰 */}
<div className="flex flex-col gap-3">
{[
{ scale: 'Display 1', size: '3.75rem', weight: 700, lh: 1.3, role: '최상위 헤드라인', sample: '해양 방제 운영 지원' },
{ scale: 'Display 2', size: '2.5rem', weight: 700, lh: 1.3, role: '메인 타이틀', sample: '확산 예측 시뮬레이션' },
{ scale: 'Display 3', size: '2.25rem', weight: 500, lh: 1.4, role: '강조형 헤드라인', sample: '오염 종합 상황판' },
{ scale: 'Heading 1', size: '2rem', weight: 700, lh: 1.4, role: '페이지 상위 제목', sample: '사고 현황 분석' },
{ scale: 'Heading 2', size: '1.5rem', weight: 700, lh: 1.4, role: '하위 제목', sample: '유출유 풍화 상태' },
{ scale: 'Heading 3', size: '1.375rem', weight: 500, lh: 1.4, role: '소제목, 그룹 제목', sample: '기상 데이터 요약' },
{ scale: 'Title 1', size: '1.125rem', weight: 700, lh: 1.5, role: '컴포넌트 제목', sample: '확산 예측 시뮬레이션' },
{ scale: 'Title 2', size: '1rem', weight: 500, lh: 1.5, role: '패널 제목', sample: '기본 정보 입력' },
{ scale: 'Body 1', size: '0.875rem', weight: 400, lh: 1.6, role: '기본 본문', sample: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.' },
{ scale: 'Body 2', size: '0.8125rem', weight: 400, lh: 1.6, role: '보조 본문', sample: '최근 업데이트: 2026-03-24 09:00 KST' },
{ scale: 'Label 1', size: '0.75rem', weight: 500, lh: 1.5, role: '레이블, 버튼', sample: '시뮬레이션 실행' },
{ scale: 'Label 2', size: '0.6875rem', weight: 500, lh: 1.5, role: '소형 레이블', sample: '유출량 (kL)' },
{ scale: 'Caption', size: '0.6875rem', weight: 400, lh: 1.5, role: '캡션, 메타', sample: 'v2.1 | 해양환경공단' },
].map((row) => (
<div key={row.scale} className="rounded-lg px-5 py-4 flex items-center gap-6"
style={{ backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa', border: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}` }}>
{category.items.map((row) => (
<div
key={row.token}
className="rounded-lg px-5 py-4 flex items-center gap-6"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
border: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`,
}}
>
<div className="shrink-0 w-[100px]">
<div className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>{row.scale}</div>
<div className="font-mono text-[10px] mt-0.5" style={{ color: t.textMuted }}>{row.size} · {row.weight} · {row.lh}</div>
<div
className="font-mono text-xs font-semibold"
style={{ color: t.textAccent }}
>
{row.token}
</div>
<div className="font-mono text-[10px] mt-0.5" style={{ color: t.textMuted }}>
{row.size} · {row.weight} · {row.lineHeight}
</div>
</div>
<div className="flex-1">
<span className="font-korean" style={{ fontSize: row.size, fontWeight: row.weight, lineHeight: row.lh, color: t.textPrimary }}>
<span
className="font-korean"
style={{
fontSize: row.size,
fontWeight: row.weight,
lineHeight: row.lineHeight,
letterSpacing: row.letterSpacing,
color: t.textPrimary,
}}
>
{row.sample}
</span>
</div>
<div className="shrink-0 text-xs font-korean" style={{ color: t.textMuted }}>{row.role}</div>
<div className="shrink-0 text-xs font-korean" style={{ color: t.textMuted }}>
{row.role}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* ── 기본 토큰 참조 ── */}
<div className="w-full flex flex-col gap-12">
{/* Font Weight */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
Regular(400), Bold(700). Medium(500) , Thin(300)
.
</p>
</div>
<div
className="rounded-lg overflow-hidden"
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"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Token</span>
<span>Value</span>
<span>Name</span>
<span>Preview</span>
</div>
{[
{
token: '--font-weight-thin',
value: '300',
name: 'Thin',
preview: '해양 방제 운영 WING-OPS',
},
{
token: '--font-weight-regular',
value: '400',
name: 'Regular',
preview: '해양 방제 운영 WING-OPS',
},
{
token: '--font-weight-medium',
value: '500',
name: 'Medium',
preview: '해양 방제 운영 WING-OPS',
},
{
token: '--font-weight-bold',
value: '700',
name: 'Bold',
preview: '해양 방제 운영 WING-OPS',
},
].map((row) => (
<div
key={row.token}
className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 items-center px-4 py-3"
style={{
borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
{row.name}
</span>
<span
className="font-korean text-sm"
style={{ color: t.textPrimary, fontWeight: Number(row.value) }}
>
{row.preview}
</span>
</div>
))}
</div>
</div>
{/* Line Height */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
(1.3), (1.6).
.
</p>
</div>
<div
className="rounded-lg overflow-hidden"
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"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Token</span>
<span>Value</span>
<span>Percentage</span>
<span></span>
</div>
{[
{
token: '--line-height-tight',
value: '1.3',
pct: '130%',
desc: 'Display (대형 텍스트)',
},
{
token: '--line-height-snug',
value: '1.4',
pct: '140%',
desc: 'Heading, Navigation',
},
{ token: '--line-height-normal', value: '1.5', pct: '150%', desc: 'Label, Caption' },
{ token: '--line-height-relaxed', value: '1.6', pct: '160%', desc: 'Body (본문)' },
].map((row) => (
<div
key={row.token}
className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 items-center px-4 py-3"
style={{
borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
{row.pct}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
{row.desc}
</span>
</div>
))}
</div>
</div>
{/* Letter Spacing */}
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
</h2>
<p className="text-sm" style={{ color: t.textSecondary }}>
. Display는 , Body는 .
</p>
</div>
<div
className="rounded-lg overflow-hidden"
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"
style={{
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
color: t.textMuted,
}}
>
<span>Token</span>
<span>Value</span>
<span>Tailwind</span>
<span></span>
</div>
{[
{
token: '--letter-spacing-display',
value: '0.06em',
tw: 'tracking-display',
category: 'Display',
},
{
token: '--letter-spacing-heading',
value: '0.02em',
tw: 'tracking-heading',
category: 'Heading',
},
{
token: '--letter-spacing-body',
value: '0em',
tw: 'tracking-body',
category: 'Body',
},
{
token: '--letter-spacing-navigation',
value: '0.02em',
tw: 'tracking-navigation',
category: 'Navigation',
},
{
token: '--letter-spacing-label',
value: '0.04em',
tw: 'tracking-label',
category: 'Label',
},
].map((row) => (
<div
key={row.token}
className="grid grid-cols-[1fr_80px_140px_1fr] gap-2 items-center px-4 py-3"
style={{
borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.10)' : '#e5e7eb'}`,
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
}}
>
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
{row.token}
</span>
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
{row.value}
</span>
<span
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
style={{
color: t.textAccent,
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.25)',
}}
>
{row.tw}
</span>
<span className="text-xs" style={{ color: t.textSecondary }}>
{row.category}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};

파일 보기

@ -13,35 +13,37 @@ const buttonRows: ButtonRow[] = [
label: '프라이머리 (그라디언트)',
defaultBtn: (
<div
className='rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
className="rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{
background: 'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
background:
'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
}}
>
<div className='text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
hoverBtn: (
<div
className='rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
className="rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{
background: 'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
background:
'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
boxShadow: '0px 0px 12px 0px rgba(6, 182, 212, 0.4)',
}}
>
<div className='text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
disabledBtn: (
<div
className='bg-[#334155] rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
className="bg-[#334155] rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{ opacity: 0.5 }}
>
<div className='text-[#64748b] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="text-[#64748b] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -50,22 +52,22 @@ const buttonRows: ButtonRow[] = [
{
label: '세컨더리 (솔리드)',
defaultBtn: (
<div className='bg-[#1a2236] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[#1a2236] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
hoverBtn: (
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
disabledBtn: (
<div className='bg-[rgba(26,34,54,0.50)] rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[rgba(26,34,54,0.50)] rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -74,22 +76,22 @@ const buttonRows: ButtonRow[] = [
{
label: '아웃라인 (고스트)',
defaultBtn: (
<div className='rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
hoverBtn: (
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
disabledBtn: (
<div className='rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -98,40 +100,32 @@ const buttonRows: ButtonRow[] = [
{
label: 'PDF 액션',
defaultBtn: (
<div className='bg-[rgba(59,130,246,0.08)] rounded-md border border-solid border-[rgba(59,130,246,0.30)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'>
<img
className='shrink-0 relative overflow-visible'
src={pdfFileIcon}
alt='PDF 아이콘'
/>
<div className='text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[rgba(59,130,246,0.08)] rounded-md border border-solid border-[rgba(59,130,246,0.30)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative">
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
PDF
</div>
</div>
),
hoverBtn: (
<div
className='bg-[rgba(59,130,246,0.15)] rounded-md border border-solid border-[rgba(59,130,246,0.50)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'
className="bg-[rgba(59,130,246,0.15)] rounded-md border border-solid border-[rgba(59,130,246,0.50)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative"
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
>
<img
className='shrink-0 relative overflow-visible'
src={pdfFileIcon}
alt='PDF 아이콘'
/>
<div className='text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
PDF
</div>
</div>
),
disabledBtn: (
<div className='bg-[rgba(59,130,246,0.04)] rounded-md border border-solid border-[rgba(59,130,246,0.10)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'>
<div className="bg-[rgba(59,130,246,0.04)] rounded-md border border-solid border-[rgba(59,130,246,0.10)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative">
<img
className='shrink-0 relative overflow-visible'
className="shrink-0 relative overflow-visible"
src={pdfFileDisabledIcon}
alt='PDF 아이콘 (비활성)'
alt="PDF 아이콘 (비활성)"
/>
<div className='text-[rgba(59,130,246,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="text-[rgba(59,130,246,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
PDF
</div>
</div>
@ -140,25 +134,25 @@ const buttonRows: ButtonRow[] = [
{
label: '경고 (삭제)',
defaultBtn: (
<div className='bg-[rgba(239,68,68,0.10)] rounded-md border border-solid border-[rgba(239,68,68,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[rgba(239,68,68,0.10)] rounded-md border border-solid border-[rgba(239,68,68,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
hoverBtn: (
<div
className='bg-[rgba(239,68,68,0.20)] rounded-md border border-solid border-[rgba(239,68,68,0.50)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
className="bg-[rgba(239,68,68,0.20)] rounded-md border border-solid border-[rgba(239,68,68,0.50)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
style={{ boxShadow: '0px 0px 8px 0px rgba(239, 68, 68, 0.15)' }}
>
<div className='text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
),
disabledBtn: (
<div className='bg-[rgba(239,68,68,0.05)] rounded-md border border-solid border-[rgba(239,68,68,0.15)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[rgba(239,68,68,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
<div className="bg-[rgba(239,68,68,0.05)] rounded-md border border-solid border-[rgba(239,68,68,0.15)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
<div className="text-[rgba(239,68,68,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -168,12 +162,12 @@ const buttonRows: ButtonRow[] = [
export const ButtonCatalogSection = () => {
return (
<div className='bg-[#1a2236] rounded-[10px] border border-solid border-[#1e2a42] flex flex-col gap-0 items-start justify-start overflow-hidden w-full'>
<div className="bg-[#1a2236] rounded-[10px] border border-solid border-[#1e2a42] flex flex-col gap-0 items-start justify-start overflow-hidden w-full">
{/* 카드 헤더 */}
<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="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-xs leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
인터페이스: 버튼
@ -181,17 +175,17 @@ export const ButtonCatalogSection = () => {
</div>
{/* 테이블 본문 */}
<div className='p-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative overflow-hidden'>
<div className='flex flex-col items-start justify-start self-stretch shrink-0 relative'>
<div className="p-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative overflow-hidden">
<div className="flex flex-col items-start justify-start self-stretch shrink-0 relative">
{/* 헤더 행 */}
<div className='border-b border-solid border-[#1e2a42] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative'>
<div className="border-b border-solid border-[#1e2a42] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative">
{['버튼 유형', '기본 상태', '호버 상태', '비활성 상태'].map((header) => (
<div
key={header}
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'
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-xs font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '-0.55px' }}
>
{header}
@ -202,34 +196,34 @@ export const ButtonCatalogSection = () => {
{/* 데이터 행 */}
<div
className='flex flex-col items-start justify-start self-stretch shrink-0 relative'
className="flex flex-col items-start justify-start self-stretch shrink-0 relative"
style={{ margin: '-1px 0 0 0' }}
>
{buttonRows.map((row, index) => (
<div
key={row.label}
className='border-t border-solid border-[rgba(30,42,66,0.50)] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative'
className="border-t border-solid border-[rgba(30,42,66,0.50)] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative"
style={index === 0 ? { borderTopColor: 'transparent' } : { margin: '-1px 0 0 0' }}
>
{/* 버튼 유형 레이블 */}
<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="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">
{row.label}
</div>
</div>
{/* 기본 상태 */}
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
<div className="pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
{row.defaultBtn}
</div>
{/* 호버 상태 */}
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
<div className="pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
{row.hoverBtn}
</div>
{/* 비활성 상태 */}
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
<div className="pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
{row.disabledBtn}
</div>
</div>

파일 보기

@ -19,20 +19,20 @@ const logisticsItems: LogisticsItem[] = [
export const CardSection = () => {
return (
<div
className='grid gap-6 w-full'
className="grid gap-6 w-full"
style={{
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
}}
>
{/* col 3: 활성 물류 현황 카드 */}
<div
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col gap-6 items-start justify-start relative'
className="bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col gap-6 items-start justify-start relative"
style={{ gridColumn: '3 / span 1', gridRow: '1 / span 1' }}
>
{/* 카드 헤더 */}
<div className='border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className="border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className='text-[#64748b] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start'
className="text-[#64748b] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
@ -40,27 +40,27 @@ export const CardSection = () => {
</div>
{/* 물류 아이템 목록 */}
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
{logisticsItems.map((item, index) => (
<div
key={index}
className='flex flex-row gap-4 items-center justify-start self-stretch shrink-0 relative'
className="flex flex-row gap-4 items-center justify-start self-stretch shrink-0 relative"
>
<div className='bg-[#1e293b] rounded-md flex flex-row gap-0 items-center justify-center shrink-0 w-10 h-10 relative'>
<div className="bg-[#1e293b] rounded-md flex flex-row gap-0 items-center justify-center shrink-0 w-10 h-10 relative">
<img
className='shrink-0 relative overflow-visible'
className="shrink-0 relative overflow-visible"
src={item.icon}
alt={item.label}
/>
</div>
<div className='flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
<div className='flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className='text-[#dfe2f3] text-left font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-start'>
<div className="flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#dfe2f3] text-left font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-start">
{item.label}
</div>
</div>
<div className='flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className='text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start'>
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
{item.progress}
</div>
</div>
@ -70,15 +70,15 @@ export const CardSection = () => {
</div>
{/* 대응팀 배치 버튼 */}
<div className='pt-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className="pt-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className='rounded-md pt-2 pb-2 flex flex-col gap-0 items-center justify-center self-stretch shrink-0 relative'
className="rounded-md pt-2 pb-2 flex flex-col gap-0 items-center justify-center self-stretch shrink-0 relative"
style={{
background:
'linear-gradient(97.29deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
}}
>
<div className='text-white text-center font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-center'>
<div className="text-white text-center font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-center">
</div>
</div>
@ -87,66 +87,66 @@ export const CardSection = () => {
{/* col 1-2 span: 실시간 텔레메트리 카드 */}
<div
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col items-start justify-between min-h-[240px] relative overflow-hidden'
className="bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col items-start justify-between min-h-[240px] relative overflow-hidden"
style={{ gridColumn: '1 / span 2', gridRow: '1 / span 1' }}
>
{/* 배경 파형 (opacity 0.3) */}
<div
className='flex flex-col gap-0 items-start justify-center shrink-0 h-24 absolute right-px left-px bottom-[1.5px]'
className="flex flex-col gap-0 items-start justify-center shrink-0 h-24 absolute right-px left-px bottom-[1.5px]"
style={{ opacity: 0.3 }}
>
<img
className='self-stretch shrink-0 h-24 relative overflow-visible'
className="self-stretch shrink-0 h-24 relative overflow-visible"
src={wingWaveGraph}
alt='wave graph'
alt="wave graph"
/>
</div>
{/* 상단 콘텐츠 */}
<div className='flex flex-col gap-6 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-col gap-6 items-start justify-start self-stretch shrink-0 relative">
{/* 제목 영역 */}
<div className='flex flex-row items-start justify-between self-stretch shrink-0 relative'>
<div className='flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative'>
<div className="flex flex-row items-start justify-between self-stretch shrink-0 relative">
<div className="flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative">
<div
className='text-[#22d3ee] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start'
className="text-[#22d3ee] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
</div>
<div className='text-[#dfe2f3] text-left font-korean text-2xl leading-8 font-medium relative flex items-center justify-start'>
<div className="text-[#dfe2f3] text-left font-korean text-2xl leading-8 font-medium relative flex items-center justify-start">
</div>
</div>
<img
className='shrink-0 w-[13.5px] h-[13.5px] relative overflow-visible'
className="shrink-0 w-[13.5px] h-[13.5px] relative overflow-visible"
src={wingChartBarIcon}
alt='chart bar'
alt="chart bar"
/>
</div>
{/* 속도 수치 */}
<div className='flex flex-row gap-2 justify-start self-stretch shrink-0 relative'>
<div className='text-white text-left font-sans font-bold text-4xl leading-10 relative flex items-center justify-start'>
<div className="flex flex-row gap-2 justify-start self-stretch shrink-0 relative">
<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-sm leading-5 relative flex items-center justify-start">
(knots)
</div>
</div>
</div>
{/* 하단 뱃지 + 버튼 */}
<div className='flex flex-row items-center justify-between self-stretch shrink-0 relative'>
<div className="flex flex-row items-center justify-between self-stretch shrink-0 relative">
{/* 정상 가동중 뱃지 */}
<div className='bg-[rgba(34,197,94,0.10)] rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start shrink-0 relative'>
<div className='text-[#22c55e] text-left font-korean text-[9px] leading-[13.5px] font-medium relative flex items-center justify-start'>
<div className="bg-[rgba(34,197,94,0.10)] rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div className="text-[#22c55e] text-left font-korean text-[9px] leading-[13.5px] font-medium relative flex items-center justify-start">
</div>
</div>
{/* 대응팀 배치 아웃라인 버튼 */}
<div className='rounded-md border border-[#1e2a42] pt-1 pr-3 pb-1 pl-3 flex flex-col gap-0 items-center justify-center shrink-0 relative'>
<div className='text-[#c0c8dc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center'>
<div className="rounded-md border border-[#1e2a42] pt-1 pr-3 pb-1 pl-3 flex flex-col gap-0 items-center justify-center shrink-0 relative">
<div className="text-[#c0c8dc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center">
</div>
</div>

파일 보기

@ -46,22 +46,22 @@ const dataTags: DataTag[] = [
export const IconBadgeSection = () => {
return (
<div
className='grid gap-8 w-full'
className="grid gap-8 w-full"
style={{
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
}}
>
{/* 좌측 카드: 제어 인터페이스 — 아이콘 버튼 */}
<div
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden'
className="bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden"
style={{ gridColumn: '1 / span 1', gridRow: '1 / span 1' }}
>
{/* 카드 헤더 */}
<div className='border-b 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-[#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="border-b 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-[#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-xs leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
컨트롤: 아이콘
@ -70,22 +70,22 @@ export const IconBadgeSection = () => {
</div>
{/* 아이콘 버튼 목록 */}
<div className='p-8 flex flex-row gap-6 items-start justify-evenly self-stretch shrink-0 relative'>
<div className="p-8 flex flex-row gap-6 items-start justify-evenly self-stretch shrink-0 relative">
{iconButtons.map((btn) => (
<div
key={btn.label}
className='flex flex-col gap-3 items-center justify-start self-stretch shrink-0 relative'
className="flex flex-col gap-3 items-center justify-start self-stretch shrink-0 relative"
>
<div className='bg-[#1a2236] rounded-md border border-[#1e2a42] flex flex-row gap-0 items-center justify-center shrink-0 w-9 h-9 relative'>
<div className="bg-[#1a2236] rounded-md border border-[#1e2a42] flex flex-row gap-0 items-center justify-center shrink-0 w-9 h-9 relative">
<img
className='shrink-0 relative overflow-visible'
className="shrink-0 relative overflow-visible"
src={btn.icon}
alt={btn.label}
/>
</div>
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '0.9px' }}
>
{btn.label}
@ -96,8 +96,8 @@ export const IconBadgeSection = () => {
</div>
{/* 카드 푸터 */}
<div className='bg-[rgba(15,23,42,0.30)] pt-4 pr-6 pb-4 pl-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className='text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start'>
<div className="bg-[rgba(15,23,42,0.30)] pt-4 pr-6 pb-4 pl-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
Standard dimensions: 36x36px with radius-md (6px)
</div>
</div>
@ -105,15 +105,15 @@ export const IconBadgeSection = () => {
{/* 우측 카드: 마이크로 컨트롤 — 뱃지 & 태그 */}
<div
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden'
className="bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden"
style={{ gridColumn: '2 / span 1', gridRow: '1 / span 1' }}
>
{/* 카드 헤더 */}
<div className='border-b 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-[#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="border-b 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-[#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-xs leading-4 font-medium uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1.2px' }}
>
컨트롤: 아이콘
@ -122,27 +122,26 @@ export const IconBadgeSection = () => {
</div>
{/* 카드 바디 */}
<div className='p-6 flex flex-col gap-8 items-start justify-start self-stretch shrink-0 relative'>
<div className="p-6 flex flex-col gap-8 items-start justify-start self-stretch shrink-0 relative">
{/* Operational Status 섹션 */}
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
<div className='border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
Operational Status
</div>
</div>
<div className='flex flex-row gap-3 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-row gap-3 items-start justify-start self-stretch shrink-0 relative">
{statusBadges.map((badge) => (
<div
key={badge.label}
className='rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'
className="rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative"
style={{ backgroundColor: badge.bg }}
>
<div
className='text-left font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-start'
className="text-left font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-start"
style={{ color: badge.color }}
>
{badge.label}
@ -153,29 +152,29 @@ export const IconBadgeSection = () => {
</div>
{/* Data Classification 섹션 */}
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
<div className='border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
<div
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
style={{ letterSpacing: '1px' }}
>
Data Classification
</div>
</div>
<div className='flex flex-row gap-4 items-start justify-start self-stretch shrink-0 relative'>
<div className="flex flex-row gap-4 items-start justify-start self-stretch shrink-0 relative">
{dataTags.map((tag) => (
<div
key={tag.label}
className='rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-row gap-2 items-center justify-start self-stretch shrink-0 relative'
className="rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-row gap-2 items-center justify-start self-stretch shrink-0 relative"
style={{ backgroundColor: tag.bg }}
>
<div
className='rounded-xl shrink-0 w-1.5 h-1.5 relative'
className="rounded-xl shrink-0 w-1.5 h-1.5 relative"
style={{ backgroundColor: tag.dotColor }}
></div>
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
<div
className='text-left font-sans font-bold text-[10px] leading-[15px] relative flex items-center justify-start'
className="text-left font-sans font-bold text-[10px] leading-[15px] relative flex items-center justify-start"
style={{ color: tag.color }}
>
{tag.label}
@ -185,7 +184,6 @@ export const IconBadgeSection = () => {
))}
</div>
</div>
</div>
</div>
</div>

파일 보기

@ -214,36 +214,102 @@ export const DARK_THEME: DesignTheme = {
swatchBorderHover: 'rgba(76,215,246,0.20)',
bgTokens: [
{ bg: '#0a0e1a', token: 'bg-0', hex: '#0a0e1a', desc: 'Primary page canvas, deepest immersion layer.' },
{ bg: '#0f1524', token: 'bg-1', hex: '#0f1524', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
{ bg: '#121929', token: 'bg-2', hex: '#121929', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
{ bg: '#1a2236', token: 'bg-3', hex: '#1a2236', desc: 'Surface Level 3: Elevated cards and floating elements.' },
{ bg: '#1e2844', token: 'bg-hover', hex: '#1e2844', desc: 'Interactive states, list item highlighting.', isHover: true },
{
bg: '#0a0e1a',
token: 'bg-0',
hex: '#0a0e1a',
desc: 'Primary page canvas, deepest immersion layer.',
},
{
bg: '#0f1524',
token: 'bg-1',
hex: '#0f1524',
desc: 'Surface Level 1: Sidebar containers and utility panels.',
},
{
bg: '#121929',
token: 'bg-2',
hex: '#121929',
desc: 'Surface Level 2: Table headers and subtle sectional shifts.',
},
{
bg: '#1a2236',
token: 'bg-3',
hex: '#1a2236',
desc: 'Surface Level 3: Elevated cards and floating elements.',
},
{
bg: '#1e2844',
token: 'bg-hover',
hex: '#1e2844',
desc: 'Interactive states, list item highlighting.',
isHover: true,
},
],
accentTokens: [
{
color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
color: '#06b6d4',
name: 'Cyan Accent',
token: 'primary',
badge: 'Primary Action',
glow: '0px 0px 15px 0px rgba(6,182,212,0.4)',
badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
badgeBg: 'rgba(6,182,212,0.10)',
badgeBorder: 'rgba(6,182,212,0.25)',
badgeText: '#06b6d4',
},
{
color: '#3b82f6', name: 'Blue Accent', token: 'secondary', badge: 'Information',
color: '#3b82f6',
name: 'Blue Accent',
token: 'secondary',
badge: 'Information',
glow: '0px 0px 15px 0px rgba(59,130,246,0.3)',
badgeBg: 'rgba(59,130,246,0.10)', badgeBorder: 'rgba(59,130,246,0.25)', badgeText: '#3b82f6',
badgeBg: 'rgba(59,130,246,0.10)',
badgeBorder: 'rgba(59,130,246,0.25)',
badgeText: '#3b82f6',
},
{
color: '#a855f7', name: 'Purple Accent', token: 'tertiary', badge: 'Operations',
color: '#a855f7',
name: 'Purple Accent',
token: 'tertiary',
badge: 'Operations',
glow: '0px 0px 15px 0px rgba(168,85,247,0.3)',
badgeBg: 'rgba(168,85,247,0.10)', badgeBorder: 'rgba(168,85,247,0.25)', badgeText: '#a855f7',
badgeBg: 'rgba(168,85,247,0.10)',
badgeBorder: 'rgba(168,85,247,0.25)',
badgeText: '#a855f7',
},
],
statusTokens: [
{ color: '#ef4444', bg: 'rgba(239,68,68,0.05)', border: 'rgba(239,68,68,0.20)', label: '위험 Critical', hex: '#ef4444', glow: '0px 0px 8px 0px rgba(239, 68, 68, 0.6)' },
{ color: '#f97316', bg: 'rgba(249,115,22,0.05)', border: 'rgba(249,115,22,0.20)', label: '주의 Warning', hex: '#f97316' },
{ color: '#eab308', bg: 'rgba(234,179,8,0.05)', border: 'rgba(234,179,8,0.20)', label: '경고 Caution', hex: '#eab308' },
{ color: '#22c55e', bg: 'rgba(34,197,94,0.05)', border: 'rgba(34,197,94,0.20)', label: '정상 Normal', hex: '#22c55e' },
{
color: '#ef4444',
bg: 'rgba(239,68,68,0.05)',
border: 'rgba(239,68,68,0.20)',
label: '위험 Critical',
hex: '#ef4444',
glow: '0px 0px 8px 0px rgba(239, 68, 68, 0.6)',
},
{
color: '#f97316',
bg: 'rgba(249,115,22,0.05)',
border: 'rgba(249,115,22,0.20)',
label: '주의 Warning',
hex: '#f97316',
},
{
color: '#eab308',
bg: 'rgba(234,179,8,0.05)',
border: 'rgba(234,179,8,0.20)',
label: '경고 Caution',
hex: '#eab308',
},
{
color: '#22c55e',
bg: 'rgba(34,197,94,0.05)',
border: 'rgba(34,197,94,0.20)',
label: '정상 Normal',
hex: '#22c55e',
},
],
borderTokens: [
@ -252,9 +318,27 @@ export const DARK_THEME: DesignTheme = {
],
textTokens: [
{ token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: 'rgba(237,240,247,0.60)' },
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#c0c8dc] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: 'rgba(192,200,220,0.60)' },
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#9ba3b8] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: 'rgba(155,163,184,0.60)' },
{
token: 'text-1',
sampleText: '주요 텍스트 Primary Text',
sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold',
desc: 'Headings, active values, and primary labels.',
descColor: 'rgba(237,240,247,0.60)',
},
{
token: 'text-2',
sampleText: '보조 텍스트 Secondary Text',
sampleClass: 'text-[#c0c8dc] font-korean text-[15px] font-medium',
desc: 'Supporting labels and secondary information.',
descColor: 'rgba(192,200,220,0.60)',
},
{
token: 'text-3',
sampleText: '비활성 텍스트 Muted Text',
sampleClass: 'text-[#9ba3b8] font-korean text-[15px]',
desc: 'Disabled states, placeholders, and captions.',
descColor: 'rgba(155,163,184,0.60)',
},
],
};
@ -332,28 +416,69 @@ export const LIGHT_THEME: DesignTheme = {
swatchBorderHover: 'rgba(6,182,212,0.20)',
bgTokens: [
{ bg: '#f8fafc', token: 'bg-0', hex: '#f8fafc', desc: 'Primary page canvas, lightest foundation layer.' },
{ bg: '#ffffff', token: 'bg-1', hex: '#ffffff', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
{ bg: '#f1f5f9', token: 'bg-2', hex: '#f1f5f9', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
{ bg: '#e2e8f0', token: 'bg-3', hex: '#e2e8f0', desc: 'Surface Level 3: Elevated cards and floating elements.' },
{ bg: '#cbd5e1', token: 'bg-hover', hex: '#cbd5e1', desc: 'Interactive states, list item highlighting.', isHover: true },
{
bg: '#f8fafc',
token: 'bg-0',
hex: '#f8fafc',
desc: 'Primary page canvas, lightest foundation layer.',
},
{
bg: '#ffffff',
token: 'bg-1',
hex: '#ffffff',
desc: 'Surface Level 1: Sidebar containers and utility panels.',
},
{
bg: '#f1f5f9',
token: 'bg-2',
hex: '#f1f5f9',
desc: 'Surface Level 2: Table headers and subtle sectional shifts.',
},
{
bg: '#e2e8f0',
token: 'bg-3',
hex: '#e2e8f0',
desc: 'Surface Level 3: Elevated cards and floating elements.',
},
{
bg: '#cbd5e1',
token: 'bg-hover',
hex: '#cbd5e1',
desc: 'Interactive states, list item highlighting.',
isHover: true,
},
],
accentTokens: [
{
color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
color: '#06b6d4',
name: 'Cyan Accent',
token: 'primary',
badge: 'Primary Action',
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
badgeBg: 'rgba(6,182,212,0.10)',
badgeBorder: 'rgba(6,182,212,0.25)',
badgeText: '#06b6d4',
},
{
color: '#0891b2', name: 'Teal Accent', token: 'secondary', badge: 'Information',
color: '#0891b2',
name: 'Teal Accent',
token: 'secondary',
badge: 'Information',
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
badgeBg: 'rgba(8,145,178,0.10)', badgeBorder: 'rgba(8,145,178,0.25)', badgeText: '#0891b2',
badgeBg: 'rgba(8,145,178,0.10)',
badgeBorder: 'rgba(8,145,178,0.25)',
badgeText: '#0891b2',
},
{
color: '#6366f1', name: 'Indigo Accent', token: 'tertiary', badge: 'Operations',
color: '#6366f1',
name: 'Indigo Accent',
token: 'tertiary',
badge: 'Operations',
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
badgeBg: 'rgba(99,102,241,0.10)', badgeBorder: 'rgba(99,102,241,0.25)', badgeText: '#6366f1',
badgeBg: 'rgba(99,102,241,0.10)',
badgeBorder: 'rgba(99,102,241,0.25)',
badgeText: '#6366f1',
},
],
@ -370,9 +495,27 @@ export const LIGHT_THEME: DesignTheme = {
],
textTokens: [
{ token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#0f172a] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: '#64748b' },
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#475569] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: '#64748b' },
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#94a3b8] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: '#94a3b8' },
{
token: 'text-1',
sampleText: '주요 텍스트 Primary Text',
sampleClass: 'text-[#0f172a] font-korean text-[15px] font-bold',
desc: 'Headings, active values, and primary labels.',
descColor: '#64748b',
},
{
token: 'text-2',
sampleText: '보조 텍스트 Secondary Text',
sampleClass: 'text-[#475569] font-korean text-[15px] font-medium',
desc: 'Supporting labels and secondary information.',
descColor: '#64748b',
},
{
token: 'text-3',
sampleText: '비활성 텍스트 Muted Text',
sampleClass: 'text-[#94a3b8] font-korean text-[15px]',
desc: 'Disabled states, placeholders, and captions.',
descColor: '#94a3b8',
},
],
};

파일 보기

@ -17,7 +17,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
});
const toggle = (id: string) => {
setExpanded(prev => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
@ -28,7 +28,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
const containsActive = (item: AdminMenuItem): boolean => {
if (item.id === activeMenu) return true;
return item.children?.some(c => containsActive(c)) ?? false;
return item.children?.some((c) => containsActive(c)) ?? false;
};
const renderLeaf = (item: AdminMenuItem, depth: number) => {
@ -73,13 +73,16 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
}}
>
<span>{item.label}</span>
<span className="text-[9px] text-fg-disabled transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
<span
className="text-[9px] text-fg-disabled transition-transform"
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
>
</span>
</button>
{isOpen && item.children && (
<div className="flex flex-col gap-px">
{item.children.map(child => renderItem(child, depth + 1))}
{item.children.map((child) => renderItem(child, depth + 1))}
</div>
)}
</div>
@ -96,7 +99,11 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
return (
<div
className="flex flex-col bg-bg-surface border-r border-stroke overflow-y-auto shrink-0"
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
style={{
width: 240,
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-light) transparent',
}}
>
{/* 헤더 */}
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
@ -107,7 +114,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
{/* 메뉴 목록 */}
<div className="flex flex-col gap-0.5 p-2">
{ADMIN_MENU.map(section => {
{ADMIN_MENU.map((section) => {
const isOpen = expanded.has(section.id);
const hasActiveChild = containsActive(section);
@ -124,7 +131,10 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
>
<span className="text-sm">{section.icon}</span>
<span className="flex-1 text-left">{section.label}</span>
<span className="text-[9px] text-fg-disabled transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
<span
className="text-[9px] text-fg-disabled transition-transform"
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
>
</span>
</button>
@ -132,7 +142,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
{/* 하위 메뉴 */}
{isOpen && section.children && (
<div className="flex flex-col gap-px mt-0.5 ml-1">
{section.children.map(child => renderItem(child, 1))}
{section.children.map((child) => renderItem(child, 1))}
</div>
)}
</div>

파일 보기

@ -34,7 +34,7 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
'asset-upload': () => <AssetUploadPanel />,
'map-base': () => <MapBasePanel />,
'map-layer': () => <LayerPanel />,
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
'dispersant-zone': () => <DispersingZonePanel />,
'vessel-materials': () => <VesselMaterialsPanel />,
@ -57,9 +57,7 @@ export function AdminView() {
return (
<div className="flex flex-1 overflow-hidden bg-bg-base">
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
<div className="flex-1 flex flex-col overflow-hidden">
{renderContent()}
</div>
<div className="flex-1 flex flex-col overflow-hidden">{renderContent()}</div>
</div>
);
}

파일 보기

@ -2,14 +2,48 @@ import { useState, useEffect, useRef } from 'react';
import { fetchUploadLogs } from '@tabs/assets/services/assetsApi';
import type { UploadLogItem } from '@tabs/assets/services/assetsApi';
const ASSET_CATEGORIES = ['전체', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '오일붐', '흡착재', '기타'];
const ASSET_CATEGORIES = [
'전체',
'방제선',
'유회수기',
'이송펌프',
'방제차량',
'살포장치',
'오일붐',
'흡착재',
'기타',
];
const JURISDICTIONS = ['전체', '남해청', '서해청', '중부청', '동해청', '제주청'];
const PERM_ITEMS = [
{ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', bg: 'rgba(245,158,11,0.15)', color: 'text-yellow-400' },
{ icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', bg: 'rgba(6,182,212,0.15)', color: 'text-color-accent' },
{ icon: '👁', role: '조회자', desc: '현황 조회만 가능', bg: 'rgba(148,163,184,0.15)', color: 'text-fg-sub' },
{ icon: '🚫', role: '게스트', desc: '접근 불가', bg: 'rgba(239,68,68,0.1)', color: 'text-red-400' },
{
icon: '👑',
role: '시스템관리자',
desc: '전체 자산 업로드/삭제 가능',
bg: 'rgba(245,158,11,0.15)',
color: 'text-yellow-400',
},
{
icon: '🔧',
role: '운영관리자',
desc: '관할청 내 자산 업로드 가능',
bg: 'rgba(6,182,212,0.15)',
color: 'text-color-accent',
},
{
icon: '👁',
role: '조회자',
desc: '현황 조회만 가능',
bg: 'rgba(148,163,184,0.15)',
color: 'text-fg-sub',
},
{
icon: '🚫',
role: '게스트',
desc: '접근 불가',
bg: 'rgba(239,68,68,0.1)',
color: 'text-red-400',
},
];
function formatDate(dtm: string) {
@ -32,7 +66,7 @@ function AssetUploadPanel() {
useEffect(() => {
fetchUploadLogs(10)
.then(setUploadHistory)
.catch(err => console.error('[AssetUploadPanel] 이력 로드 실패:', err));
.catch((err) => console.error('[AssetUploadPanel] 이력 로드 실패:', err));
}, []);
useEffect(() => {
@ -69,7 +103,9 @@ 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>
<p className="text-xs text-fg-disabled mt-1 font-korean">
</p>
</div>
{/* 본문 */}
@ -84,7 +120,10 @@ function AssetUploadPanel() {
<div className="px-5 py-4 space-y-4">
{/* 드롭존 */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
@ -96,16 +135,25 @@ 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">{selectedFile.name}</div>
<div className="text-xs 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>
<div className="text-[10px] text-fg-disabled font-korean mb-3">(.xlsx), CSV · 10MB</div>
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">
</div>
<div className="text-[10px] text-fg-disabled font-korean mb-3">
(.xlsx), CSV · 10MB
</div>
<button
type="button"
className="px-4 py-1.5 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"
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
onClick={(e) => {
e.stopPropagation();
fileInputRef.current?.click();
}}
>
</button>
@ -116,43 +164,53 @@ function AssetUploadPanel() {
type="file"
accept=".xlsx,.csv"
className="hidden"
onChange={e => handleFileSelect(e.target.files?.[0] ?? null)}
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
{/* 자산 분류 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5"> </label>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
value={assetCategory}
onChange={e => setAssetCategory(e.target.value)}
onChange={(e) => setAssetCategory(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"
>
{ASSET_CATEGORIES.map(c => (
<option key={c} value={c}>{c}</option>
{ASSET_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{/* 대상 관할 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5"> </label>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
value={jurisdiction}
onChange={e => setJurisdiction(e.target.value)}
onChange={(e) => setJurisdiction(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"
>
{JURISDICTIONS.map(j => (
<option key={j} value={j}>{j}</option>
{JURISDICTIONS.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</select>
</div>
{/* 업로드 방식 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5"> </label>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
<input
@ -200,7 +258,7 @@ function AssetUploadPanel() {
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-2">
{PERM_ITEMS.map(p => (
{PERM_ITEMS.map((p) => (
<div
key={p.role}
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
@ -213,7 +271,9 @@ function AssetUploadPanel() {
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">{p.desc}</div>
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">
{p.desc}
</div>
</div>
</div>
))}
@ -227,24 +287,30 @@ function AssetUploadPanel() {
</div>
<div className="px-5 py-4 space-y-2">
{uploadHistory.length === 0 ? (
<div className="text-[11px] text-fg-disabled font-korean text-center py-4"> .</div>
) : uploadHistory.map(h => (
<div
key={h.logSn}
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-[10px] text-fg-disabled mt-0.5 font-korean">
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}
</div>
</div>
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold
bg-[rgba(34,197,94,0.15)] text-color-success flex-shrink-0">
</span>
<div className="text-[11px] text-fg-disabled font-korean text-center py-4">
.
</div>
))}
) : (
uploadHistory.map((h) => (
<div
key={h.logSn}
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-[10px] text-fg-disabled mt-0.5 font-korean">
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}
</div>
</div>
<span
className="px-2 py-0.5 rounded-full text-[10px] font-semibold
bg-[rgba(34,197,94,0.15)] text-color-success flex-shrink-0"
>
</span>
</div>
))
)}
</div>
</div>
</div>

파일 보기

@ -66,13 +66,15 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
}
}, [activeCategory, search, page]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
}, [load]);
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
const items = data?.items ?? [];
const toggleSelect = (sn: number) => {
setSelected(prev => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(sn)) next.delete(sn);
else next.add(sn);
@ -84,7 +86,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
if (selected.size === items.length) {
setSelected(new Set());
} else {
setSelected(new Set(items.map(i => i.sn)));
setSelected(new Set(items.map((i) => i.sn)));
}
};
@ -93,7 +95,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
setDeleting(true);
try {
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
await Promise.all([...selected].map((sn) => adminDeleteBoardPost(sn)));
await load();
} catch {
alert('삭제 중 오류가 발생했습니다.');
@ -118,15 +120,13 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 헤더 */}
<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>
<span className="text-xs text-fg-disabled"> {data?.totalCount ?? 0}</span>
</div>
{/* 카테고리 탭 + 검색 */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke-1">
<div className="flex gap-1">
{CATEGORY_TABS.map(tab => (
{CATEGORY_TABS.map((tab) => (
<button
key={tab.code}
onClick={() => handleCategoryChange(tab.code)}
@ -144,7 +144,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
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"
/>
@ -192,14 +192,18 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-fg-disabled"> ...</td>
<td colSpan={7} className="py-8 text-center text-fg-disabled">
...
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-fg-disabled"> .</td>
<td colSpan={7} className="py-8 text-center text-fg-disabled">
.
</td>
</tr>
) : (
items.map(post => (
items.map((post) => (
<PostRow
key={post.sn}
post={post}
@ -216,7 +220,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
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"
>
@ -231,7 +235,9 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-fg-disabled hover:bg-bg-elevated'
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
}`}
>
{p}
@ -239,7 +245,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
);
})}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
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"
>
@ -262,27 +268,24 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
return (
<tr className="border-b border-stroke-1 hover:bg-bg-surface/50 transition-colors">
<td className="py-2 text-center">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="accent-blue-500"
/>
<input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" />
</td>
<td className="py-2 text-center text-fg-disabled">{post.sn}</td>
<td className="py-2 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
'bg-blue-500/15 text-blue-400'
}`}>
<span
className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
post.categoryCd === 'NOTICE'
? 'bg-red-500/15 text-red-400'
: post.categoryCd === 'QNA'
? 'bg-purple-500/15 text-purple-400'
: 'bg-blue-500/15 text-blue-400'
}`}
>
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
</span>
</td>
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
{post.pinnedYn === 'Y' && (
<span className="text-[10px] text-orange-400 mr-1">[]</span>
)}
{post.pinnedYn === 'Y' && <span className="text-[10px] text-orange-400 mr-1">[]</span>}
{post.title}
</td>
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>

파일 보기

@ -6,9 +6,17 @@ import { typeTagCls } from '@tabs/assets/components/assetTypes';
const PAGE_SIZE = 20;
const regionShort = (j: string) =>
j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' :
j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' :
j.includes('제주') ? '제주청' : j;
j.includes('남해')
? '남해청'
: j.includes('서해')
? '서해청'
: j.includes('중부')
? '중부청'
: j.includes('동해')
? '동해청'
: j.includes('제주')
? '제주청'
: j;
function CleanupEquipPanel() {
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
@ -23,49 +31,59 @@ function CleanupEquipPanel() {
setLoading(true);
fetchOrganizations()
.then(setOrganizations)
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.catch((err) => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
let cancelled = false;
fetchOrganizations()
.then(data => { if (!cancelled) setOrganizations(data); })
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.then((data) => {
if (!cancelled) setOrganizations(data);
})
.catch((err) => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const typeOptions = useMemo(() => {
const set = new Set(organizations.map(o => o.type));
const set = new Set(organizations.map((o) => o.type));
return Array.from(set).sort();
}, [organizations]);
const EQUIP_FIELDS: Record<string, keyof AssetOrgCompat> = {
'방제선': 'vessel',
'유회수기': 'skimmer',
'이송펌프': 'pump',
'방제차량': 'vehicle',
'살포장치': 'sprayer',
: 'vessel',
: 'skimmer',
: 'pump',
: 'vehicle',
: 'sprayer',
};
const filtered = useMemo(() =>
organizations
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter(o => typeFilter === '전체' || o.type === typeFilter)
.filter(o => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0)
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
[organizations, regionFilter, typeFilter, equipFilter, searchTerm]
const filtered = useMemo(
() =>
organizations
.filter((o) => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter((o) => typeFilter === '전체' || o.type === typeFilter)
.filter((o) => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0)
.filter(
(o) => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm),
),
[organizations, regionFilter, typeFilter, equipFilter, searchTerm],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setter(e.target.value);
setCurrentPage(1);
};
const handleFilterChange =
(setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setter(e.target.value);
setCurrentPage(1);
};
const pageNumbers = (() => {
const range: number[] = [];
@ -102,8 +120,10 @@ function CleanupEquipPanel() {
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"
>
<option value="전체"> </option>
{typeOptions.map(t => (
<option key={t} value={t}>{t}</option>
{typeOptions.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
@ -122,7 +142,10 @@ function CleanupEquipPanel() {
type="text"
placeholder="기관명, 주소 검색..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
onChange={(e) => {
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"
/>
<button
@ -144,65 +167,117 @@ function CleanupEquipPanel() {
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}></th>
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}></th>
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}></th>
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}></th>
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
>
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">
</th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-xs text-fg-disabled font-korean">
<td
colSpan={11}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
>
.
</td>
</tr>
) : paged.map((org, idx) => (
<tr key={org.id} className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
{org.address}
</td>
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled"></span>}
</td>
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled"></span>}
</td>
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
{org.pump > 0 ? org.pump : <span className="text-fg-disabled"></span>}
</td>
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled"></span>}
</td>
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}>
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
))}
) : (
paged.map((org, idx) => (
<tr
key={org.id}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
{org.address}
</td>
<td
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
>
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled"></span>}
</td>
<td
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
>
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled"></span>}
</td>
<td
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
>
{org.pump > 0 ? org.pump : <span className="text-fg-disabled"></span>}
</td>
<td
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
>
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled"></span>}
</td>
<td
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
>
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
)}
@ -224,10 +299,20 @@ function CleanupEquipPanel() {
].map((t) => {
const isActive = t.label === equipFilter || t.label === '총자산';
return (
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-color-accent/10' : ''}`}>
<span className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}>{t.label}</span>
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}>
{t.value.toLocaleString()}{t.unit}
<div
key={t.label}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-color-accent/10' : ''}`}
>
<span
className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
>
{t.label}
</span>
<span
className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
>
{t.value.toLocaleString()}
{t.unit}
</span>
</div>
);
@ -239,31 +324,37 @@ function CleanupEquipPanel() {
{!loading && filtered.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
<span className="text-[11px] text-fg-disabled font-korean">
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} / {filtered.length}
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} /
{filtered.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage === 1}
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&lt;
</button>
{pageNumbers.map(p => (
{pageNumbers.map((p) => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
style={p === safePage
? { borderColor: 'var(--color-accent)', color: 'var(--color-accent)', background: 'rgba(6,182,212,0.1)' }
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
style={
p === safePage
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
>
{p}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage === totalPages}
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>

파일 보기

@ -63,9 +63,7 @@ const MOCK_DATA: HrCollectItem[] = [
clctDate: '2024-12-16',
jobName: 'DeptJob',
resultClctList: [],
etaClctList: [
{ startDate: '2025-09-12 04:20', endDate: '2025-09-12 04:21' },
],
etaClctList: [{ startDate: '2025-09-12 04:20', endDate: '2025-09-12 04:21' }],
},
{
id: '100200',
@ -113,9 +111,7 @@ const MOCK_DATA: HrCollectItem[] = [
clctDate: '2025-01-10',
jobName: 'PositionJob',
resultClctList: [{ resultDate: '2025-09-12 04:30', count: 42 }],
etaClctList: [
{ startDate: '2025-09-12 04:30', endDate: '2025-09-12 04:31' },
],
etaClctList: [{ startDate: '2025-09-12 04:30', endDate: '2025-09-12 04:31' }],
},
{
id: '100202',
@ -139,9 +135,7 @@ const MOCK_DATA: HrCollectItem[] = [
clctDate: '2025-03-20',
jobName: 'OrgJob',
resultClctList: [{ resultDate: '2025-09-12 04:40', count: 15 }],
etaClctList: [
{ startDate: '2025-09-12 04:40', endDate: '2025-09-12 04:41' },
],
etaClctList: [{ startDate: '2025-09-12 04:40', endDate: '2025-09-12 04:41' }],
},
{
id: '100203',
@ -193,13 +187,26 @@ function formatCron(cron: string | null): string {
if (!cron) return '-';
const parts = cron.split(' ');
if (parts.length < 6) return cron;
const [sec, min, hour, , , ] = parts;
const [sec, min, hour, , ,] = parts;
return `매일 ${hour}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}`;
}
// ─── 테이블 ─────────────────────────────────────────────────
const HEADERS = ['번호', '수집항목', '기관', '시스템', '유형', '수집주기', '대상테이블', 'Job명', '활성', '수집시작일', '최근수집일', '상태'];
const HEADERS = [
'번호',
'수집항목',
'기관',
'시스템',
'유형',
'수집주기',
'대상테이블',
'Job명',
'활성',
'수집시작일',
'최근수집일',
'상태',
];
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
return (
@ -208,7 +215,12 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{HEADERS.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
@ -226,26 +238,39 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
: rows.map((row, idx) => {
const status = getCollectStatus(row);
return (
<tr key={`${row.seq}`} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr
key={`${row.seq}`}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
>
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.clctName}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.clctName}
</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.depth2}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.depth3}</td>
<td className="px-3 py-2 text-t2">{row.clctTypeName}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{formatCron(row.receiveCycle)}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{formatCron(row.receiveCycle)}
</td>
<td className="px-3 py-2 text-t2 font-mono">{row.targetTable}</td>
<td className="px-3 py-2 text-t2 font-mono">{row.jobName}</td>
<td className="px-3 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${
row.activeYn === 'Y' ? 'text-emerald-400 bg-emerald-500/10' : 'text-t3 bg-bg-elevated'
}`}>
<span
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${
row.activeYn === 'Y'
? 'text-emerald-400 bg-emerald-500/10'
: 'text-t3 bg-bg-elevated'
}`}
>
{row.activeYn === 'Y' ? 'Y' : 'N'}
</span>
</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.clctStartDt}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.clctDate ?? '-'}</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${status.color}`}>
<span
className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${status.color}`}
>
{status.label}
</span>
</td>
@ -276,9 +301,13 @@ export default function CollectHrPanel() {
useEffect(() => {
let isMounted = true;
if (rows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchData(); });
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
}
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [rows.length, fetchData]);
const activeCount = rows.filter((r) => r.activeYn === 'Y').length;
@ -292,7 +321,12 @@ export default function CollectHrPanel() {
<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' })}
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
@ -306,7 +340,12 @@ export default function CollectHrPanel() {
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" />
<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>

파일 보기

@ -70,8 +70,7 @@ const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: st
{ key: '수심', value: '수심 10m 이하' },
{
key: '사용거리',
value:
'어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
value: '어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
},
{
key: '사용승인(절차)',
@ -93,14 +92,18 @@ const DispersingZonePanel = () => {
useEffect(() => {
fetch('/dispersant-consider.geojson')
.then(r => r.json())
.then((r) => r.json())
.then(setConsiderData)
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
.catch(() => {
/* GeoJSON 없을 때 빈 상태 유지 */
});
fetch('/dispersant-restrict.geojson')
.then(r => r.json())
.then((r) => r.json())
.then(setRestrictData)
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
.catch(() => {
/* GeoJSON 없을 때 빈 상태 유지 */
});
}, []);
const layers: Layer[] = [
@ -131,7 +134,7 @@ const DispersingZonePanel = () => {
];
const handleToggleExpand = (zone: ZoneKey) => {
setExpandedZone(prev => (prev === zone ? null : zone));
setExpandedZone((prev) => (prev === zone ? null : zone));
};
const renderZoneCard = (zone: ZoneKey) => {
@ -153,15 +156,13 @@ const DispersingZonePanel = () => {
<span className="flex-1 text-xs font-semibold text-fg font-korean">{info.label}</span>
{/* 토글 스위치 */}
<button
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setShowLayer(prev => !prev);
setShowLayer((prev) => !prev);
}}
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
showLayer
? 'bg-color-accent'
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
showLayer ? 'bg-color-accent' : 'bg-[rgba(255,255,255,0.08)] border border-stroke'
}`}
>
<span
@ -179,7 +180,7 @@ const DispersingZonePanel = () => {
<div className="border-t border-stroke px-3 py-3">
<table className="w-full">
<tbody>
{info.rows.map(row => (
{info.rows.map((row) => (
<tr key={row.key} className="border-b border-stroke last:border-0">
<td className="py-2 pr-2 text-[11px] text-fg-disabled font-korean whitespace-nowrap align-top w-24">
{row.key}

파일 보기

@ -42,7 +42,11 @@ interface LayerFormData {
const PAGE_SIZE = 10;
async function fetchLayers(page: number, search: string, useYn: string): Promise<LayerListResponse> {
async function fetchLayers(
page: number,
search: string,
useYn: string,
): Promise<LayerListResponse> {
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
if (search) params.set('search', search);
if (useYn) params.set('useYn', useYn);
@ -56,7 +60,9 @@ async function fetchLayerOptions(): Promise<LayerOption[]> {
}
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', {
layerCd,
});
return res.data;
}
@ -104,23 +110,25 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null);
useEffect(() => {
fetchLayerOptions().then(setOptions).catch(() => {});
fetchLayerOptions()
.then(setOptions)
.catch(() => {});
}, []);
const handleField = <K extends keyof LayerFormData>(key: K, value: LayerFormData[K]) => {
setForm(prev => ({ ...prev, [key]: value }));
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleParentChange = async (upLayerCd: string) => {
if (!upLayerCd) {
setParentInfo(null);
setForm(prev => ({ ...prev, upLayerCd: '' }));
setForm((prev) => ({ ...prev, upLayerCd: '' }));
return;
}
const parent = options.find(o => o.layerCd === upLayerCd);
const parent = options.find((o) => o.layerCd === upLayerCd);
if (parent) {
setParentInfo({ fullNm: parent.layerFullNm, level: parent.layerLevel });
setForm(prev => ({
setForm((prev) => ({
...prev,
upLayerCd,
layerLevel: parent.layerLevel + 1,
@ -129,12 +137,14 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
}
try {
const nextCode = await fetchNextLayerCode(upLayerCd);
setForm(prev => ({ ...prev, layerCd: nextCode }));
} catch { /* 실패 시 사용자 수동 입력 */ }
setForm((prev) => ({ ...prev, layerCd: nextCode }));
} catch {
/* 실패 시 사용자 수동 입력 */
}
};
const handleLayerNmChange = (value: string) => {
setForm(prev => ({
setForm((prev) => ({
...prev,
layerNm: value,
...(parentInfo && {
@ -145,9 +155,18 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.layerCd.trim()) { setFormError('레이어코드는 필수입니다.'); return; }
if (!form.layerNm.trim()) { setFormError('레이어명은 필수입니다.'); return; }
if (!form.layerFullNm.trim()) { setFormError('레이어전체명은 필수입니다.'); return; }
if (!form.layerCd.trim()) {
setFormError('레이어코드는 필수입니다.');
return;
}
if (!form.layerNm.trim()) {
setFormError('레이어명은 필수입니다.');
return;
}
if (!form.layerFullNm.trim()) {
setFormError('레이어전체명은 필수입니다.');
return;
}
setSaving(true);
setFormError(null);
try {
@ -166,7 +185,8 @@ 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';
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';
const labelCls = 'block text-[11px] font-semibold text-fg-sub font-korean mb-1.5';
return (
@ -177,7 +197,9 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<h2 className="text-sm font-bold text-fg font-korean">
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
</h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors"></button>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
@ -187,27 +209,32 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<label className={labelCls}> </label>
<select
value={form.upLayerCd}
onChange={e => mode === 'create' ? handleParentChange(e.target.value) : handleField('upLayerCd', e.target.value)}
onChange={(e) =>
mode === 'create'
? 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"
>
<option value="">()</option>
{options
.filter(o => o.layerCd !== form.layerCd)
.map(o => (
.filter((o) => o.layerCd !== form.layerCd)
.map((o) => (
<option key={o.layerCd} value={o.layerCd}>
{o.layerCd} {o.layerNm}
</option>
))
}
))}
</select>
</div>
{/* 레이어코드 */}
<div>
<label className={labelCls}> <span className="text-red-400">*</span></label>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerCd}
onChange={e => handleField('layerCd', e.target.value)}
onChange={(e) => handleField('layerCd', e.target.value)}
readOnly={mode === 'edit'}
placeholder="예: LAYER_001"
className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-base text-fg-disabled cursor-not-allowed' : ''}`}
@ -215,11 +242,17 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
</div>
{/* 레이어명 */}
<div>
<label className={labelCls}> <span className="text-red-400">*</span></label>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerNm}
onChange={e => mode === 'create' ? handleLayerNmChange(e.target.value) : handleField('layerNm', e.target.value)}
onChange={(e) =>
mode === 'create'
? handleLayerNmChange(e.target.value)
: handleField('layerNm', e.target.value)
}
maxLength={100}
placeholder="레이어 이름"
className={inputCls}
@ -227,11 +260,13 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
</div>
{/* 레이어전체명 */}
<div>
<label className={labelCls}> <span className="text-red-400">*</span></label>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerFullNm}
onChange={e => handleField('layerFullNm', e.target.value)}
onChange={(e) => handleField('layerFullNm', e.target.value)}
maxLength={200}
placeholder="레이어 전체 경로명"
className={inputCls}
@ -243,7 +278,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<input
type="number"
value={form.layerLevel}
onChange={e => handleField('layerLevel', Number(e.target.value))}
onChange={(e) => handleField('layerLevel', Number(e.target.value))}
min={1}
max={10}
className={inputCls}
@ -255,7 +290,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<input
type="text"
value={form.wmsLayerNm}
onChange={e => handleField('wmsLayerNm', e.target.value)}
onChange={(e) => handleField('wmsLayerNm', e.target.value)}
placeholder="WMS 레이어명 (선택)"
className={`${inputCls} font-mono`}
/>
@ -266,7 +301,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<input
type="number"
value={form.sortOrd}
onChange={e => handleField('sortOrd', Number(e.target.value))}
onChange={(e) => handleField('sortOrd', Number(e.target.value))}
className={inputCls}
/>
</div>
@ -275,7 +310,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
<label className={labelCls}></label>
<select
value={form.useYn}
onChange={e => handleField('useYn', e.target.value)}
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"
>
<option value="Y"></option>
@ -330,7 +365,9 @@ const LayerPanel = () => {
const [filterUseYn, setFilterUseYn] = useState('');
// 모달
const [modal, setModal] = useState<{ mode: 'create' | 'edit'; data?: LayerAdminItem } | null>(null);
const [modal, setModal] = useState<{ mode: 'create' | 'edit'; data?: LayerAdminItem } | null>(
null,
);
const load = useCallback(async (p: number, search: string, useYn: string) => {
setLoading(true);
@ -361,13 +398,13 @@ const LayerPanel = () => {
setToggling(layerCd);
try {
const result = await toggleLayerUse(layerCd);
setItems(prev =>
prev.map(item => {
setItems((prev) =>
prev.map((item) => {
if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn };
// 직접 자식의 parentUseYn도 즉시 동기화
if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn };
return item;
})
}),
);
// 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신
queryClient.invalidateQueries({ queryKey: ['layers'] });
@ -425,14 +462,14 @@ const LayerPanel = () => {
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
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"
/>
<select
value={filterUseYn}
onChange={e => setFilterUseYn(e.target.value)}
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"
>
<option value=""></option>
@ -465,22 +502,45 @@ const LayerPanel = () => {
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">WMS레이어명</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
WMS레이어명
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">
</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-12 text-center text-fg-disabled text-sm font-korean">
<td
colSpan={10}
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
>
.
</td>
</tr>
@ -495,13 +555,9 @@ const LayerPanel = () => {
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
{/* 레이어코드 */}
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
{item.layerCd}
</td>
<td className="px-4 py-3 text-[11px] 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 font-korean">{item.layerNm}</td>
{/* 레이어전체명 */}
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
@ -586,7 +642,7 @@ const LayerPanel = () => {
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
@ -594,7 +650,9 @@ const LayerPanel = () => {
</button>
{buildPageButtons().map((btn, i) =>
btn === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled"></span>
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">
</span>
) : (
<button
key={btn}
@ -607,10 +665,10 @@ const LayerPanel = () => {
>
{btn}
</button>
)
),
)}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>

파일 보기

@ -81,11 +81,15 @@ function MapBaseModal({
<h2 className="text-sm font-bold text-fg font-korean">
{isEdit ? '지도 수정' : '지도 등록'}
</h2>
<button
onClick={onClose}
className="text-fg-disabled hover:text-fg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
@ -102,7 +106,7 @@ function MapBaseModal({
<input
type="text"
value={form.mapNm}
onChange={e => setField('mapNm', e.target.value)}
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"
/>
@ -116,7 +120,7 @@ function MapBaseModal({
<input
type="text"
value={form.mapKey}
onChange={e => setField('mapKey', e.target.value)}
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"
@ -130,12 +134,14 @@ function MapBaseModal({
</label>
<select
value={form.mapLevelCd}
onChange={e => setField('mapLevelCd', e.target.value)}
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"
>
<option value=""></option>
{MAP_LEVEL_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
{MAP_LEVEL_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
@ -148,7 +154,7 @@ function MapBaseModal({
<input
type="text"
value={form.mapSrc}
onChange={e => setField('mapSrc', e.target.value)}
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"
/>
@ -162,7 +168,7 @@ function MapBaseModal({
<textarea
rows={3}
value={form.mapDc}
onChange={e => setField('mapDc', e.target.value)}
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"
/>
@ -194,9 +200,7 @@ function MapBaseModal({
</div>
{/* 에러 */}
{modalError && (
<p className="text-[11px] text-red-400 font-korean">{modalError}</p>
)}
{modalError && <p className="text-[11px] text-red-400 font-korean">{modalError}</p>}
</div>
{/* 모달 푸터 */}
@ -224,7 +228,7 @@ function MapBaseModal({
// ─── 메인 패널 ─────────────────────────────────────────────
function MapBasePanel() {
const loadMapTypes = useMapStore(s => s.loadMapTypes);
const loadMapTypes = useMapStore((s) => s.loadMapTypes);
const [items, setItems] = useState<MapBaseItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@ -241,7 +245,7 @@ function MapBasePanel() {
setLoading(true);
try {
const res = await api.get<{ rows: MapBaseItem[]; total: number }>(
`/map-base?page=${page}&limit=10`
`/map-base?page=${page}&limit=10`,
);
setItems(res.data.rows);
setTotal(res.data.total);
@ -255,7 +259,9 @@ function MapBasePanel() {
// page 변경 시 목록 재조회 (loadData는 page를 클로저로 참조)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { loadData(); }, [page]);
useEffect(() => {
loadData();
}, [page]);
const openModal = (item: MapBaseItem | null) => {
setEditItem(item);
@ -389,58 +395,60 @@ function MapBasePanel() {
...
</td>
</tr>
) : items.map((item, idx) => (
<tr
key={item.mapSn}
className="border-b border-stroke hover:bg-bg-surface/50 transition-colors"
>
<td className="py-3 text-center text-fg-disabled">
{(page - 1) * 10 + idx + 1}
</td>
<td className="py-3 pl-4">
<span className="text-fg font-korean">{item.mapNm}</span>
<span className="ml-2 text-[10px] text-fg-disabled font-mono">{item.mapKey}</span>
</td>
<td className="py-3 text-center text-fg-sub">{item.mapLevelCd ?? '-'}</td>
<td className="py-3 text-center text-fg-sub font-korean">{item.regNm ?? '-'}</td>
<td className="py-3 text-center text-fg-disabled">{item.regDtm ?? '-'}</td>
<td
className="py-3 text-center cursor-pointer"
onClick={() => handleToggleUse(item)}
) : (
items.map((item, idx) => (
<tr
key={item.mapSn}
className="border-b border-stroke hover:bg-bg-surface/50 transition-colors"
>
<div className="flex justify-center">
<button
type="button"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
item.useYn === 'Y' ? 'translate-x-5' : 'translate-x-1'
<td className="py-3 text-center text-fg-disabled">{(page - 1) * 10 + idx + 1}</td>
<td className="py-3 pl-4">
<span className="text-fg font-korean">{item.mapNm}</span>
<span className="ml-2 text-[10px] text-fg-disabled font-mono">
{item.mapKey}
</span>
</td>
<td className="py-3 text-center text-fg-sub">{item.mapLevelCd ?? '-'}</td>
<td className="py-3 text-center text-fg-sub font-korean">{item.regNm ?? '-'}</td>
<td className="py-3 text-center text-fg-disabled">{item.regDtm ?? '-'}</td>
<td
className="py-3 text-center cursor-pointer"
onClick={() => handleToggleUse(item)}
>
<div className="flex justify-center">
<button
type="button"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
}`}
/>
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
item.useYn === 'Y' ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
</td>
<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)]"
>
</button>
</div>
</td>
<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)]"
>
</button>
</td>
<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"
>
</button>
</td>
</tr>
))}
</td>
<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"
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{!loading && items.length === 0 && (
@ -454,7 +462,7 @@ function MapBasePanel() {
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
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"
>
@ -469,7 +477,9 @@ function MapBasePanel() {
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-fg-disabled hover:bg-bg-elevated'
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
}`}
>
{p}
@ -477,7 +487,7 @@ function MapBasePanel() {
);
})}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
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"
>

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react';
import {
DndContext,
closestCenter,
@ -8,135 +8,137 @@ import {
useSensors,
DragOverlay,
type DragEndEvent,
} from '@dnd-kit/core'
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
} from '@dnd-kit/sortable';
import {
fetchMenuConfig,
updateMenuConfigApi,
type MenuConfigItem,
} from '@common/services/authApi'
import { useMenuStore } from '@common/store/menuStore'
import SortableMenuItem from './SortableMenuItem'
} from '@common/services/authApi';
import { useMenuStore } from '@common/store/menuStore';
import SortableMenuItem from './SortableMenuItem';
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() {
const [menus, setMenus] = useState<MenuConfigItem[]>([])
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null)
const { setMenuConfig } = useMenuStore()
const [menus, setMenus] = useState<MenuConfigItem[]>([]);
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null);
const { setMenuConfig } = useMenuStore();
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const loadMenus = useCallback(async () => {
setLoading(true)
setLoading(true);
try {
const config = await fetchMenuConfig()
setMenus(config)
setOriginalMenus(config)
const config = await fetchMenuConfig();
setMenus(config);
setOriginalMenus(config);
} catch (err) {
console.error('메뉴 설정 조회 실패:', err)
console.error('메뉴 설정 조회 실패:', err);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
loadMenus()
}, [loadMenus])
loadMenus();
}, [loadMenus]);
useEffect(() => {
if (!emojiPickerId) return
if (!emojiPickerId) return;
const handler = (e: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
setEmojiPickerId(null)
setEmojiPickerId(null);
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [emojiPickerId])
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [emojiPickerId]);
const toggleMenu = (id: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
}
setMenus((prev) => prev.map((m) => (m.id === id ? { ...m, enabled: !m.enabled } : m)));
};
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
}
setMenus((prev) => prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
};
const handleEmojiSelect = (emoji: { native: string }) => {
if (emojiPickerId) {
updateMenuField(emojiPickerId, 'icon', emoji.native)
setEmojiPickerId(null)
updateMenuField(emojiPickerId, 'icon', emoji.native);
setEmojiPickerId(null);
}
}
};
const moveMenu = (idx: number, direction: -1 | 1) => {
const targetIdx = idx + direction
if (targetIdx < 0 || targetIdx >= menus.length) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}
const targetIdx = idx + direction;
if (targetIdx < 0 || targetIdx >= menus.length) return;
setMenus((prev) => {
const arr = [...prev];
[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]];
return arr.map((m, i) => ({ ...m, order: i + 1 }));
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id) return
setMenus(prev => {
const oldIndex = prev.findIndex(m => m.id === active.id)
const newIndex = prev.findIndex(m => m.id === over.id)
const reordered = arrayMove(prev, oldIndex, newIndex)
return reordered.map((m, i) => ({ ...m, order: i + 1 }))
})
}
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) return;
setMenus((prev) => {
const oldIndex = prev.findIndex((m) => m.id === active.id);
const newIndex = prev.findIndex((m) => m.id === over.id);
const reordered = arrayMove(prev, oldIndex, newIndex);
return reordered.map((m, i) => ({ ...m, order: i + 1 }));
});
};
const handleSave = async () => {
setSaving(true)
setSaving(true);
try {
const updated = await updateMenuConfigApi(menus)
setMenus(updated)
setOriginalMenus(updated)
setMenuConfig(updated)
const updated = await updateMenuConfigApi(menus);
setMenus(updated);
setOriginalMenus(updated);
setMenuConfig(updated);
} catch (err) {
console.error('메뉴 설정 저장 실패:', err)
console.error('메뉴 설정 저장 실패:', err);
} finally {
setSaving(false)
setSaving(false);
}
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-fg-disabled text-sm font-korean"> ...</div>
</div>
)
);
}
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
const activeMenu = activeId ? menus.find((m) => m.id === activeId) : null;
return (
<div className="flex flex-col h-full">
<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>
<p className="text-xs text-fg-disabled mt-1 font-korean">
, , ,
</p>
</div>
<button
onClick={handleSave}
@ -158,7 +160,7 @@ function MenusPanel() {
onDragStart={(event) => setActiveId(event.active.id as string)}
onDragEnd={handleDragEnd}
>
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
<SortableContext items={menus.map((m) => m.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2 max-w-[700px]">
{menus.map((menu, idx) => (
<SortableMenuItem
@ -172,7 +174,10 @@ function MenusPanel() {
onToggle={toggleMenu}
onMove={moveMenu}
onEditStart={setEditingId}
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
onEditEnd={() => {
setEditingId(null);
setEmojiPickerId(null);
}}
onEmojiPickerToggle={setEmojiPickerId}
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
onEmojiSelect={handleEmojiSelect}
@ -185,14 +190,16 @@ function MenusPanel() {
<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-[16px]">{activeMenu.icon}</span>
<span className="text-[13px] font-semibold text-fg font-korean">{activeMenu.label}</span>
<span className="text-[13px] font-semibold text-fg font-korean">
{activeMenu.label}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)
);
}
export default MenusPanel
export default MenusPanel;

파일 보기

@ -1,8 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import {
getNumericalDataStatus,
type NumericalDataStatus,
} from '../services/monitorApi';
import { getNumericalDataStatus, type NumericalDataStatus } from '../services/monitorApi';
type TabId = 'all' | 'ocean' | 'koast';
@ -118,13 +115,7 @@ const TABLE_HEADERS = [
'다음 예정',
];
function ForecastTable({
rows,
loading,
}: {
rows: NumericalDataStatus[];
loading: boolean;
}) {
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
@ -195,9 +186,7 @@ export default function MonitorForecastPanel() {
}, [fetchData]);
const visibleRows = filterByTab(rows, activeTab);
const errorCount = visibleRows.filter(
(r) => r.lastStatus === 'FAILED'
).length;
const errorCount = visibleRows.filter((r) => r.lastStatus === 'FAILED').length;
const totalCount = visibleRows.length;
return (
@ -259,9 +248,7 @@ 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-xs text-t3"> {totalCount}</span>}
</div>
{/* 테이블 */}

파일 보기

@ -73,7 +73,15 @@ interface MarineRow {
const fmt = (v: number | null | undefined, digits = 1): string =>
v != null ? v.toFixed(digits) : '-';
function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCount: number; total: number }) {
function StatusBadge({
loading,
errorCount,
total,
}: {
loading: boolean;
errorCount: number;
total: number;
}) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
@ -107,7 +115,18 @@ function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCo
}
function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
const headers = ['관측소', '수온(°C)', '기온(°C)', '기압(hPa)', '풍향(°)', '풍속(m/s)', '유향(°)', '유속(m/s)', '조위(cm)', '상태'];
const headers = [
'관측소',
'수온(°C)',
'기온(°C)',
'기압(hPa)',
'풍향(°)',
'풍속(m/s)',
'유향(°)',
'유속(m/s)',
'조위(cm)',
'상태',
];
return (
<div className="overflow-auto">
@ -115,7 +134,12 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
@ -131,8 +155,13 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<tr
key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName}
</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.water_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_pres)}</td>
@ -159,7 +188,16 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
}
function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolean }) {
const headers = ['지점', '기온(°C)', '풍속(m/s)', '풍향(°)', '파고(m)', '강수(mm)', '습도(%)', '상태'];
const headers = [
'지점',
'기온(°C)',
'풍속(m/s)',
'풍향(°)',
'파고(m)',
'강수(mm)',
'습도(%)',
'상태',
];
return (
<div className="overflow-auto">
@ -167,7 +205,12 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
@ -183,8 +226,13 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<tr
key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName}
</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windDirection, 0)}</td>
@ -217,7 +265,12 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
@ -274,9 +327,7 @@ export default function MonitorRealtimePanel() {
const fetchKhoa = useCallback(async () => {
setKhoaLoading(true);
const results = await Promise.allSettled(
STATIONS.map((s) => getRecentObservation(s.code))
);
const results = await Promise.allSettled(STATIONS.map((s) => getRecentObservation(s.code)));
const rows: KhoaRow[] = STATIONS.map((s, i) => {
const result = results[i];
if (result.status === 'fulfilled') {
@ -296,7 +347,7 @@ export default function MonitorRealtimePanel() {
WEATHER_STATIONS.map((s) => {
const { nx, ny } = convertToGridCoords(s.lat, s.lon);
return getUltraShortForecast(nx, ny, baseDate, baseTime);
})
}),
);
const rows: KmaUltraRow[] = WEATHER_STATIONS.map((s, i) => {
const result = results[i];
@ -317,7 +368,7 @@ export default function MonitorRealtimePanel() {
const tmFc = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}00`;
const results = await Promise.allSettled(
MARINE_REGION_LIST.map((r) => getMarineForecast(r.regId, tmFc))
MARINE_REGION_LIST.map((r) => getMarineForecast(r.regId, tmFc)),
);
const rows: MarineRow[] = MARINE_REGION_LIST.map((r, i) => {
const result = results[i];
@ -336,15 +387,31 @@ export default function MonitorRealtimePanel() {
let isMounted = true;
if (activeTab === 'khoa' && khoaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKhoa(); });
void Promise.resolve().then(() => {
if (isMounted) void fetchKhoa();
});
} else if (activeTab === 'kma-ultra' && kmaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKmaUltra(); });
void Promise.resolve().then(() => {
if (isMounted) void fetchKmaUltra();
});
} else if (activeTab === 'kma-marine' && marineRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchMarine(); });
void Promise.resolve().then(() => {
if (isMounted) void fetchMarine();
});
}
return () => { isMounted = false; };
}, [activeTab, khoaRows.length, kmaRows.length, marineRows.length, fetchKhoa, fetchKmaUltra, fetchMarine]);
return () => {
isMounted = false;
};
}, [
activeTab,
khoaRows.length,
kmaRows.length,
marineRows.length,
fetchKhoa,
fetchKmaUltra,
fetchMarine,
]);
const handleRefresh = () => {
if (activeTab === 'khoa') fetchKhoa();
@ -352,10 +419,17 @@ export default function MonitorRealtimePanel() {
else fetchMarine();
};
const isLoading = activeTab === 'khoa' ? khoaLoading : activeTab === 'kma-ultra' ? kmaLoading : marineLoading;
const currentRows = activeTab === 'khoa' ? khoaRows : activeTab === 'kma-ultra' ? kmaRows : marineRows;
const isLoading =
activeTab === 'khoa' ? khoaLoading : activeTab === 'kma-ultra' ? kmaLoading : marineLoading;
const currentRows =
activeTab === 'khoa' ? khoaRows : activeTab === 'kma-ultra' ? kmaRows : marineRows;
const errorCount = currentRows.filter((r) => r.error).length;
const totalCount = activeTab === 'khoa' ? STATIONS.length : activeTab === 'kma-ultra' ? WEATHER_STATIONS.length : MARINE_REGION_LIST.length;
const totalCount =
activeTab === 'khoa'
? STATIONS.length
: activeTab === 'kma-ultra'
? WEATHER_STATIONS.length
: MARINE_REGION_LIST.length;
const TABS: { id: TabId; label: string }[] = [
{ id: 'khoa', label: 'KHOA 조위관측소' },
@ -371,7 +445,12 @@ export default function MonitorRealtimePanel() {
<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' })}
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
@ -385,7 +464,12 @@ export default function MonitorRealtimePanel() {
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" />
<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>
@ -421,15 +505,9 @@ export default function MonitorRealtimePanel() {
{/* 테이블 콘텐츠 */}
<div className="flex-1 overflow-auto p-5">
{activeTab === 'khoa' && (
<KhoaTable rows={khoaRows} loading={khoaLoading} />
)}
{activeTab === 'kma-ultra' && (
<KmaUltraTable rows={kmaRows} loading={kmaLoading} />
)}
{activeTab === 'kma-marine' && (
<MarineTable rows={marineRows} loading={marineLoading} />
)}
{activeTab === 'khoa' && <KhoaTable rows={khoaRows} loading={khoaLoading} />}
{activeTab === 'kma-ultra' && <KmaUltraTable rows={kmaRows} loading={kmaLoading} />}
{activeTab === 'kma-marine' && <MarineTable rows={marineRows} loading={marineLoading} />}
</div>
</div>
);

파일 보기

@ -31,28 +31,248 @@ const INSTITUTION_NAMES: Record<string, string> = {
/** Mock 데이터 정의 (스크린샷 기반) */
const MOCK_DATA: Omit<VesselMonitorRow, 'institution'>[] = [
{ institutionCode: 'BS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '439 / 499', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'BS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '133 / 463', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'BSN', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '255 / 278', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'BSN', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '133 / 426', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'DH', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'DH', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'DS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '0', isNormal: false, lastMessageTime: '2026-03-15 15:38:57' },
{ institutionCode: 'DS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '0', isNormal: false, lastMessageTime: '2026-03-15 15:38:56' },
{ institutionCode: 'GI', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '120 / 136', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'GI', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '55 / 467', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'GIC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '180 / 216', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'GIC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'GS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'GS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'IC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '149 / 176', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'IC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '55 / 503', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'JDC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '433 / 524', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'JDC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '256 / 1619', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'JJ', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '429 / 508', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'JJ', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '160 / 1592', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' },
{ institutionCode: 'MP', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{ institutionCode: 'MP', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' },
{
institutionCode: 'BS',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '439 / 499',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'BS',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '133 / 463',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'BSN',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '255 / 278',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'BSN',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '133 / 426',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'DH',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'DH',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'DS',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '0',
isNormal: false,
lastMessageTime: '2026-03-15 15:38:57',
},
{
institutionCode: 'DS',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '0',
isNormal: false,
lastMessageTime: '2026-03-15 15:38:56',
},
{
institutionCode: 'GI',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '120 / 136',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'GI',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '55 / 467',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'GIC',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '180 / 216',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'GIC',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'GS',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'GS',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'IC',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '149 / 176',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'IC',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '55 / 503',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'JDC',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '433 / 524',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'JDC',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '256 / 1619',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'JJ',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '429 / 508',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'JJ',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '00:00:00',
collectionCount: '160 / 1592',
isNormal: true,
lastMessageTime: '2026-03-25 10:29:09',
},
{
institutionCode: 'MP',
systemName: 'VTS_AIS',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
{
institutionCode: 'MP',
systemName: 'VTS_RT',
linkInfo: 'VTS',
storagePlace: 'signal.t_dynamic_all_reply',
linkMethod: 'KAFKA',
collectionCycle: '수신대기중',
collectionCount: '0',
isNormal: false,
lastMessageTime: '',
},
];
/** Mock fetch — TODO: 실제 API 연동 시 fetch 호출로 교체 */
@ -69,7 +289,15 @@ function fetchVesselMonitorData(): Promise<VesselMonitorRow[]> {
}
/* ── 상태 뱃지 ── */
function StatusBadge({ loading, onCount, total }: { loading: boolean; onCount: number; total: number }) {
function StatusBadge({
loading,
onCount,
total,
}: {
loading: boolean;
onCount: number;
total: number;
}) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
@ -104,16 +332,20 @@ function StatusBadge({ loading, onCount, total }: { loading: boolean; onCount: n
}
/* ── 연결상태 셀 ── */
function ConnectionBadge({ isNormal, lastMessageTime }: { isNormal: boolean; lastMessageTime: string }) {
function ConnectionBadge({
isNormal,
lastMessageTime,
}: {
isNormal: boolean;
lastMessageTime: string;
}) {
if (isNormal) {
return (
<div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-blue-600 text-white">
ON
</span>
{lastMessageTime && (
<span className="text-[10px] text-t3">{lastMessageTime}</span>
)}
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
</div>
);
}
@ -122,15 +354,24 @@ function ConnectionBadge({ isNormal, lastMessageTime }: { isNormal: boolean; las
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-orange-500 text-white">
OFF
</span>
{lastMessageTime && (
<span className="text-[10px] text-t3">{lastMessageTime}</span>
)}
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
</div>
);
}
/* ── 테이블 ── */
const HEADERS = ['번호', '원천기관', '기관코드', '정보시스템명', '연계정보', '저장장소', '연계방식', '수집주기', '선박건수/신호건수', '연결상태'];
const HEADERS = [
'번호',
'원천기관',
'기관코드',
'정보시스템명',
'연계정보',
'저장장소',
'연계방식',
'수집주기',
'선박건수/신호건수',
'연결상태',
];
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
return (
@ -139,7 +380,12 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{HEADERS.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">{h}</th>
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
@ -155,9 +401,14 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
</tr>
))
: rows.map((row, idx) => (
<tr key={`${row.institutionCode}-${row.systemName}`} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr
key={`${row.institutionCode}-${row.systemName}`}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
>
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.institution}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.institution}
</td>
<td className="px-3 py-2 text-t2">{row.institutionCode}</td>
<td className="px-3 py-2 text-t2">{row.systemName}</td>
<td className="px-3 py-2 text-t2">{row.linkInfo}</td>
@ -166,7 +417,10 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
<td className="px-3 py-2 text-t2">{row.collectionCycle}</td>
<td className="px-3 py-2 text-t2 text-center">{row.collectionCount}</td>
<td className="px-3 py-2">
<ConnectionBadge isNormal={row.isNormal} lastMessageTime={row.lastMessageTime} />
<ConnectionBadge
isNormal={row.isNormal}
lastMessageTime={row.lastMessageTime}
/>
</td>
</tr>
))}
@ -193,9 +447,13 @@ export default function MonitorVesselPanel() {
useEffect(() => {
let isMounted = true;
if (rows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchData(); });
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
}
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [rows.length, fetchData]);
const onCount = rows.filter((r) => r.isNormal).length;
@ -208,7 +466,12 @@ export default function MonitorVesselPanel() {
<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' })}
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
@ -222,7 +485,12 @@ export default function MonitorVesselPanel() {
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" />
<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>

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

파일 보기

@ -41,7 +41,9 @@ async function fetchSensitiveLayers(
}
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', {
layerCd,
});
return res.data;
}
@ -60,20 +62,23 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
const [appliedSearch, setAppliedSearch] = useState('');
const [filterUseYn, setFilterUseYn] = useState('');
const load = useCallback(async (p: number, search: string, useYn: string) => {
setLoading(true);
setError(null);
try {
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
setItems(res.items);
setTotal(res.total);
setTotalPages(res.totalPages);
} catch {
setError('레이어 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [categoryCode]);
const load = useCallback(
async (p: number, search: string, useYn: string) => {
setLoading(true);
setError(null);
try {
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
setItems(res.items);
setTotal(res.total);
setTotalPages(res.totalPages);
} catch {
setError('레이어 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
},
[categoryCode],
);
useEffect(() => {
setPage(1);
@ -96,10 +101,10 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
setToggling(layerCd);
try {
const result = await toggleLayerUse(layerCd);
setItems(prev =>
prev.map(item =>
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
)
setItems((prev) =>
prev.map((item) =>
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item,
),
);
} catch {
setError('사용여부 변경에 실패했습니다.');
@ -138,14 +143,14 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
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"
/>
<select
value={filterUseYn}
onChange={e => setFilterUseYn(e.target.value)}
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"
>
<option value=""></option>
@ -178,21 +183,42 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">WMS레이어명</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
WMS레이어명
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">
</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-12 text-center text-fg-disabled text-sm font-korean">
<td
colSpan={9}
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
>
.
</td>
</tr>
@ -205,12 +231,8 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3 text-[11px] 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-[11px] 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]">
<span className="block truncate" title={item.layerFullNm}>
{item.layerFullNm}
@ -234,7 +256,11 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<button
onClick={() => handleToggle(item.layerCd)}
disabled={toggling === item.layerCd}
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
title={
item.useYn === 'Y'
? '사용 중 (클릭하여 비활성화)'
: '미사용 (클릭하여 활성화)'
}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
item.useYn === 'Y'
? 'bg-color-accent'
@ -264,7 +290,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
@ -272,7 +298,9 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
</button>
{buildPageButtons().map((btn, i) =>
btn === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled"></span>
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">
</span>
) : (
<button
key={btn}
@ -285,10 +313,10 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
>
{btn}
</button>
)
),
)}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react';
import {
fetchRegistrationSettings,
updateRegistrationSettingsApi,
@ -6,61 +6,67 @@ import {
updateOAuthSettingsApi,
type RegistrationSettings,
type OAuthSettings,
} from '@common/services/authApi'
} from '@common/services/authApi';
// ─── 시스템 설정 패널 ────────────────────────────────────────
function SettingsPanel() {
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null)
const [oauthDomainInput, setOauthDomainInput] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [savingOAuth, setSavingOAuth] = useState(false)
const [settings, setSettings] = useState<RegistrationSettings | null>(null);
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null);
const [oauthDomainInput, setOauthDomainInput] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingOAuth, setSavingOAuth] = useState(false);
useEffect(() => {
loadSettings()
}, [])
loadSettings();
}, []);
const loadSettings = async () => {
setLoading(true)
setLoading(true);
try {
const [regData, oauthData] = await Promise.all([
fetchRegistrationSettings(),
fetchOAuthSettings(),
])
setSettings(regData)
setOauthSettings(oauthData)
setOauthDomainInput(oauthData.autoApproveDomains)
]);
setSettings(regData);
setOauthSettings(oauthData);
setOauthDomainInput(oauthData.autoApproveDomains);
} catch (err) {
console.error('설정 조회 실패:', err)
console.error('설정 조회 실패:', err);
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleToggle = async (key: keyof RegistrationSettings) => {
if (!settings) return
const newValue = !settings[key]
setSaving(true)
if (!settings) return;
const newValue = !settings[key];
setSaving(true);
try {
const updated = await updateRegistrationSettingsApi({ [key]: newValue })
setSettings(updated)
const updated = await updateRegistrationSettingsApi({ [key]: newValue });
setSettings(updated);
} catch (err) {
console.error('설정 변경 실패:', err)
console.error('설정 변경 실패:', err);
} finally {
setSaving(false)
setSaving(false);
}
}
};
if (loading) {
return <div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean"> ...</div>
return (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
...
</div>
);
}
return (
<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>
<p className="text-xs text-fg-disabled mt-1 font-korean">
</p>
</div>
<div className="flex-1 overflow-auto px-6 py-6">
@ -69,7 +75,9 @@ 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>
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean"> </p>
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">
</p>
</div>
<div className="divide-y divide-border">
@ -78,8 +86,11 @@ function SettingsPanel() {
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-fg font-korean"> </div>
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
{' '}
<span className="text-green-400 font-semibold">ACTIVE</span> .
{' '}
<span className="text-yellow-400 font-semibold">PENDING</span>
.
</p>
</div>
<button
@ -100,10 +111,13 @@ function SettingsPanel() {
{/* 기본 역할 자동 할당 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-fg font-korean"> </div>
<div className="text-[13px] font-semibold text-fg font-korean">
</div>
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
<span className="text-color-accent font-semibold"> </span> .
.
{' '}
<span className="text-color-accent font-semibold"> </span>
. .
</p>
</div>
<button
@ -127,15 +141,20 @@ 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">Google OAuth </h2>
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">Google </p>
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">
Google
</p>
</div>
<div className="px-5 py-4">
<div className="flex-1 mr-4 mb-3">
<div className="text-[13px] font-semibold text-fg font-korean mb-1"> </div>
<div className="text-[13px] font-semibold text-fg font-korean mb-1">
</div>
<p className="text-[11px] text-fg-disabled font-korean leading-relaxed mb-3">
Google <span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
(,) .
Google {' '}
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span>
. (,) .
</p>
<div className="flex gap-2">
<input
@ -147,18 +166,23 @@ function SettingsPanel() {
/>
<button
onClick={async () => {
setSavingOAuth(true)
setSavingOAuth(true);
try {
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
setOauthSettings(updated)
setOauthDomainInput(updated.autoApproveDomains)
const updated = await updateOAuthSettingsApi({
autoApproveDomains: oauthDomainInput.trim(),
});
setOauthSettings(updated);
setOauthDomainInput(updated.autoApproveDomains);
} catch (err) {
console.error('OAuth 설정 변경 실패:', err)
console.error('OAuth 설정 변경 실패:', err);
} finally {
setSavingOAuth(false)
setSavingOAuth(false);
}
}}
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
disabled={
savingOAuth ||
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
}
className={`px-4 py-2 text-xs 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)]'
@ -171,15 +195,23 @@ function SettingsPanel() {
</div>
{oauthSettings?.autoApproveDomains && (
<div className="flex flex-wrap gap-1.5 mt-3">
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--color-accent)', border: '1px solid rgba(6,182,212,0.25)' }}
>
@{domain}
</span>
))}
{oauthSettings.autoApproveDomains
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((domain) => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
style={{
background: 'rgba(6,182,212,0.1)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,0.25)',
}}
>
@{domain}
</span>
))}
</div>
)}
</div>
@ -193,7 +225,9 @@ function SettingsPanel() {
<div className="px-5 py-4">
<div className="flex flex-col gap-3 text-[12px] font-korean">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
<span
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`}
/>
<span className="text-fg-sub">
{' '}
{settings?.autoApprove ? (
@ -204,7 +238,9 @@ function SettingsPanel() {
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`} />
<span
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`}
/>
<span className="text-fg-sub">
{' '}
{settings?.defaultRole ? (
@ -215,11 +251,15 @@ function SettingsPanel() {
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`} />
<span
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`}
/>
<span className="text-fg-sub">
Google OAuth {' '}
{oauthSettings?.autoApproveDomains ? (
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
<span className="text-blue-400 font-semibold font-mono">
{oauthSettings.autoApproveDomains}
</span>
) : (
<span className="text-fg-disabled font-semibold"></span>
)}
@ -231,7 +271,7 @@ function SettingsPanel() {
</div>
</div>
</div>
)
);
}
export default SettingsPanel
export default SettingsPanel;

파일 보기

@ -1,54 +1,58 @@
import data from '@emoji-mart/data'
import EmojiPicker from '@emoji-mart/react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { type MenuConfigItem } from '@common/services/authApi'
import data from '@emoji-mart/data';
import EmojiPicker from '@emoji-mart/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { type MenuConfigItem } from '@common/services/authApi';
// ─── 메뉴 항목 (Sortable) ────────────────────────────────────
export interface SortableMenuItemProps {
menu: MenuConfigItem
idx: number
totalCount: number
isEditing: boolean
emojiPickerId: string | null
emojiPickerRef: React.RefObject<HTMLDivElement | null>
onToggle: (id: string) => void
onMove: (idx: number, direction: -1 | 1) => void
onEditStart: (id: string) => void
onEditEnd: () => void
onEmojiPickerToggle: (id: string | null) => void
onLabelChange: (id: string, value: string) => void
onEmojiSelect: (emoji: { native: string }) => void
menu: MenuConfigItem;
idx: number;
totalCount: number;
isEditing: boolean;
emojiPickerId: string | null;
emojiPickerRef: React.RefObject<HTMLDivElement | null>;
onToggle: (id: string) => void;
onMove: (idx: number, direction: -1 | 1) => void;
onEditStart: (id: string) => void;
onEditEnd: () => void;
onEmojiPickerToggle: (id: string | null) => void;
onLabelChange: (id: string, value: string) => void;
onEmojiSelect: (emoji: { native: string }) => void;
}
function SortableMenuItem({
menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef,
onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect,
menu,
idx,
totalCount,
isEditing,
emojiPickerId,
emojiPickerRef,
onToggle,
onMove,
onEditStart,
onEditEnd,
onEmojiPickerToggle,
onLabelChange,
onEmojiSelect,
}: SortableMenuItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: menu.id })
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: menu.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 50 : undefined,
}
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
menu.enabled
? 'bg-bg-surface border-stroke'
: 'bg-bg-base border-stroke opacity-50'
menu.enabled ? 'bg-bg-surface border-stroke' : 'bg-bg-base border-stroke opacity-50'
}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
@ -59,12 +63,17 @@ function SortableMenuItem({
title="드래그하여 순서 변경"
>
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
<circle cx="3" cy="2" r="1.5" />
<circle cx="9" cy="2" r="1.5" />
<circle cx="3" cy="8" r="1.5" />
<circle cx="9" cy="8" r="1.5" />
<circle cx="3" cy="14" r="1.5" />
<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">{idx + 1}</span>
<span className="text-fg-disabled text-xs font-mono w-6 text-center shrink-0">
{idx + 1}
</span>
{isEditing ? (
<>
<div className="relative shrink-0">
@ -109,7 +118,9 @@ function SortableMenuItem({
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}>
<div
className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}
>
{menu.label}
</div>
<div className="text-[10px] text-fg-disabled font-mono">{menu.id}</div>
@ -155,7 +166,7 @@ function SortableMenuItem({
</div>
</div>
</div>
)
);
}
export default SortableMenuItem
export default SortableMenuItem;

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

파일 보기

@ -6,9 +6,17 @@ import { typeTagCls } from '@tabs/assets/components/assetTypes';
const PAGE_SIZE = 20;
const regionShort = (j: string) =>
j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' :
j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' :
j.includes('제주') ? '제주청' : j;
j.includes('남해')
? '남해청'
: j.includes('서해')
? '서해청'
: j.includes('중부')
? '중부청'
: j.includes('동해')
? '동해청'
: j.includes('제주')
? '제주청'
: j;
function VesselMaterialsPanel() {
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
@ -22,41 +30,51 @@ function VesselMaterialsPanel() {
setLoading(true);
fetchOrganizations()
.then(setOrganizations)
.catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
.catch((err) => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
let cancelled = false;
fetchOrganizations()
.then(data => { if (!cancelled) setOrganizations(data); })
.catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.then((data) => {
if (!cancelled) setOrganizations(data);
})
.catch((err) => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const typeOptions = useMemo(() => {
const set = new Set(organizations.map(o => o.type));
const set = new Set(organizations.map((o) => o.type));
return Array.from(set).sort();
}, [organizations]);
const filtered = useMemo(() =>
organizations
.filter(o => o.vessel > 0)
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter(o => typeFilter === '전체' || o.type === typeFilter)
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
[organizations, regionFilter, typeFilter, searchTerm]
const filtered = useMemo(
() =>
organizations
.filter((o) => o.vessel > 0)
.filter((o) => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter((o) => typeFilter === '전체' || o.type === typeFilter)
.filter(
(o) => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm),
),
[organizations, regionFilter, typeFilter, searchTerm],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setter(e.target.value);
setCurrentPage(1);
};
const handleFilterChange =
(setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setter(e.target.value);
setCurrentPage(1);
};
const pageNumbers = (() => {
const range: number[] = [];
@ -72,7 +90,9 @@ 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"> {filtered.length} ( )</p>
<p className="text-xs text-fg-disabled mt-1 font-korean">
{filtered.length} ( )
</p>
</div>
<div className="flex items-center gap-3">
<select
@ -93,15 +113,20 @@ function VesselMaterialsPanel() {
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"
>
<option value="전체"> </option>
{typeOptions.map(t => (
<option key={t} value={t}>{t}</option>
{typeOptions.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<input
type="text"
placeholder="기관명, 주소 검색..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
onChange={(e) => {
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"
/>
<button
@ -123,65 +148,97 @@ function VesselMaterialsPanel() {
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-color-accent bg-color-accent/5"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-color-accent bg-color-accent/5">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">
</th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-xs text-fg-disabled font-korean">
<td
colSpan={11}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
>
.
</td>
</tr>
) : paged.map((org, idx) => (
<tr key={org.id} className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
{org.address}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-color-accent font-semibold bg-color-accent/5">
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.pump > 0 ? org.pump : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
))}
) : (
paged.map((org, idx) => (
<tr
key={org.id}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
{org.address}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-color-accent font-semibold bg-color-accent/5">
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.pump > 0 ? org.pump : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
{org.totalAssets.toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
)}
@ -194,17 +251,57 @@ function VesselMaterialsPanel() {
({filtered.length} )
</span>
{[
{ label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척', active: true },
{ label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대', active: false },
{ label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대', active: false },
{ label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대', active: false },
{ label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대', active: false },
{ label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '', active: true },
{
label: '방제선',
value: filtered.reduce((s, o) => s + o.vessel, 0),
unit: '척',
active: true,
},
{
label: '유회수기',
value: filtered.reduce((s, o) => s + o.skimmer, 0),
unit: '대',
active: false,
},
{
label: '이송펌프',
value: filtered.reduce((s, o) => s + o.pump, 0),
unit: '대',
active: false,
},
{
label: '방제차량',
value: filtered.reduce((s, o) => s + o.vehicle, 0),
unit: '대',
active: false,
},
{
label: '살포장치',
value: filtered.reduce((s, o) => s + o.sprayer, 0),
unit: '대',
active: false,
},
{
label: '총자산',
value: filtered.reduce((s, o) => s + o.totalAssets, 0),
unit: '',
active: true,
},
].map((t) => (
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-color-accent/10' : ''}`}>
<span className={`text-[9px] font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}>{t.label}</span>
<span className={`text-[10px] font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}>
{t.value.toLocaleString()}{t.unit}
<div
key={t.label}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-color-accent/10' : ''}`}
>
<span
className={`text-[9px] font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}
>
{t.label}
</span>
<span
className={`text-[10px] font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}
>
{t.value.toLocaleString()}
{t.unit}
</span>
</div>
))}
@ -215,31 +312,37 @@ function VesselMaterialsPanel() {
{!loading && filtered.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
<span className="text-[11px] text-fg-disabled font-korean">
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} / {filtered.length}
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} /
{filtered.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage === 1}
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>
&lt;
</button>
{pageNumbers.map(p => (
{pageNumbers.map((p) => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
style={p === safePage
? { borderColor: 'var(--color-accent)', color: 'var(--color-accent)', background: 'rgba(6,182,212,0.1)' }
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
style={
p === safePage
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
>
{p}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage === totalPages}
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
>

파일 보기

@ -5,24 +5,24 @@ const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as cons
type SignalSource = (typeof SIGNAL_SOURCES)[number];
interface SignalSlot {
time: string; // HH:mm
time: string; // HH:mm
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
none: 'rgba(255,255,255,0.06)',
none: 'rgba(255,255,255,0.06)',
};
const HOURS = Array.from({ length: 24 }, (_, i) => i);
@ -39,7 +39,10 @@ function generateTimeSlots(date: string): SignalSlot[] {
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
const sources = {} as Record<
SignalSource,
{ count: number; status: 'ok' | 'warn' | 'error' | 'none' }
>;
for (const src of SIGNAL_SOURCES) {
if (!isPast) {
sources[src] = { count: 0, status: 'none' };
@ -64,11 +67,21 @@ function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSou
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
return (
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
<div
className="w-full h-5 overflow-hidden flex"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
{slots.map((slot, i) => {
const s = slot.sources[source];
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
const statusLabel =
s.status === 'ok'
? '정상'
: s.status === 'warn'
? '지연'
: s.status === 'error'
? '오류'
: '미수신';
return (
<div
@ -108,8 +121,11 @@ export default function VesselSignalPanel() {
}, [load]);
// 통계 계산
const stats = SIGNAL_SOURCES.map(src => {
let total = 0, ok = 0, warn = 0, error = 0;
const stats = SIGNAL_SOURCES.map((src) => {
let total = 0,
ok = 0,
warn = 0,
error = 0;
for (const slot of slots) {
const s = slot.sources[src];
if (s.status !== 'none') {
@ -131,7 +147,7 @@ export default function VesselSignalPanel() {
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
onChange={(e) => setDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg"
/>
<button
@ -155,12 +171,18 @@ export default function VesselSignalPanel() {
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map(src => {
{SIGNAL_SOURCES.map((src) => {
const c = SOURCE_COLORS[src];
const st = stats.find(s => s.src === src)!;
const st = stats.find((s) => s.src === src)!;
return (
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
<div
key={src}
className="flex flex-col justify-center mb-4"
style={{ height: 20 }}
>
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>
{src}
</span>
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
</div>
);
@ -171,7 +193,7 @@ export default function VesselSignalPanel() {
<div className="flex-1 min-w-0 flex flex-col">
{/* 시간 축 (상단) */}
<div className="relative h-5 mb-3">
{HOURS.map(h => (
{HOURS.map((h) => (
<span
key={h}
className="absolute text-[10px] text-fg-disabled font-mono"
@ -189,7 +211,7 @@ export default function VesselSignalPanel() {
</div>
{/* 소스별 타임라인 바 */}
{SIGNAL_SOURCES.map(src => (
{SIGNAL_SOURCES.map((src) => (
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
<TimelineBar slots={slots} source={src} />
</div>
@ -198,7 +220,6 @@ export default function VesselSignalPanel() {
</div>
)}
</div>
</div>
);
}

파일 보기

@ -4,14 +4,19 @@ export const DEFAULT_ROLE_COLORS: Record<string, string> = {
MANAGER: 'var(--color-warning)',
USER: 'var(--color-accent)',
VIEWER: 'var(--fg-disabled)',
}
};
export const CUSTOM_ROLE_COLORS = [
'#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf',
]
'#a78bfa',
'#34d399',
'#f472b6',
'#fbbf24',
'#60a5fa',
'#2dd4bf',
];
export function getRoleColor(code: string, index: number): string {
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length]
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length];
}
export const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
@ -20,6 +25,6 @@ export const statusLabels: Record<string, { label: string; color: string; dot: s
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
INACTIVE: { label: '비활성', color: 'text-fg-disabled', dot: 'bg-fg-disabled' },
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
}
};
// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel)

파일 보기

@ -9,21 +9,27 @@ export interface AdminMenuItem {
export const ADMIN_MENU: AdminMenuItem[] = [
{
id: 'env-settings', label: '환경설정', icon: '⚙️',
id: 'env-settings',
label: '환경설정',
icon: '⚙️',
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
],
},
{
id: 'user-info', label: '사용자정보', icon: '👥',
id: 'user-info',
label: '사용자정보',
icon: '👥',
children: [
{ id: 'users', label: '사용자관리' },
{ id: 'permissions', label: '권한관리' },
],
},
{
id: 'board-mgmt', label: '게시판관리', icon: '📋',
id: 'board-mgmt',
label: '게시판관리',
icon: '📋',
children: [
{ id: 'notice', label: '공지사항' },
{ id: 'board', label: '게시판' },
@ -31,24 +37,29 @@ export const ADMIN_MENU: AdminMenuItem[] = [
],
},
{
id: 'reference', label: '기준정보', icon: '🗺️',
id: 'reference',
label: '기준정보',
icon: '🗺️',
children: [
{
id: 'map-mgmt', label: '지도관리',
id: 'map-mgmt',
label: '지도관리',
children: [
{ id: 'map-base', label: '지도백데이터' },
{ id: 'map-layer', label: '레이어' },
],
},
{
id: 'sensitive-map', label: '민감자원지도',
id: 'sensitive-map',
label: '민감자원지도',
children: [
{ id: 'env-ecology', label: '환경/생태' },
{ id: 'social-economy', label: '사회/경제' },
],
},
{
id: 'coast-guard-assets', label: '해경자산',
id: 'coast-guard-assets',
label: '해경자산',
children: [
{ id: 'cleanup-equip', label: '방제장비' },
{ id: 'asset-upload', label: '자산현행화' },
@ -59,17 +70,21 @@ export const ADMIN_MENU: AdminMenuItem[] = [
],
},
{
id: 'external', label: '연계관리', icon: '🔗',
id: 'external',
label: '연계관리',
icon: '🔗',
children: [
{
id: 'collection', label: '수집자료',
id: 'collection',
label: '수집자료',
children: [
{ id: 'collect-vessel-signal', label: '선박신호' },
{ id: 'collect-hr', label: '인사정보' },
],
},
{
id: 'monitoring', label: '연계모니터링',
id: 'monitoring',
label: '연계모니터링',
children: [
{ id: 'monitor-realtime', label: '실시간 관측자료' },
{ id: 'monitor-forecast', label: '수치예측자료' },

파일 보기

@ -1 +1 @@
export { AdminView } from './components/AdminView'
export { AdminView } from './components/AdminView';

파일 보기

@ -1,5 +1,5 @@
import { useState } from 'react'
import { sanitizeHtml } from '@common/utils/sanitize'
import { useState } from 'react';
import { sanitizeHtml } from '@common/utils/sanitize';
const panels = [
{ id: 0, icon: '🌐', label: '개요' },
@ -9,23 +9,31 @@ const panels = [
{ id: 4, icon: '📏', label: '면적 산정' },
{ id: 5, icon: '🔗', label: '확산예측 연계' },
{ id: 6, icon: '📚', label: '논문·특허' },
]
];
function getHtml(panelId: number): string {
switch (panelId) {
case 0: return panel0Html
case 1: return panel1Html
case 2: return panel2Html
case 3: return panel3Html
case 4: return panel4Html
case 5: return panel5Html
case 6: return panel6Html
default: return ''
case 0:
return panel0Html;
case 1:
return panel1Html;
case 2:
return panel2Html;
case 3:
return panel3Html;
case 4:
return panel4Html;
case 5:
return panel5Html;
case 6:
return panel6Html;
default:
return '';
}
}
export function AerialTheoryView() {
const [activePanel, setActivePanel] = useState(0)
const [activePanel, setActivePanel] = useState(0);
return (
<div className="flex flex-col h-full w-full flex-1 overflow-hidden bg-bg-base">
@ -33,17 +41,27 @@ export function AerialTheoryView() {
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center text-xl border" style={{ background: 'linear-gradient(135deg,rgba(249,115,22,.2),rgba(234,179,8,.15))', borderColor: 'rgba(249,115,22,.3)' }}>📐</div>
<div
className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center text-xl border"
style={{
background: 'linear-gradient(135deg,rgba(249,115,22,.2),rgba(234,179,8,.15))',
borderColor: 'rgba(249,115,22,.3)',
}}
>
📐
</div>
<div>
<div className="text-base font-bold"> · </div>
<div className="text-[10px] text-fg-disabled mt-0.5"> · · ESI · 10-1567431 </div>
<div className="text-[10px] text-fg-disabled mt-0.5">
· · ESI · 10-1567431
</div>
</div>
</div>
</div>
{/* 내부 네비게이션 */}
<div className="flex gap-[3px] bg-bg-card rounded-lg p-1 mb-5 border border-stroke">
{panels.map(p => (
{panels.map((p) => (
<button
key={p.id}
onClick={() => setActivePanel(p.id)}
@ -62,7 +80,7 @@ export function AerialTheoryView() {
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(getHtml(activePanel)) }} />
</div>
</div>
)
);
}
// ═══ PANEL 0: 개요 ═══
@ -158,7 +176,7 @@ const panel0Html = `
</div>
</div>
</div>
`
`;
// ═══ PANEL 1: 탐지 장비 ═══
const panel1Html = `
@ -249,7 +267,7 @@ const panel1Html = `
</div>
</div>
</div>
`
`;
// ═══ PANEL 2: 원격탐사 ═══
const panel2Html = `
@ -306,7 +324,7 @@ const panel2Html = `
</div>
</div>
</div>
`
`;
// ═══ PANEL 3: ESI 방제지도 ═══
const panel3Html = `
@ -355,7 +373,7 @@ const panel3Html = `
ESI 1999~2002( 3) <b style="color:var(--fg-default)">25,000:1 </b> . · (ENC) . ESI DB와 .
</div>
</div>
`
`;
// ═══ PANEL 4: 면적 산정 ═══
const panel4Html = `
@ -412,7 +430,7 @@ const panel4Html = `
</div>
</div>
</div>
`
`;
// ═══ PANEL 5: 확산예측 연계 ═══
const panel5Html = `
@ -469,7 +487,7 @@ const panel5Html = `
</div>
<div style="font-size:9px;color:var(--fg-disabled);font-family:var(--font-korean);text-align:center;margin-top:8px">·· CHARRY + (S10S20S30)</div>
</div>
`
`;
// ═══ PANEL 6: 논문·특허 ═══
const panel6Html = `
@ -580,4 +598,4 @@ const panel6Html = `
<div style="display:grid;grid-template-columns:56px 1fr;gap:7px;padding:5px 8px;background:var(--bg-base);border-radius:4px"><div style="padding:2px 5px;background:rgba(249,115,22,.1);border-radius:3px;color:var(--color-warning);font-weight:700;font-size:8px;text-align:center;height:fit-content"> </div><div style="color:var(--fg-sub);line-height:1.6">Flather &amp; Heaps(1975) (tidal flat) </div></div>
</div>
</div>
`
`;

파일 보기

@ -1,43 +1,41 @@
import { useSubMenu } from '@common/hooks/useSubMenu'
import { AerialTheoryView } from './AerialTheoryView'
import { MediaManagement } from './MediaManagement'
import { OilAreaAnalysis } from './OilAreaAnalysis'
import { RealtimeDrone } from './RealtimeDrone'
import { SensorAnalysis } from './SensorAnalysis'
import { SatelliteRequest } from './SatelliteRequest'
import { WingAI } from './WingAI'
import { CctvView } from './CctvView'
import { useSubMenu } from '@common/hooks/useSubMenu';
import { AerialTheoryView } from './AerialTheoryView';
import { MediaManagement } from './MediaManagement';
import { OilAreaAnalysis } from './OilAreaAnalysis';
import { RealtimeDrone } from './RealtimeDrone';
import { SensorAnalysis } from './SensorAnalysis';
import { SatelliteRequest } from './SatelliteRequest';
import { WingAI } from './WingAI';
import { CctvView } from './CctvView';
export function AerialView() {
const { activeSubTab } = useSubMenu('aerial')
const { activeSubTab } = useSubMenu('aerial');
const renderContent = () => {
switch (activeSubTab) {
case 'theory':
return <AerialTheoryView />
return <AerialTheoryView />;
case 'satellite':
return <SatelliteRequest />
return <SatelliteRequest />;
case 'spectral':
return <WingAI />
return <WingAI />;
case 'cctv':
return <CctvView />
return <CctvView />;
case 'analysis':
return <OilAreaAnalysis />
return <OilAreaAnalysis />;
case 'realtime':
return <RealtimeDrone />
return <RealtimeDrone />;
case 'sensor':
return <SensorAnalysis />
return <SensorAnalysis />;
case 'media':
default:
return <MediaManagement />
return <MediaManagement />;
}
}
};
return (
<div className="flex flex-col h-full w-full bg-bg-base">
<div className="flex-1 overflow-auto px-6 py-5">
{renderContent()}
</div>
<div className="flex-1 overflow-auto px-6 py-5">{renderContent()}</div>
</div>
)
);
}

파일 보기

@ -1,4 +1,12 @@
import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import {
useRef,
useEffect,
useState,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from 'react';
import Hls from 'hls.js';
import { detectStreamType } from '../utils/streamUtils';
import { useOilDetection } from '../hooks/useOilDetection';
@ -30,160 +38,194 @@ function toProxyUrl(url: string): string {
return url;
}
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
const [retryKey, setRetryKey] = useState(0);
/** 원본 URL 기반으로 타입 감지, 재생은 프록시 URL 사용 */
const proxiedUrl = useMemo(
() => (streamUrl ? toProxyUrl(streamUrl) : null),
[streamUrl],
);
/** props 기반으로 상태를 동기적으로 파생 */
const isOffline = sttsCd === 'OFFLINE' || sttsCd === 'MAINT';
const hasNoUrl = !isOffline && (!streamUrl || !proxiedUrl);
const streamType = useMemo(
() => (streamUrl && !isOffline ? detectStreamType(streamUrl) : null),
[streamUrl, isOffline],
);
const playerState: PlayerState = isOffline
? 'offline'
: hasNoUrl
? 'no-url'
: (streamType === 'mjpeg' || streamType === 'iframe')
? 'playing'
: hlsPlayerState;
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
videoRef,
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
});
useImperativeHandle(ref, () => ({
capture: () => {
const container = containerRef.current;
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(2, 2);
// 1) video frame
const video = videoRef.current;
if (video && video.readyState >= 2) {
ctx.drawImage(video, 0, 0, w, h);
}
// 2) oil detection overlay
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
if (overlayCanvas) {
ctx.drawImage(overlayCanvas, 0, 0, w, h);
}
// 3) OSD: camera name + timestamp
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(cameraNm, 18, 23);
const ts = new Date().toLocaleString('ko-KR');
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tsW = ctx.measureText(ts).width + 16;
ctx.fillRect(8, h - 26, tsW, 20);
ctx.fillStyle = '#a0aec0';
ctx.fillText(ts, 16, h - 12);
// 4) oil detection info
if (oilResult && oilResult.regions.length > 0) {
const areaText = oilResult.totalAreaM2 >= 1000
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
ctx.font = 'bold 11px sans-serif';
const atW = ctx.measureText(areaText).width + 16;
ctx.fillStyle = 'rgba(239,68,68,0.25)';
ctx.fillRect(8, h - 48, atW, 18);
ctx.fillStyle = '#f87171';
ctx.fillText(areaText, 16, h - 34);
}
// download
const link = document.createElement('a');
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
(
{
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
},
}), [cameraNm, oilResult]);
ref,
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>(
'loading',
);
const [retryKey, setRetryKey] = useState(0);
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
/** 원본 URL 기반으로 타입 감지, 재생은 프록시 URL 사용 */
const proxiedUrl = useMemo(() => (streamUrl ? toProxyUrl(streamUrl) : null), [streamUrl]);
useEffect(() => {
if (isOffline || hasNoUrl || !streamUrl || !proxiedUrl) {
destroyHls();
return;
}
/** props 기반으로 상태를 동기적으로 파생 */
const isOffline = sttsCd === 'OFFLINE' || sttsCd === 'MAINT';
const hasNoUrl = !isOffline && (!streamUrl || !proxiedUrl);
const streamType = useMemo(
() => (streamUrl && !isOffline ? detectStreamType(streamUrl) : null),
[streamUrl, isOffline],
);
const type = detectStreamType(streamUrl);
queueMicrotask(() => setHlsPlayerState('loading'));
const playerState: PlayerState = isOffline
? 'offline'
: hasNoUrl
? 'no-url'
: streamType === 'mjpeg' || streamType === 'iframe'
? 'playing'
: hlsPlayerState;
if (type === 'hls') {
const video = videoRef.current;
if (!video) return;
const {
result: oilResult,
isAnalyzing: oilAnalyzing,
error: oilError,
} = useOilDetection({
videoRef,
enabled:
oilDetectionEnabled &&
playerState === 'playing' &&
(streamType === 'hls' || streamType === 'mp4'),
});
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxiedUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setHlsPlayerState('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setHlsPlayerState('error');
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
setTimeout(() => hls.startLoad(), 3000);
}
useImperativeHandle(
ref,
() => ({
capture: () => {
const container = containerRef.current;
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(2, 2);
// 1) video frame
const video = videoRef.current;
if (video && video.readyState >= 2) {
ctx.drawImage(video, 0, 0, w, h);
}
});
return () => destroyHls();
// 2) oil detection overlay
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
if (overlayCanvas) {
ctx.drawImage(overlayCanvas, 0, 0, w, h);
}
// 3) OSD: camera name + timestamp
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(cameraNm, 18, 23);
const ts = new Date().toLocaleString('ko-KR');
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tsW = ctx.measureText(ts).width + 16;
ctx.fillRect(8, h - 26, tsW, 20);
ctx.fillStyle = '#a0aec0';
ctx.fillText(ts, 16, h - 12);
// 4) oil detection info
if (oilResult && oilResult.regions.length > 0) {
const areaText =
oilResult.totalAreaM2 >= 1000
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
ctx.font = 'bold 11px sans-serif';
const atW = ctx.measureText(areaText).width + 16;
ctx.fillStyle = 'rgba(239,68,68,0.25)';
ctx.fillRect(8, h - 48, atW, 18);
ctx.fillStyle = '#f87171';
ctx.fillText(areaText, 16, h - 34);
}
// download
const link = document.createElement('a');
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
},
}),
[cameraNm, oilResult],
);
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
useEffect(() => {
if (isOffline || hasNoUrl || !streamUrl || !proxiedUrl) {
destroyHls();
return;
}
// Safari 네이티브 HLS (프록시 경유)
if (video.canPlayType('application/vnd.apple.mpegurl')) {
const type = detectStreamType(streamUrl);
queueMicrotask(() => setHlsPlayerState('loading'));
if (type === 'hls') {
const video = videoRef.current;
if (!video) return;
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxiedUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setHlsPlayerState('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setHlsPlayerState('error');
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
setTimeout(() => hls.startLoad(), 3000);
}
}
});
return () => destroyHls();
}
// Safari 네이티브 HLS (프록시 경유)
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxiedUrl;
const onLoaded = () => setHlsPlayerState('playing');
const onError = () => setHlsPlayerState('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
queueMicrotask(() => setHlsPlayerState('error'));
return;
}
if (type === 'mp4') {
const video = videoRef.current;
if (!video) return;
video.src = proxiedUrl;
const onLoaded = () => setHlsPlayerState('playing');
const onError = () => setHlsPlayerState('error');
@ -196,160 +238,149 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
};
}
if (type === 'mjpeg' || type === 'iframe') {
return;
}
queueMicrotask(() => setHlsPlayerState('error'));
return;
return () => destroyHls();
}, [streamUrl, proxiedUrl, isOffline, hasNoUrl, destroyHls, retryKey]);
// 오프라인
if (playerState === 'offline') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-30 mb-2">📹</div>
<div className="text-[11px] font-korean text-fg-disabled opacity-70">
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
</div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
</div>
);
}
if (type === 'mp4') {
const video = videoRef.current;
if (!video) return;
video.src = proxiedUrl;
const onLoaded = () => setHlsPlayerState('playing');
const onError = () => setHlsPlayerState('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
// URL 미설정
if (playerState === 'no-url') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-20 mb-2">📹</div>
<div className="text-[10px] font-korean text-fg-disabled opacity-50">
URL
</div>
<div className="text-[9px] font-korean text-fg-disabled opacity-30 mt-1">{cameraNm}</div>
</div>
);
}
if (type === 'mjpeg' || type === 'iframe') {
return;
}
queueMicrotask(() => setHlsPlayerState('error'));
return () => destroyHls();
}, [streamUrl, proxiedUrl, isOffline, hasNoUrl, destroyHls, retryKey]);
// 오프라인
if (playerState === 'offline') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-30 mb-2">📹</div>
<div className="text-[11px] font-korean text-fg-disabled opacity-70">
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
</div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
</div>
);
}
// URL 미설정
if (playerState === 'no-url') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-20 mb-2">📹</div>
<div className="text-[10px] font-korean text-fg-disabled opacity-50"> URL </div>
<div className="text-[9px] font-korean text-fg-disabled opacity-30 mt-1">{cameraNm}</div>
</div>
);
}
// 에러
if (playerState === 'error') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-30 mb-2"></div>
<div className="text-[10px] font-korean text-color-danger opacity-70"> </div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
<button
onClick={() => setRetryKey(k => k + 1)}
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
>
</button>
</div>
);
}
return (
<div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */}
{playerState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base z-10">
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
<div className="text-[10px] font-korean text-fg-disabled opacity-50"> ...</div>
</div>
)}
{/* HLS / MP4 */}
{(streamType === 'hls' || streamType === 'mp4') && (
<video
ref={videoRef}
key={`video-${cellIndex}-${retryKey}`}
className="absolute inset-0 w-full h-full object-cover"
muted
autoPlay
playsInline
loop={streamType === 'mp4'}
/>
)}
{/* 오일 감지 오버레이 */}
{oilDetectionEnabled && (
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
)}
{/* MJPEG */}
{streamType === 'mjpeg' && proxiedUrl && (
<img
src={proxiedUrl}
alt={cameraNm}
className="absolute inset-0 w-full h-full object-cover"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* iframe (원본 URL 사용 — iframe은 자체 CORS) */}
{streamType === 'iframe' && streamUrl && (
<iframe
src={streamUrl}
title={cameraNm}
className="absolute inset-0 w-full h-full border-none"
allow="autoplay; encrypted-media"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
{cameraNm}
</span>
{sttsCd === 'LIVE' && (
<span
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
style={{ background: 'rgba(239,68,68,.3)' }}
// 에러
if (playerState === 'error') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
<div className="text-2xl opacity-30 mb-2"></div>
<div className="text-[10px] font-korean text-color-danger opacity-70"> </div>
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
<button
onClick={() => setRetryKey((k) => k + 1)}
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
>
REC
</span>
</button>
</div>
);
}
return (
<div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */}
{playerState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base z-10">
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
<div className="text-[10px] font-korean text-fg-disabled opacity-50"> ...</div>
</div>
)}
{/* HLS / MP4 */}
{(streamType === 'hls' || streamType === 'mp4') && (
<video
ref={videoRef}
key={`video-${cellIndex}-${retryKey}`}
className="absolute inset-0 w-full h-full object-cover"
muted
autoPlay
playsInline
loop={streamType === 'mp4'}
/>
)}
{/* 오일 감지 오버레이 */}
{oilDetectionEnabled && (
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
)}
{/* MJPEG */}
{streamType === 'mjpeg' && proxiedUrl && (
<img
src={proxiedUrl}
alt={cameraNm}
className="absolute inset-0 w-full h-full object-cover"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* iframe (원본 URL 사용 — iframe은 자체 CORS) */}
{streamType === 'iframe' && streamUrl && (
<iframe
src={streamUrl}
title={cameraNm}
className="absolute inset-0 w-full h-full border-none"
allow="autoplay; encrypted-media"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}
>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}
>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
{cameraNm}
</span>
{sttsCd === 'LIVE' && (
<span
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
style={{ background: 'rgba(239,68,68,.3)' }}
>
REC
</span>
)}
</div>
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
{coordDc ?? ''}
{sourceNm ? ` · ${sourceNm}` : ''}
</div>
</div>
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
</div>
</div>
);
});
);
},
);
CCTVPlayer.displayName = 'CCTVPlayer';

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

파일 보기

@ -1,31 +1,39 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi'
import type { AerialMediaItem } from '../services/aerialApi'
import { navigateToTab } from '@common/hooks/useSubMenu'
import { useState, useCallback, useRef, useEffect } from 'react';
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi';
import type { AerialMediaItem } from '../services/aerialApi';
import { navigateToTab } from '@common/hooks/useSubMenu';
// ── Helpers ──
function formatDtm(dtm: string | null): string {
if (!dtm) return '—'
const d = new Date(dtm)
return d.toISOString().slice(0, 16).replace('T', ' ')
if (!dtm) return '—';
const d = new Date(dtm);
return d.toISOString().slice(0, 16).replace('T', ' ');
}
const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
const equipIcon = (t: string) => (t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰');
const equipTagCls = (t: string) =>
t === 'drone'
? 'bg-[rgba(59,130,246,0.12)] text-color-info'
: t === 'plane'
? 'bg-[rgba(34,197,94,0.12)] text-color-success'
: 'bg-[rgba(168,85,247,0.12)] text-color-tertiary'
: 'bg-[rgba(168,85,247,0.12)] text-color-tertiary';
const mediaTagCls = (t: string) =>
t === '영상'
? 'bg-[rgba(239,68,68,0.12)] text-color-danger'
: 'bg-[rgba(234,179,8,0.12)] text-color-caution'
: 'bg-[rgba(234,179,8,0.12)] text-color-caution';
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
const FilterBtn = ({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) => (
<button
onClick={onClick}
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
@ -36,127 +44,137 @@ const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean;
>
{label}
</button>
)
);
// ── Component ──
export function MediaManagement() {
const [mediaItems, setMediaItems] = useState<AerialMediaItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [equipFilter, setEquipFilter] = useState<string>('all')
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState('latest')
const [showUpload, setShowUpload] = useState(false)
const [downloadingId, setDownloadingId] = useState<number | null>(null)
const [bulkDownloading, setBulkDownloading] = useState(false)
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(null)
const modalRef = useRef<HTMLDivElement>(null)
const [mediaItems, setMediaItems] = useState<AerialMediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [equipFilter, setEquipFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('latest');
const [showUpload, setShowUpload] = useState(false);
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const [bulkDownloading, setBulkDownloading] = useState(false);
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
null,
);
const modalRef = useRef<HTMLDivElement>(null);
const loadData = useCallback(async () => {
setLoading(true)
setLoading(true);
try {
const items = await fetchAerialMedia()
setMediaItems(items)
const items = await fetchAerialMedia();
setMediaItems(items);
} catch (err) {
console.error('[aerial] 미디어 목록 조회 실패:', err)
console.error('[aerial] 미디어 목록 조회 실패:', err);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
loadData()
}, [loadData])
loadData();
}, [loadData]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
setShowUpload(false)
setShowUpload(false);
}
}
if (showUpload) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showUpload])
};
if (showUpload) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showUpload]);
const filtered = mediaItems.filter(f => {
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false
const filtered = mediaItems.filter((f) => {
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false;
if (typeFilter.size > 0) {
const isPhoto = f.mediaTpCd !== '영상'
const isVideo = f.mediaTpCd === '영상'
if (typeFilter.has('photo') && !isPhoto) return false
if (typeFilter.has('video') && !isVideo) return false
const isPhoto = f.mediaTpCd !== '영상';
const isVideo = f.mediaTpCd === '영상';
if (typeFilter.has('photo') && !isPhoto) return false;
if (typeFilter.has('video') && !isVideo) return false;
}
if (searchTerm && !f.fileNm.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
if (searchTerm && !f.fileNm.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm)
if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0')
return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '')
})
if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm);
if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0');
return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '');
});
const toggleId = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === sorted.length) {
setSelectedIds(new Set())
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(sorted.map(f => f.aerialMediaSn)))
setSelectedIds(new Set(sorted.map((f) => f.aerialMediaSn)));
}
}
};
const toggleTypeFilter = (t: string) => {
setTypeFilter(prev => {
const next = new Set(prev)
if (next.has(t)) { next.delete(t) } else { next.add(t) }
return next
})
}
setTypeFilter((prev) => {
const next = new Set(prev);
if (next.has(t)) {
next.delete(t);
} else {
next.add(t);
}
return next;
});
};
const handleBulkDownload = async () => {
if (bulkDownloading || selectedIds.size === 0) return
setBulkDownloading(true)
let success = 0
const total = selectedIds.size
if (bulkDownloading || selectedIds.size === 0) return;
setBulkDownloading(true);
let success = 0;
const total = selectedIds.size;
for (const sn of selectedIds) {
const item = mediaItems.find(f => f.aerialMediaSn === sn)
if (!item) continue
const item = mediaItems.find((f) => f.aerialMediaSn === sn);
if (!item) continue;
try {
await downloadAerialMedia(sn, item.orgnlNm ?? item.fileNm)
success++
await downloadAerialMedia(sn, item.orgnlNm ?? item.fileNm);
success++;
} catch {
// 실패 건 스킵
}
}
setBulkDownloading(false)
setDownloadResult({ total, success })
}
setBulkDownloading(false);
setDownloadResult({ total, success });
};
const handleDownload = async (e: React.MouseEvent, item: AerialMediaItem) => {
e.stopPropagation()
if (downloadingId !== null) return
setDownloadingId(item.aerialMediaSn)
e.stopPropagation();
if (downloadingId !== null) return;
setDownloadingId(item.aerialMediaSn);
try {
await downloadAerialMedia(item.aerialMediaSn, item.orgnlNm ?? item.fileNm)
await downloadAerialMedia(item.aerialMediaSn, item.orgnlNm ?? item.fileNm);
} catch {
alert('다운로드 실패: 이미지를 찾을 수 없습니다.')
alert('다운로드 실패: 이미지를 찾을 수 없습니다.');
} finally {
setDownloadingId(null)
setDownloadingId(null);
}
}
};
const droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length
const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length
const droneCount = mediaItems.filter((f) => f.equipTpCd === 'drone').length;
const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length;
const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length;
return (
<div className="flex flex-col h-full">
@ -164,26 +182,50 @@ export function MediaManagement() {
<div className="flex items-center justify-between mb-4">
<div className="flex gap-1.5 items-center">
<span className="text-[11px] text-fg-disabled font-korean"> :</span>
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
<FilterBtn
label="전체"
active={equipFilter === 'all'}
onClick={() => setEquipFilter('all')}
/>
<FilterBtn
label="🛸 드론"
active={equipFilter === 'drone'}
onClick={() => setEquipFilter('drone')}
/>
<FilterBtn
label="✈ 유인항공기"
active={equipFilter === 'plane'}
onClick={() => setEquipFilter('plane')}
/>
<FilterBtn
label="🛰 위성"
active={equipFilter === 'satellite'}
onClick={() => setEquipFilter('satellite')}
/>
<span className="w-px h-4 bg-border mx-1" />
<span className="text-[11px] text-fg-disabled font-korean">:</span>
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
<FilterBtn
label="📷 사진"
active={typeFilter.has('photo')}
onClick={() => toggleTypeFilter('photo')}
/>
<FilterBtn
label="🎬 영상"
active={typeFilter.has('video')}
onClick={() => toggleTypeFilter('video')}
/>
</div>
<div className="flex gap-2 items-center">
<input
type="text"
placeholder="파일명 검색..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-sm text-fg font-korean text-[11px] outline-none w-40 focus:border-color-accent"
/>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
onChange={(e) => setSortBy(e.target.value)}
className="prd-i py-1.5 w-auto"
>
<option value="latest"></option>
@ -196,13 +238,31 @@ export function MediaManagement() {
{/* Summary Stats */}
<div className="flex gap-2.5 mb-4">
{[
{ icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-color-accent' },
{ icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-fg' },
{ icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-fg' },
{
icon: '📸',
value: loading ? '…' : String(mediaItems.length),
label: '총 파일',
color: 'text-color-accent',
},
{
icon: '🛸',
value: loading ? '…' : String(droneCount),
label: '드론',
color: 'text-fg',
},
{
icon: '✈',
value: loading ? '…' : String(planeCount),
label: '유인항공기',
color: 'text-fg',
},
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-fg' },
{ icon: '💾', value: '—', label: '총 용량', color: 'text-fg' },
].map((s, i) => (
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-stroke rounded-sm">
<div
key={i}
className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-stroke rounded-sm"
>
<span className="text-xl">{s.icon}</span>
<div>
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
@ -240,66 +300,101 @@ export function MediaManagement() {
/>
</th>
<th className="px-1 py-2.5" />
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled text-center">📥</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
</th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled text-center">
📥
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-fg-disabled font-korean"> ...</td>
</tr>
) : sorted.map(f => (
<tr
key={f.aerialMediaSn}
onClick={() => toggleId(f.aerialMediaSn)}
className={`border-b border-stroke/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
}`}
>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.aerialMediaSn)}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
<td className="px-2 py-2 text-[10px] font-semibold text-fg font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
<td className="px-2 py-2 text-[10px] text-color-accent font-mono truncate">{f.locDc ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-semibold text-fg font-korean truncate">{f.fileNm}</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}>
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span>
</td>
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<button
onClick={(e) => handleDownload(e, f)}
disabled={downloadingId === f.aerialMediaSn}
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
<td
colSpan={11}
className="px-4 py-8 text-center text-[11px] text-fg-disabled font-korean"
>
...
</td>
</tr>
))}
) : (
sorted.map((f) => (
<tr
key={f.aerialMediaSn}
onClick={() => toggleId(f.aerialMediaSn)}
className={`border-b border-stroke/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
}`}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.aerialMediaSn)}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
<td className="px-2 py-2 text-[10px] font-semibold text-fg font-korean truncate">
{f.acdntSn != null ? String(f.acdntSn) : '—'}
</td>
<td className="px-2 py-2 text-[10px] text-color-accent font-mono truncate">
{f.locDc ?? '—'}
</td>
<td className="px-2 py-2 text-[11px] font-semibold text-fg font-korean truncate">
{f.fileNm}
</td>
<td className="px-2 py-2">
<span
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}
>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
<span
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}
>
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span>
</td>
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => handleDownload(e, f)}
disabled={downloadingId === f.aerialMediaSn}
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
@ -311,7 +406,10 @@ export function MediaManagement() {
: <span className="text-color-accent font-semibold">{selectedIds.size}</span>
</div>
<div className="flex gap-2">
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean">
<button
onClick={toggleAll}
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
>
</button>
<button
@ -340,9 +438,17 @@ export function MediaManagement() {
<span className="text-color-accent font-bold">{downloadResult.total}</span>
</div>
<div className="text-[13px] font-korean text-fg-sub mb-4">
<span className="text-color-success font-bold">{downloadResult.success}</span>
<span className="text-color-success font-bold">{downloadResult.success}</span>
{downloadResult.total - downloadResult.success > 0 && (
<> / <span className="text-color-danger font-bold">{downloadResult.total - downloadResult.success}</span> </>
<>
{' '}
/{' '}
<span className="text-color-danger font-bold">
{downloadResult.total - downloadResult.success}
</span>
</>
)}
</div>
<button
@ -358,18 +464,32 @@ export function MediaManagement() {
{/* Upload Modal */}
{showUpload && (
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
<div ref={modalRef} className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
<div
ref={modalRef}
className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6"
>
<div className="flex justify-between items-center mb-4">
<span className="text-base font-bold font-korean">📤 · </span>
<button onClick={() => setShowUpload(false)} className="text-fg-disabled text-lg hover:text-fg"></button>
<button
onClick={() => setShowUpload(false)}
className="text-fg-disabled text-lg hover:text-fg"
>
</button>
</div>
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-color-accent/40 transition-colors">
<div className="text-3xl mb-2 opacity-50">📁</div>
<div className="text-[13px] font-semibold mb-1 font-korean"> </div>
<div className="text-[11px] text-fg-disabled font-korean">JPG, TIFF, GeoTIFF, MP4, MOV · 2GB</div>
<div className="text-[13px] font-semibold mb-1 font-korean">
</div>
<div className="text-[11px] text-fg-disabled font-korean">
JPG, TIFF, GeoTIFF, MP4, MOV · 2GB
</div>
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean"> </label>
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
<option> (DJI M300 RTK)</option>
<option> (DJI Mavic 3E)</option>
@ -381,7 +501,9 @@ 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>
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
<option> (2026-01-18)</option>
<option> (2026-01-18)</option>
@ -389,18 +511,25 @@ 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>
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<textarea
className="prd-i w-full h-[60px] resize-y"
placeholder="촬영 조건, 비고 등..."
/>
</div>
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }}>
<button
className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer"
style={{
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
}}
>
📤
</button>
</div>
</div>
)}
</div>
)
);
}

파일 보기

@ -34,8 +34,12 @@ function formatDateTime(dt: Date | string | null): string | null {
if (!dt) return null;
if (dt instanceof Date) {
return dt.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
return String(dt);
@ -72,7 +76,7 @@ export function OilAreaAnalysis() {
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
useEffect(() => {
return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url));
previewUrls.forEach((url) => URL.revokeObjectURL(url));
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -84,8 +88,9 @@ export function OilAreaAnalysis() {
if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file);
exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.then(exif => {
exifr
.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.then((exif) => {
const info: ImageExif = {
lat: exif?.latitude ?? null,
lon: exif?.longitude ?? null,
@ -100,7 +105,7 @@ export function OilAreaAnalysis() {
imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null,
imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null,
};
setImageExifs(prev => {
setImageExifs((prev) => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = info;
@ -108,14 +113,22 @@ export function OilAreaAnalysis() {
});
})
.catch(() => {
setImageExifs(prev => {
setImageExifs((prev) => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = {
lat: null, lon: null, altitude: null,
make: null, model: null, dateTime: null,
exposureTime: null, fNumber: null, iso: null,
focalLength: null, imageWidth: null, imageHeight: null,
lat: null,
lon: null,
altitude: null,
make: null,
model: null,
dateTime: null,
exposureTime: null,
fNumber: null,
iso: null,
focalLength: null,
imageWidth: null,
imageHeight: null,
};
return updated;
});
@ -128,7 +141,7 @@ export function OilAreaAnalysis() {
const incoming = Array.from(e.target.files ?? []);
if (incoming.length === 0) return;
setSelectedFiles(prev => {
setSelectedFiles((prev) => {
const merged = [...prev, ...incoming].slice(0, MAX_IMAGES);
if (prev.length + incoming.length > MAX_IMAGES) {
setError(`최대 ${MAX_IMAGES}장까지 선택할 수 있습니다.`);
@ -138,37 +151,40 @@ export function OilAreaAnalysis() {
// setSelectedFiles updater 밖에서 독립 호출 — updater 내부 side effect는
// React Strict Mode의 이중 호출로 인해 URL이 중복 생성되는 버그를 유발함
setPreviewUrls(prev => {
setPreviewUrls((prev) => {
const available = MAX_IMAGES - prev.length;
const toAdd = incoming.slice(0, available);
return [...prev, ...toAdd.map(f => URL.createObjectURL(f))];
return [...prev, ...toAdd.map((f) => URL.createObjectURL(f))];
});
// input 초기화 (동일 파일 재선택 허용)
e.target.value = '';
}, []);
const handleRemoveFile = useCallback((idx: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
setPreviewUrls(prev => {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageExifs(prev => prev.filter((_, i) => i !== idx));
setSelectedImageIndex(prev => {
if (prev === null) return null;
if (prev === idx) return null;
if (prev > idx) return prev - 1;
return prev;
});
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedPreviewUrl(null);
}
setError(null);
}, [stitchedPreviewUrl]);
const handleRemoveFile = useCallback(
(idx: number) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== idx));
setPreviewUrls((prev) => {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageExifs((prev) => prev.filter((_, i) => i !== idx));
setSelectedImageIndex((prev) => {
if (prev === null) return null;
if (prev === idx) return null;
if (prev > idx) return prev - 1;
return prev;
});
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedPreviewUrl(null);
}
setError(null);
},
[stitchedPreviewUrl],
);
const handleStitch = async () => {
if (selectedFiles.length < 2) {
@ -186,8 +202,8 @@ export function OilAreaAnalysis() {
const msg =
err instanceof Error
? err.message
: (err as { message?: string }).message ?? '이미지 합성에 실패했습니다.';
const status = err instanceof Error ? 0 : (err as { status?: number }).status ?? 0;
: ((err as { message?: string }).message ?? '이미지 합성에 실패했습니다.');
const status = err instanceof Error ? 0 : ((err as { status?: number }).status ?? 0);
setError(status === 504 ? '이미지 합성 서버 응답 시간이 초과되었습니다.' : msg);
} finally {
setIsStitching(false);
@ -199,13 +215,19 @@ export function OilAreaAnalysis() {
setError(null);
setIsAnalyzing(true);
try {
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, { type: 'image/jpeg' });
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, {
type: 'image/jpeg',
});
const result = await analyzeImage(stitchedFile);
setPendingImageAnalysis({ ...result, autoRun: true });
navigateToTab('prediction', 'analysis');
} catch (err) {
const msg = err instanceof Error ? err.message : '분석에 실패했습니다.';
setError(msg.includes('GPS') ? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.' : msg);
setError(
msg.includes('GPS')
? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.'
: msg,
);
setIsAnalyzing(false);
}
};
@ -256,7 +278,10 @@ export function OilAreaAnalysis() {
<span className="text-color-accent">📷</span>
<span className="flex-1 truncate text-fg">{file.name}</span>
<button
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(i);
}}
disabled={isStitching || isAnalyzing}
className="text-fg-disabled hover:text-color-danger transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
@ -270,36 +295,54 @@ export function OilAreaAnalysis() {
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
<MetaRow
label="해상도"
value={imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
: null}
value={
imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
: null
}
/>
<MetaRow label="촬영일시" value={formatDateTime(imageExifs[i]!.dateTime)} />
<MetaRow
label="장비"
value={imageExifs[i]!.make || imageExifs[i]!.model
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
: null}
value={
imageExifs[i]!.make || imageExifs[i]!.model
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
: null
}
/>
<MetaRow
label="위도"
value={imageExifs[i]!.lat !== null ? decimalToDMS(imageExifs[i]!.lat!, true) : null}
value={
imageExifs[i]!.lat !== null
? decimalToDMS(imageExifs[i]!.lat!, true)
: null
}
/>
<MetaRow
label="경도"
value={imageExifs[i]!.lon !== null ? decimalToDMS(imageExifs[i]!.lon!, false) : null}
value={
imageExifs[i]!.lon !== null
? decimalToDMS(imageExifs[i]!.lon!, false)
: null
}
/>
<MetaRow
label="고도"
value={imageExifs[i]!.altitude !== null ? `${imageExifs[i]!.altitude!.toFixed(1)} m` : null}
value={
imageExifs[i]!.altitude !== null
? `${imageExifs[i]!.altitude!.toFixed(1)} m`
: null
}
/>
<MetaRow
label="셔터속도"
value={imageExifs[i]!.exposureTime
? imageExifs[i]!.exposureTime! < 1
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
: `${imageExifs[i]!.exposureTime}s`
: null}
value={
imageExifs[i]!.exposureTime
? imageExifs[i]!.exposureTime! < 1
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
: `${imageExifs[i]!.exposureTime}s`
: null
}
/>
<MetaRow
label="조리개"
@ -311,7 +354,9 @@ export function OilAreaAnalysis() {
/>
<MetaRow
label="초점거리"
value={imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null}
value={
imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null
}
/>
</div>
)}
@ -345,7 +390,11 @@ export function OilAreaAnalysis() {
disabled={!canAnalyze}
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
disabled:opacity-40 disabled:cursor-not-allowed text-white"
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' } : { background: 'var(--bg-3)' }}
style={
canAnalyze
? { background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }
: { background: 'var(--bg-3)' }
}
>
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
</button>
@ -363,7 +412,9 @@ export function OilAreaAnalysis() {
${previewUrls[i] ? 'cursor-pointer' : ''}
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
style={{ height: '300px' }}
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
onClick={() => {
if (previewUrls[i]) setSelectedImageIndex(i);
}}
>
{previewUrls[i] ? (
<>
@ -379,14 +430,19 @@ export function OilAreaAnalysis() {
{selectedFiles[i]?.name}
</div>
{imageExifs[i] === undefined ? (
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS ...</div>
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
GPS ...
</div>
) : imageExifs[i]?.lat !== null ? (
<div className="text-[10px] text-color-accent font-mono leading-tight text-right shrink-0">
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
{decimalToDMS(imageExifs[i]!.lat!, true)}
<br />
{decimalToDMS(imageExifs[i]!.lon!, false)}
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS </div>
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
GPS
</div>
)}
</div>
</>

파일 보기

@ -10,151 +10,153 @@ export interface OilDetectionOverlayProps {
/** 클래스 ID → RGBA 색상 (오버레이용) */
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
1: [0, 0, 204, 90], // black oil → 파란색
2: [180, 180, 180, 90], // brown oil → 회색
3: [255, 255, 0, 90], // rainbow oil → 노란색
4: [178, 102, 255, 90], // silver oil → 보라색
1: [0, 0, 204, 90], // black oil → 파란색
2: [180, 180, 180, 90], // brown oil → 회색
3: [255, 255, 0, 90], // rainbow oil → 노란색
4: [178, 102, 255, 90], // silver oil → 보라색
};
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const OilDetectionOverlay = memo(
({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.clientWidth;
const displayH = canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.clientWidth;
const displayH = canvas.clientHeight;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, displayW, displayH);
ctx.clearRect(0, 0, displayW, displayH);
if (!result || result.regions.length === 0) return;
if (!result || result.regions.length === 0) return;
const { mask, maskWidth, maskHeight } = result;
const { mask, maskWidth, maskHeight } = result;
// 클래스별 색상으로 마스크 렌더링
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
const offCtx = offscreen.getContext('2d');
if (offCtx) {
const imageData = new ImageData(maskWidth, maskHeight);
for (let i = 0; i < mask.length; i++) {
const classId = mask[i];
if (classId === 0) continue; // background skip
// 클래스별 색상으로 마스크 렌더링
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
const offCtx = offscreen.getContext('2d');
if (offCtx) {
const imageData = new ImageData(maskWidth, maskHeight);
for (let i = 0; i < mask.length; i++) {
const classId = mask[i];
if (classId === 0) continue; // background skip
const color = CLASS_COLORS[classId];
if (!color) continue;
const color = CLASS_COLORS[classId];
if (!color) continue;
const pixelIdx = i * 4;
imageData.data[pixelIdx] = color[0];
imageData.data[pixelIdx + 1] = color[1];
imageData.data[pixelIdx + 2] = color[2];
imageData.data[pixelIdx + 3] = color[3];
const pixelIdx = i * 4;
imageData.data[pixelIdx] = color[0];
imageData.data[pixelIdx + 1] = color[1];
imageData.data[pixelIdx + 2] = color[2];
imageData.data[pixelIdx + 3] = color[3];
}
offCtx.putImageData(imageData, 0, 0);
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
}
offCtx.putImageData(imageData, 0, 0);
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
}
}, [result]);
}, [result]);
const formatArea = (m2: number): string => {
if (m2 >= 1000) {
return `${(m2 / 1_000_000).toFixed(1)} km²`;
}
return `~${Math.round(m2)}`;
};
const formatArea = (m2: number): string => {
if (m2 >= 1000) {
return `${(m2 / 1_000_000).toFixed(1)} km²`;
}
return `~${Math.round(m2)}`;
};
const hasRegions = result !== null && result.regions.length > 0;
const hasRegions = result !== null && result.regions.length > 0;
return (
<>
<canvas
ref={canvasRef}
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
/>
return (
<>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none z-[15]"
/>
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
{/* 에러 표시 */}
{error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
</div>
)}
{/* 클래스별 감지 결과 */}
{hasRegions && result !== null && (
<>
{result.regions.map((region) => {
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
const label = OIL_CLASS_NAMES[region.classId] || region.className;
return (
<div
key={region.classId}
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: `${color}33`,
border: `1px solid ${color}80`,
color,
}}
>
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
</div>
);
})}
{/* 합계 */}
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
<div className="absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1">
{/* 에러 표시 */}
{error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
</div>
</>
)}
)}
{/* 감지 없음 */}
{!hasRegions && !isAnalyzing && !error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.35)',
color: '#4ade80',
}}
>
</div>
)}
{/* 클래스별 감지 결과 */}
{hasRegions && result !== null && (
<>
{result.regions.map((region) => {
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
const label = OIL_CLASS_NAMES[region.classId] || region.className;
{/* 분석 중 */}
{isAnalyzing && (
<span className='text-[9px] font-korean text-fg-disabled animate-pulse px-1'>
...
</span>
)}
</div>
</>
);
});
return (
<div
key={region.classId}
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
style={{
background: `${color}33`,
border: `1px solid ${color}80`,
color,
}}
>
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
</div>
);
})}
{/* 합계 */}
<div
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
</div>
</>
)}
{/* 감지 없음 */}
{!hasRegions && !isAnalyzing && !error && (
<div
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.35)',
color: '#4ade80',
}}
>
</div>
)}
{/* 분석 중 */}
{isAnalyzing && (
<span className="text-[9px] font-korean text-fg-disabled animate-pulse px-1">
...
</span>
)}
</div>
</>
);
},
);
OilDetectionOverlay.displayName = 'OilDetectionOverlay';

파일 보기

@ -1,18 +1,21 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
import type { DroneStreamItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
import { useState, useEffect, useCallback, useRef } from 'react';
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import type { StyleSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi';
import type { DroneStreamItem } from '../services/aerialApi';
import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer';
/** 함정 위치 + 드론 비행 위치 */
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } },
'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
}
const DRONE_POSITIONS: Record<
string,
{ ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }
> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } },
'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } },
};
const DRONE_MAP_STYLE: StyleSpecification = {
version: 8,
@ -27,186 +30,285 @@ const DRONE_MAP_STYLE: StyleSpecification = {
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
}
layers: [
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
],
};
export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const [streams, setStreams] = useState<DroneStreamItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null);
const [gridMode, setGridMode] = useState(1);
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([]);
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null);
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]);
const showMap = activeCells.length === 0
const showMap = activeCells.length === 0;
const loadStreams = useCallback(async () => {
try {
const items = await fetchDroneStreams()
setStreams(items)
const items = await fetchDroneStreams();
setStreams(items);
// Update selected stream and active cells with latest status
setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev)
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev));
setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell));
} catch {
// Fallback: show configured streams as idle
setStreams([
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null },
])
{
id: 'busan-1501',
name: '1501함 드론',
shipName: '부산서 1501함',
droneModel: 'DJI M300 RTK',
ip: '10.26.7.213',
rtspUrl: 'rtsp://10.26.7.213:554/stream0',
region: '부산',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'incheon-3008',
name: '3008함 드론',
shipName: '인천서 3008함',
droneModel: 'DJI M30T',
ip: '10.26.5.21',
rtspUrl: 'rtsp://10.26.5.21:554/stream0',
region: '인천',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'mokpo-3015',
name: '3015함 드론',
shipName: '목포서 3015함',
droneModel: 'DJI Mavic 3E',
ip: '10.26.7.85',
rtspUrl: 'rtsp://10.26.7.85:554/stream0',
region: '목포',
status: 'idle',
hlsUrl: null,
error: null,
},
]);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
loadStreams()
}, [loadStreams])
loadStreams();
}, [loadStreams]);
// Poll status every 3 seconds when any stream is starting
useEffect(() => {
const hasStarting = streams.some(s => s.status === 'starting')
if (!hasStarting) return
const timer = setInterval(loadStreams, 3000)
return () => clearInterval(timer)
}, [streams, loadStreams])
const hasStarting = streams.some((s) => s.status === 'starting');
if (!hasStarting) return;
const timer = setInterval(loadStreams, 3000);
return () => clearInterval(timer);
}, [streams, loadStreams]);
const handleStartStream = async (id: string) => {
try {
await startDroneStreamApi(id)
await startDroneStreamApi(id);
// Immediately update to 'starting' state
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
setStreams((prev) =>
prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)),
);
// Poll for status update
setTimeout(loadStreams, 2000)
setTimeout(loadStreams, 2000);
} catch {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s,
),
);
}
}
};
const handleStopStream = async (id: string) => {
try {
await stopDroneStreamApi(id)
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
setActiveCells(prev => prev.filter(c => c.id !== id))
await stopDroneStreamApi(id);
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s,
),
);
setActiveCells((prev) => prev.filter((c) => c.id !== id));
} catch {
// ignore
}
}
};
const handleSelectStream = (stream: DroneStreamItem) => {
setSelectedStream(stream)
setSelectedStream(stream);
if (stream.status === 'streaming' && stream.hlsUrl) {
if (gridMode === 1) {
setActiveCells([stream])
setActiveCells([stream]);
} else {
setActiveCells(prev => {
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
return prev
})
setActiveCells((prev) => {
if (prev.length < gridMode && !prev.find((c) => c.id === stream.id))
return [...prev, stream];
return prev;
});
}
}
}
};
const statusInfo = (status: string) => {
switch (status) {
case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }
case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }
case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }
default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }
case 'streaming':
return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' };
case 'starting':
return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' };
case 'error':
return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' };
default:
return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' };
}
}
};
const gridCols = gridMode === 1 ? 1 : 2
const totalCells = gridMode
const gridCols = gridMode === 1 ? 1 : 2;
const totalCells = gridMode;
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
<div
className="flex h-full overflow-hidden"
style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}
>
{/* 좌측: 드론 스트림 목록 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
{/* 헤더 */}
<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">
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
<span
className="w-[7px] h-[7px] rounded-full inline-block"
style={{
background: streams.some((s) => s.status === 'streaming')
? 'var(--color-success)'
: 'var(--fg-disabled)',
}}
/>
</div>
<button
onClick={loadStreams}
className="px-2 py-0.5 text-[9px] font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
>
</button>
</div>
<div className="text-[9px] text-fg-disabled font-korean">
ViewLink RTSP ·
</div>
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP · </div>
</div>
{/* 드론 스트림 카드 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
<div
className="flex-1 overflow-y-auto"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
{loading ? (
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean"> ...</div>
) : streams.map(stream => {
const si = statusInfo(stream.status)
const isSelected = selectedStream?.id === stream.id
return (
<div
key={stream.id}
onClick={() => handleSelectStream(stream)}
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div>
<div className="text-[11px] font-semibold text-fg font-korean">{stream.shipName} <span className="text-[9px] text-color-accent font-semibold">({stream.droneModel})</span></div>
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">
...
</div>
) : (
streams.map((stream) => {
const si = statusInfo(stream.status);
const isSelected = selectedStream?.id === stream.id;
return (
<div
key={stream.id}
onClick={() => handleSelectStream(stream)}
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div>
<div className="text-[11px] font-semibold text-fg font-korean">
{stream.shipName}{' '}
<span className="text-[9px] text-color-accent font-semibold">
({stream.droneModel})
</span>
</div>
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
</div>
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>
{si.label}
</span>
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>{si.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
</div>
{stream.error && (
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
{stream.error}
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">
{stream.region}
</span>
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">
RTSP :554
</span>
</div>
)}
{/* 시작/중지 버튼 */}
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<button
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
> </button>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
> </button>
{stream.error && (
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
{stream.error}
</div>
)}
{/* 시작/중지 버튼 */}
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<button
onClick={(e) => {
e.stopPropagation();
handleStartStream(stream.id);
}}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{
background: 'rgba(34,197,94,.1)',
borderColor: 'rgba(34,197,94,.3)',
color: 'var(--color-success)',
}}
>
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleStopStream(stream.id);
}}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{
background: 'rgba(239,68,68,.1)',
borderColor: 'rgba(239,68,68,.3)',
color: 'var(--color-danger)',
}}
>
</button>
)}
</div>
</div>
</div>
)
})}
);
})
)}
</div>
{/* 하단 안내 */}
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
RTSP .
ViewLink .
RTSP . ViewLink .
</div>
</div>
</div>
@ -220,13 +322,35 @@ export function RealtimeDrone() {
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
</div>
{selectedStream?.status === 'streaming' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--color-success)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
style={{
background: 'rgba(34,197,94,.14)',
border: '1px solid rgba(34,197,94,.35)',
color: 'var(--color-success)',
}}
>
<span
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-success)' }}
/>
LIVE
</div>
)}
{selectedStream?.status === 'starting' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--color-accent)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
style={{
background: 'rgba(6,182,212,.14)',
border: '1px solid rgba(6,182,212,.35)',
color: 'var(--color-accent)',
}}
>
<span
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-accent)' }}
/>
</div>
)}
</div>
@ -236,23 +360,31 @@ export function RealtimeDrone() {
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
].map(g => (
].map((g) => (
<button
key={g.mode}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
onClick={() => {
setGridMode(g.mode);
setActiveCells((prev) => prev.slice(0, g.mode));
}}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
style={
gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
}
>{g.icon}</button>
>
{g.icon}
</button>
))}
</div>
<button
onClick={() => playerRefs.current.forEach(r => r?.capture())}
onClick={() => playerRefs.current.forEach((r) => r?.capture())}
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
>📷 </button>
>
📷
</button>
</div>
</div>
@ -265,48 +397,141 @@ export function RealtimeDrone() {
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{streams.map(stream => {
const pos = DRONE_POSITIONS[stream.id]
if (!pos) return null
const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
{streams.map((stream) => {
const pos = DRONE_POSITIONS[stream.id];
if (!pos) return null;
const statusColor =
stream.status === 'streaming'
? '#22c55e'
: stream.status === 'starting'
? '#06b6d4'
: stream.status === 'error'
? '#ef4444'
: '#94a3b8';
return (
<Marker
key={stream.id}
longitude={(pos.ship.lon + pos.drone.lon) / 2}
latitude={(pos.ship.lat + pos.drone.lat) / 2}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
onClick={(e) => {
e.originalEvent.stopPropagation();
setMapPopup(stream);
}}
>
<div className="cursor-pointer group" title={stream.shipName}>
<svg width="130" height="85" viewBox="0 0 130 85" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
<svg
width="130"
height="85"
viewBox="0 0 130 85"
fill="none"
className="drop-shadow-lg transition-transform group-hover:scale-105"
style={{ overflow: 'visible' }}
>
{/* 연결선 (점선) */}
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
<line
x1="28"
y1="52"
x2="88"
y2="30"
stroke={statusColor}
strokeWidth="1.2"
strokeDasharray="4 3"
opacity="0.5"
/>
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
<polygon
points="28,38 18,58 38,58"
fill="none"
stroke="#fff"
strokeWidth="0.8"
opacity="0.5"
/>
{/* 함정명 라벨 */}
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
<text
x="28"
y="70.5"
textAnchor="middle"
fill="#fff"
fontSize="7"
fontFamily="sans-serif"
fontWeight="bold"
>
{stream.shipName.replace(/서 /, ' ')}
</text>
{/* ── 드론: 쿼드콥터 아이콘 ── */}
{/* 외곽 원 */}
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
<circle
cx="88"
cy="30"
r="18"
fill="rgba(10,14,24,.7)"
stroke={statusColor}
strokeWidth="1.5"
/>
{/* X자 팔 */}
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line
x1="76"
y1="18"
x2="100"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
<line
x1="100"
y1="18"
x2="76"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
{/* 프로펠러 4개 (회전 애니메이션) */}
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 18"
to="360 76 18"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 18"
to="-360 100 18"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 42"
to="-360 76 42"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 42"
to="360 100 42"
dur="1.5s"
repeatCount="indefinite"
/>
</ellipse>
{/* 본체 */}
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
@ -316,16 +541,31 @@ export function RealtimeDrone() {
{/* 송출중 REC LED */}
{stream.status === 'streaming' && (
<circle cx="100" cy="16" r="3" fill="#ef4444">
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
<animate
attributeName="opacity"
values="1;0.2;1"
dur="1s"
repeatCount="indefinite"
/>
</circle>
)}
{/* 드론 모델명 */}
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
<text
x="88"
y="60"
textAnchor="middle"
fill={statusColor}
fontSize="7"
fontFamily="sans-serif"
fontWeight="bold"
>
{stream.droneModel.split(' ').slice(-1)[0]}
</text>
</svg>
</div>
</Marker>
)
);
})}
{/* 드론 클릭 팝업 */}
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
@ -338,56 +578,102 @@ export function RealtimeDrone() {
offset={36}
className="cctv-dark-popup"
>
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
<div
className="p-2.5"
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">🚁</span>
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</div>
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
<div className="text-[8px] text-fg-disabled font-mono mb-2">
{mapPopup.ip} · {mapPopup.region}
</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
> {statusInfo(mapPopup.status).label}</span>
<span
className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{
background: statusInfo(mapPopup.status).bg,
color: statusInfo(mapPopup.status).color,
}}
>
{statusInfo(mapPopup.status).label}
</span>
</div>
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
<button
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
onClick={() => {
handleStartStream(mapPopup.id);
handleSelectStream(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
> </button>
style={{
background: 'rgba(34,197,94,.15)',
borderColor: 'rgba(34,197,94,.3)',
color: '#4ade80',
}}
>
</button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
onClick={() => {
handleSelectStream(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
style={{
background: 'rgba(6,182,212,.15)',
borderColor: 'rgba(6,182,212,.3)',
color: '#67e8f9',
}}
>
</button>
) : (
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse"> ...</div>
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">
...
</div>
)}
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
<div
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{
background: 'rgba(0,0,0,.7)',
color: 'rgba(255,255,255,.7)',
backdropFilter: 'blur(4px)',
}}
>
🚁 ({streams.length})
</div>
</div>
) : (
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
<div
className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
}}
>
{Array.from({ length: totalCells }).map((_, i) => {
const stream = activeCells[i]
const stream = activeCells[i];
return (
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
<div
key={i}
className="relative flex items-center justify-center overflow-hidden bg-bg-base"
style={{ border: '1px solid var(--stroke-light)' }}
>
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
<CCTVPlayer
ref={el => { playerRefs.current[i] = el }}
ref={(el) => {
playerRefs.current[i] = el;
}}
cameraNm={stream.shipName}
streamUrl={stream.hlsUrl}
sttsCd="LIVE"
@ -398,36 +684,55 @@ export function RealtimeDrone() {
) : stream && stream.status === 'starting' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-40 animate-pulse">🚁</div>
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP ...</div>
<div className="text-[10px] text-color-accent font-korean animate-pulse">
RTSP ...
</div>
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
</div>
) : stream && stream.status === 'error' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-30"></div>
<div className="text-[10px] text-color-danger font-korean"> </div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">
{stream.error}
</div>
<button
onClick={() => handleStartStream(stream.id)}
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
></button>
>
</button>
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
{streams.length > 0
? '스트림을 시작하고 선택하세요'
: '드론 스트림을 선택하세요'}
</div>
)}
</div>
)
);
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[10px] text-fg-disabled font-korean">: <b className="text-fg">{selectedStream?.shipName ?? ''}</b></div>
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? ''}</span></div>
<div className="text-[10px] text-fg-disabled font-korean">: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span></div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP HLS · ViewLink </div>
<div className="text-[10px] text-fg-disabled font-korean">
: <b className="text-fg">{selectedStream?.shipName ?? ''}</b>
</div>
<div className="text-[10px] text-fg-disabled font-korean">
IP:{' '}
<span className="text-color-accent font-mono text-[9px]">
{selectedStream?.ip ?? ''}
</span>
</div>
<div className="text-[10px] text-fg-disabled font-korean">
: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span>
</div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">
RTSP HLS · ViewLink
</div>
</div>
</div>
@ -438,7 +743,10 @@ export function RealtimeDrone() {
📋
</div>
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
<div
className="flex-1 overflow-y-auto px-3 py-2.5"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
{selectedStream ? (
<div className="flex flex-col gap-1.5">
{[
@ -451,7 +759,10 @@ export function RealtimeDrone() {
['프로토콜', 'RTSP → HLS'],
['상태', statusInfo(selectedStream.status).label],
].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
<div
key={i}
className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono text-fg">{v}</span>
</div>
@ -459,7 +770,9 @@ export function RealtimeDrone() {
{selectedStream.hlsUrl && (
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
<div className="font-mono text-color-accent break-all">
{selectedStream.hlsUrl}
</div>
</div>
)}
</div>
@ -471,13 +784,23 @@ export function RealtimeDrone() {
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 </div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
<div
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
style={{ border: '1px solid rgba(6,182,212,.2)' }}
>
<span className="text-[9px] text-fg-sub font-korean">ViewLink 3.5</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}> RTSP</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>
RTSP
</span>
</div>
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
<div
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
style={{ border: '1px solid rgba(59,130,246,.2)' }}
>
<span className="text-[9px] text-fg-sub font-korean">FFmpeg </span>
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSPHLS</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>
RTSPHLS
</span>
</div>
</div>
</div>
@ -488,9 +811,21 @@ export function RealtimeDrone() {
<div className="grid grid-cols-2 gap-1.5">
{[
{ label: '전체', value: streams.length, color: 'text-fg' },
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' },
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' },
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' },
{
label: '송출중',
value: streams.filter((s) => s.status === 'streaming').length,
color: 'text-color-success',
},
{
label: '연결중',
value: streams.filter((s) => s.status === 'starting').length,
color: 'text-color-accent',
},
{
label: '오류',
value: streams.filter((s) => s.status === 'error').length,
color: 'text-color-danger',
},
].map((item, i) => (
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
@ -502,5 +837,5 @@ export function RealtimeDrone() {
</div>
</div>
</div>
)
);
}

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

파일 보기

@ -1,30 +1,71 @@
import { useRef, useEffect, useState, useMemo } from 'react';
interface ReconItem {
id: string
name: string
type: 'vessel' | 'pollution'
status: 'complete' | 'processing'
points: string
polygons: string
coverage: string
id: string;
name: string;
type: 'vessel' | 'pollution';
status: 'complete' | 'processing';
points: string;
polygons: string;
coverage: string;
}
const reconItems: ReconItem[] = [
{ id: 'V-001', name: '불명선박-A', type: 'vessel', status: 'complete', points: '980K', polygons: '38K', coverage: '97.1%' },
{ id: 'V-002', name: '불명선박-B', type: 'vessel', status: 'complete', points: '1.2M', polygons: '48K', coverage: '98.4%' },
{ id: 'V-003', name: '어선 #37', type: 'vessel', status: 'processing', points: '420K', polygons: '16K', coverage: '64.2%' },
{ id: 'P-001', name: '유류오염-A', type: 'pollution', status: 'complete', points: '560K', polygons: '22K', coverage: '95.8%' },
{ id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' },
]
{
id: 'V-001',
name: '불명선박-A',
type: 'vessel',
status: 'complete',
points: '980K',
polygons: '38K',
coverage: '97.1%',
},
{
id: 'V-002',
name: '불명선박-B',
type: 'vessel',
status: 'complete',
points: '1.2M',
polygons: '48K',
coverage: '98.4%',
},
{
id: 'V-003',
name: '어선 #37',
type: 'vessel',
status: 'processing',
points: '420K',
polygons: '16K',
coverage: '64.2%',
},
{
id: 'P-001',
name: '유류오염-A',
type: 'pollution',
status: 'complete',
points: '560K',
polygons: '22K',
coverage: '95.8%',
},
{
id: 'P-002',
name: '유류오염-B',
type: 'pollution',
status: 'processing',
points: '310K',
polygons: '12K',
coverage: '52.1%',
},
];
function mulberry32(seed: number) {
let s = seed;
return () => {
s |= 0; s = s + 0x6D2B79F5 | 0;
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
s |= 0;
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
@ -68,7 +109,7 @@ function buildVesselGeometry(): VesselGeometry {
const halfWidth = (nx: number): number => {
const t = (nx + 1) / 2; // 0~1 (0=선미, 1=선수)
if (t > 0.85) return 0.05 + ((1 - t) / 0.15) * 0.18; // 선수: 뾰족
if (t < 0.1) return 0.05 + (t / 0.1) * 0.22; // 선미: 약간 좁음
if (t < 0.1) return 0.05 + (t / 0.1) * 0.22; // 선미: 약간 좁음
return 0.22 + Math.sin(((t - 0.1) / 0.75) * Math.PI) * 0.08; // 중앙부 불룩
};
@ -117,7 +158,15 @@ function buildVesselGeometry(): VesselGeometry {
for (let i = 0; i < 40; i++) {
const angle = rand() * Math.PI * 2;
const fy = 0.17 + rand() * 0.18;
points.push({ x: -0.5 + (rand() - 0.5) * 0.04, y: fy, z: 0.04 * Math.cos(angle), r: 239, g: 68, b: 68, radius: 1.0 });
points.push({
x: -0.5 + (rand() - 0.5) * 0.04,
y: fy,
z: 0.04 * Math.cos(angle),
r: 239,
g: 68,
b: 68,
radius: 1.0,
});
}
// Wireframe edges: 선체 종방향 (매 2번째 단면)
@ -126,7 +175,10 @@ function buildVesselGeometry(): VesselGeometry {
edges.push({
a: hullPtStart + si * circPts + ci,
b: hullPtStart + (si + 2) * circPts + ci,
r: 6, g: 182, bCh: 212, isWaterline: false,
r: 6,
g: 182,
bCh: 212,
isWaterline: false,
});
}
}
@ -137,24 +189,33 @@ function buildVesselGeometry(): VesselGeometry {
edges.push({
a: hullPtStart + si * circPts + ci,
b: hullPtStart + si * circPts + ci + 1,
r: 6, g: 182, bCh: 212, isWaterline: false,
r: 6,
g: 182,
bCh: 212,
isWaterline: false,
});
}
edges.push({
a: hullPtStart + si * circPts + circPts - 1,
b: hullPtStart + si * circPts,
r: 6, g: 182, bCh: 212, isWaterline: false,
r: 6,
g: 182,
bCh: 212,
isWaterline: false,
});
}
// 수선 (waterline)
const wlSections = [4, 6, 8, 10, 12, 14, 16];
wlSections.forEach(si => {
wlSections.forEach((si) => {
if (si < sections - 1) {
edges.push({
a: hullPtStart + si * circPts,
b: hullPtStart + (si + 1) * circPts,
r: 6, g: 182, bCh: 212, isWaterline: true,
r: 6,
g: 182,
bCh: 212,
isWaterline: true,
});
}
});
@ -164,14 +225,30 @@ function buildVesselGeometry(): VesselGeometry {
{ x: 0.1, y1: -0.03, y2: 0.18 },
{ x: 0.3, y1: -0.03, y2: 0.15 },
];
craneData.forEach(cr => {
craneData.forEach((cr) => {
for (let i = 0; i < 20; i++) {
const t = i / 19;
points.push({ x: cr.x, y: cr.y1 + t * (cr.y2 - cr.y1), z: 0, r: 249, g: 115, b: 22, radius: 1.2 });
points.push({
x: cr.x,
y: cr.y1 + t * (cr.y2 - cr.y1),
z: 0,
r: 249,
g: 115,
b: 22,
radius: 1.2,
});
}
for (let i = 0; i < 12; i++) {
const t = i / 11;
points.push({ x: cr.x + t * (-0.08), y: cr.y2 - t * 0.1, z: 0, r: 249, g: 115, b: 22, radius: 1.2 });
points.push({
x: cr.x + t * -0.08,
y: cr.y2 - t * 0.1,
z: 0,
r: 249,
g: 115,
b: 22,
radius: 1.2,
});
}
});
@ -211,7 +288,12 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
const cy = H / 2 + 10;
const scale3d = 155;
const project = (px: number, py: number, pz: number, angle: number): { sx: number; sy: number; sc: number } => {
const project = (
px: number,
py: number,
pz: number,
angle: number,
): { sx: number; sy: number; sc: number } => {
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const rx = px * cosA - pz * sinA;
@ -234,7 +316,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
// 에지 (wireframe)
if (showWire) {
geo.edges.forEach(edge => {
geo.edges.forEach((edge) => {
const ptA = geo.points[edge.a];
const ptB = geo.points[edge.b];
if (!ptA || !ptB) return;
@ -242,7 +324,8 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
const projB = project(ptB.x, ptB.y, ptB.z, angle);
const avgSc = (projA.sc + projB.sc) / 2;
const brightness = 0.3 + avgSc * 0.5;
const lineAlpha = (edge.isWaterline ? 0.25 : viewMode === 'wire' ? 0.6 : 0.3) * alphaBase * brightness;
const lineAlpha =
(edge.isWaterline ? 0.25 : viewMode === 'wire' ? 0.6 : 0.3) * alphaBase * brightness;
ctx.beginPath();
ctx.moveTo(projA.sx, projA.sy);
ctx.lineTo(projB.sx, projB.sy);
@ -256,7 +339,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
// 포인트 (back-to-front 정렬)
if (showPoints) {
const projected = geo.points.map(pt => {
const projected = geo.points.map((pt) => {
const { sx, sy, sc } = project(pt.x, pt.y, pt.z, angle);
return { sx, sy, sc, pt };
});
@ -301,13 +384,21 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
return (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={canvasRef} style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }} />
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
{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>
<div className="text-color-accent/40 text-xs font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-color-accent/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
<div
className="h-full bg-color-accent/40 rounded-full"
style={{ width: '64%', animation: 'pulse 2s infinite' }}
/>
</div>
</div>
</div>
@ -355,12 +446,24 @@ function buildPollutionGeometry(): PollutionGeometry {
for (let i = 0; i <= gridCount; i++) {
const pos = -gridRange + i * gridStep;
edges.push({
points: [{ x: -gridRange, y: 0, z: pos }, { x: gridRange, y: 0, z: pos }],
r: 6, g: 182, b: 212, isDash: false,
points: [
{ x: -gridRange, y: 0, z: pos },
{ x: gridRange, y: 0, z: pos },
],
r: 6,
g: 182,
b: 212,
isDash: false,
});
edges.push({
points: [{ x: pos, y: 0, z: -gridRange }, { x: pos, y: 0, z: gridRange }],
r: 6, g: 182, b: 212, isDash: false,
points: [
{ x: pos, y: 0, z: -gridRange },
{ x: pos, y: 0, z: gridRange },
],
r: 6,
g: 182,
b: 212,
isDash: false,
});
}
@ -383,15 +486,21 @@ function buildPollutionGeometry(): PollutionGeometry {
if (dist < 0.3) {
// 중심: 두꺼운 적색
py = (rand() - 0.5) * 0.16;
pr = 239; pg = 68; pb = 68;
pr = 239;
pg = 68;
pb = 68;
} else if (dist < 0.6) {
// 중간: 주황
py = (rand() - 0.5) * 0.08;
pr = 249; pg = 115; pb = 22;
pr = 249;
pg = 115;
pb = 22;
} else {
// 외곽: 노랑, y~0
py = (rand() - 0.5) * 0.02;
pr = 234; pg = 179; pb = 8;
pr = 234;
pg = 179;
pb = 8;
}
points.push({ x: px, y: py, z: pz, r: pr, g: pg, b: pb, radius: 0.7 + rand() * 0.5 });
@ -428,7 +537,16 @@ function buildPollutionGeometry(): PollutionGeometry {
const arrowLen = 1.2;
const ax = Math.cos(arrowAngle) * arrowLen;
const az = Math.sin(arrowAngle) * arrowLen;
edges.push({ points: [{ x: 0.5, y: 0, z: 0.2 }, { x: 0.5 + ax, y: 0, z: 0.2 + az }], r: 249, g: 115, b: 22, isDash: false });
edges.push({
points: [
{ x: 0.5, y: 0, z: 0.2 },
{ x: 0.5 + ax, y: 0, z: 0.2 + az },
],
r: 249,
g: 115,
b: 22,
isDash: false,
});
return { points, edges };
}
@ -460,7 +578,12 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
const cy = H / 2 + 20;
const scale3d = 100;
const project = (px: number, py: number, pz: number, angle: number): { sx: number; sy: number; sc: number } => {
const project = (
px: number,
py: number,
pz: number,
angle: number,
): { sx: number; sy: number; sc: number } => {
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const rx = px * cosA - pz * sinA;
@ -483,7 +606,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
// 에지 렌더링
if (showWire) {
geo.edges.forEach(edge => {
geo.edges.forEach((edge) => {
if (edge.points.length < 2) return;
// 그리드는 매우 얇게
const isGrid = !edge.isDash && edge.r === 6 && edge.b === 212;
@ -510,7 +633,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
// 포인트 (back-to-front 정렬)
if (showPoints) {
const projected = geo.points.map(pt => {
const projected = geo.points.map((pt) => {
const { sx, sy, sc } = project(pt.x, pt.y, pt.z, angle);
return { sx, sy, sc, pt };
});
@ -562,12 +685,26 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
return (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={canvasRef} style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }} />
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
{viewMode === '3d' && !isProcessing && (
<div className="absolute bottom-2 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>
<div
className="absolute bottom-2 right-2 flex items-center gap-1"
style={{ fontSize: '8px', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}
>
<span>0mm</span>
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
<div
style={{
width: '60px',
height: '4px',
borderRadius: '2px',
background:
'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))',
}}
/>
<span>3.2mm</span>
</div>
)}
@ -575,9 +712,14 @@ 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>
<div className="text-color-danger/40 text-xs font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-color-danger/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
<div
className="h-full bg-color-danger/40 rounded-full"
style={{ width: '52%', animation: 'pulse 2s infinite' }}
/>
</div>
</div>
</div>
@ -587,19 +729,26 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
}
export function SensorAnalysis() {
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel')
const [viewMode, setViewMode] = useState('3d')
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1])
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel');
const [viewMode, setViewMode] = useState('3d');
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1]);
const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'))
const filteredItems = reconItems.filter(
(r) => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'),
);
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
<div
className="flex h-full overflow-hidden"
style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}
>
{/* Left Panel */}
<div className="w-[280px] bg-bg-surface border-r border-stroke flex flex-col overflow-auto">
{/* 3D Reconstruction List */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">📋 3D </div>
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
📋 3D
</div>
<div className="flex gap-1 mb-2">
<button
onClick={() => setSubTab('vessel')}
@ -623,7 +772,7 @@ export function SensorAnalysis() {
</button>
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
{filteredItems.map((item) => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
@ -635,9 +784,13 @@ export function SensorAnalysis() {
>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold text-fg font-korean">{item.name}</div>
<div className="text-[8px] text-fg-disabled font-mono">{item.id} · {item.points} pts</div>
<div className="text-[8px] text-fg-disabled font-mono">
{item.id} · {item.points} pts
</div>
</div>
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}>
<span
className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
>
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
</span>
</div>
@ -647,7 +800,9 @@ export function SensorAnalysis() {
{/* Source Images */}
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">📹 </div>
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
📹
</div>
<div className="grid grid-cols-2 gap-1">
{[
{ label: 'D-01 정면', sensor: '광학', color: 'text-color-info' },
@ -655,9 +810,17 @@ export function SensorAnalysis() {
{ label: 'D-03 우현', sensor: '광학', color: 'text-color-tertiary' },
{ label: 'D-02 상부', sensor: 'IR', color: 'text-color-danger' },
].map((src, i) => (
<div key={i} className="relative rounded-sm bg-bg-base border border-stroke overflow-hidden aspect-square">
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
<div className="text-fg-disabled/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
<div
key={i}
className="relative rounded-sm bg-bg-base border border-stroke overflow-hidden aspect-square"
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
>
<div className="text-fg-disabled/10 text-xs font-mono">
{src.label.split(' ')[0]}
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-fg-disabled font-korean">
<span>{src.label}</span>
@ -672,9 +835,21 @@ export function SensorAnalysis() {
{/* Center Panel - 3D Canvas */}
<div className="flex-1 relative bg-bg-base border-x border-stroke flex items-center justify-center overflow-hidden">
{/* Simulated 3D viewport */}
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
<div
className="absolute inset-0"
style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}
>
{/* Grid floor */}
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)', backgroundSize: '40px 40px', transform: 'perspective(500px) rotateX(55deg)', transformOrigin: 'center 80%' }} />
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)',
backgroundSize: '40px 40px',
transform: 'perspective(500px) rotateX(55deg)',
transformOrigin: 'center 80%',
}}
/>
{/* 3D Model Visualization */}
{selectedItem.type === 'vessel' ? (
@ -684,7 +859,10 @@ export function SensorAnalysis() {
)}
{/* Axis indicator */}
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}>
<div
className="absolute bottom-16 left-4"
style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}
>
<div style={{ color: '#ef4444' }}>X </div>
<div className="text-green-500">Y </div>
<div className="text-blue-500">Z </div>
@ -693,9 +871,15 @@ export function SensorAnalysis() {
{/* Title */}
<div className="absolute top-3 left-3 z-[2]">
<div className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">3D Vessel Analysis</div>
<div className="text-[13px] font-bold text-color-accent my-1 font-korean">{selectedItem.name} </div>
<div className="text-[9px] text-fg-disabled font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
<div className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">
3D Vessel Analysis
</div>
<div className="text-[13px] font-bold text-color-accent my-1 font-korean">
{selectedItem.name}
</div>
<div className="text-[9px] text-fg-disabled font-mono">
34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}
</div>
</div>
{/* View Mode Buttons */}
@ -704,7 +888,7 @@ export function SensorAnalysis() {
{ id: '3d', label: '3D모델' },
{ id: 'point', label: '포인트클라우드' },
{ id: 'wire', label: '와이어프레임' },
].map(m => (
].map((m) => (
<button
key={m.id}
onClick={() => setViewMode(m.id)}
@ -720,7 +904,10 @@ export function SensorAnalysis() {
</div>
{/* Bottom Stats */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
<div
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]"
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
>
{[
{ value: selectedItem.points, label: '포인트' },
{ value: selectedItem.polygons, label: '폴리곤' },
@ -740,26 +927,31 @@ export function SensorAnalysis() {
<div className="w-[270px] bg-bg-surface border-l border-stroke flex flex-col overflow-auto">
{/* Ship/Pollution Info */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">📊 </div>
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📊
</div>
<div className="flex flex-col gap-1.5 text-[10px]">
{(selectedItem.type === 'vessel' ? [
['대상', selectedItem.name],
['선종 추정', '일반화물선 (추정)'],
['길이', '약 85m'],
['폭', '약 14m'],
['AIS 상태', 'OFF (미식별)'],
['최초 탐지', '2026-01-18 14:20'],
['촬영 시점', '3 시점 (정면/좌현/우현)'],
['센서', '광학 4K + IR 열화상'],
] : [
['대상', selectedItem.name],
['유형', '유류 오염'],
['추정 면적', '0.42 km²'],
['추정 유출량', '12.6 kL'],
['유종', 'B-C유 (추정)'],
['최초 탐지', '2026-01-18 13:50'],
['확산 속도', '0.3 km/h (ESE 방향)'],
]).map(([k, v], i) => (
{(selectedItem.type === 'vessel'
? [
['대상', selectedItem.name],
['선종 추정', '일반화물선 (추정)'],
['길이', '약 85m'],
['폭', '약 14m'],
['AIS 상태', 'OFF (미식별)'],
['최초 탐지', '2026-01-18 14:20'],
['촬영 시점', '3 시점 (정면/좌현/우현)'],
['센서', '광학 4K + IR 열화상'],
]
: [
['대상', selectedItem.name],
['유형', '유류 오염'],
['추정 면적', '0.42 km²'],
['추정 유출량', '12.6 kL'],
['유종', 'B-C유 (추정)'],
['최초 탐지', '2026-01-18 13:50'],
['확산 속도', '0.3 km/h (ESE 방향)'],
]
).map(([k, v], i) => (
<div key={i} className="flex justify-between items-start">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono font-semibold text-fg text-right ml-2">{v}</span>
@ -770,26 +962,34 @@ export function SensorAnalysis() {
{/* AI Detection Results */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">🤖 AI </div>
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
🤖 AI
</div>
<div className="flex flex-col gap-1">
{(selectedItem.type === 'vessel' ? [
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
{ label: '선종 분류', confidence: 78, color: 'bg-color-caution' },
{ label: '손상 감지', confidence: 45, color: 'bg-color-warning' },
{ label: '화물 분석', confidence: 62, color: 'bg-color-caution' },
] : [
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
{ label: '두께 추정', confidence: 72, color: 'bg-color-caution' },
{ label: '확산 예측', confidence: 68, color: 'bg-color-warning' },
]).map((r, i) => (
{(selectedItem.type === 'vessel'
? [
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
{ label: '선종 분류', confidence: 78, color: 'bg-color-caution' },
{ label: '손상 감지', confidence: 45, color: 'bg-color-warning' },
{ label: '화물 분석', confidence: 62, color: 'bg-color-caution' },
]
: [
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
{ label: '두께 추정', confidence: 72, color: 'bg-color-caution' },
{ label: '확산 예측', confidence: 68, color: 'bg-color-warning' },
]
).map((r, i) => (
<div key={i}>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-fg-disabled font-korean">{r.label}</span>
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
</div>
<div className="w-full h-1 bg-bg-base rounded-full overflow-hidden">
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
<div
className={`h-full rounded-full ${r.color}`}
style={{ width: `${r.confidence}%` }}
/>
</div>
</div>
))}
@ -798,21 +998,26 @@ export function SensorAnalysis() {
{/* Comparison / Measurements */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">📐 3D </div>
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📐 3D
</div>
<div className="flex flex-col gap-1 text-[10px]">
{(selectedItem.type === 'vessel' ? [
['전장 (LOA)', '84.7 m'],
['형폭 (Breadth)', '14.2 m'],
['건현 (Freeboard)', '3.8 m'],
['흘수 (Draft)', '5.6 m (추정)'],
['마스트 높이', '22.3 m'],
] : [
['유막 면적', '0.42 km²'],
['최대 길이', '1.24 km'],
['최대 폭', '0.68 km'],
['평균 두께', '0.8 mm'],
['최대 두께', '3.2 mm'],
]).map(([k, v], i) => (
{(selectedItem.type === 'vessel'
? [
['전장 (LOA)', '84.7 m'],
['형폭 (Breadth)', '14.2 m'],
['건현 (Freeboard)', '3.8 m'],
['흘수 (Draft)', '5.6 m (추정)'],
['마스트 높이', '22.3 m'],
]
: [
['유막 면적', '0.42 km²'],
['최대 길이', '1.24 km'],
['최대 폭', '0.68 km'],
['평균 두께', '0.8 mm'],
['최대 두께', '3.2 mm'],
]
).map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono font-semibold text-color-accent">{v}</span>
@ -823,7 +1028,12 @@ export function SensorAnalysis() {
{/* Action Buttons */}
<div className="p-2.5 px-3">
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))' }}>
<button
className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2"
style={{
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
}}
>
📊
</button>
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
@ -832,5 +1042,5 @@ export function SensorAnalysis() {
</div>
</div>
</div>
)
);
}

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

파일 보기

@ -1 +1 @@
export { AerialView } from './components/AerialView'
export { AerialView } from './components/AerialView';

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