release: 2026-04-02 (229건 커밋) #159
@ -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>
|
||||
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -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]">📖</span> 사용자 매뉴얼
|
||||
<span className="text-title-4">📖</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 {
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
}
|
||||
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" 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" 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회) > 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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
<
|
||||
</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 조류 + 취송류 예측 → 유출유 확산 예측 (S10→S20→S30)</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 & 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)} m²`;
|
||||
};
|
||||
const formatArea = (m2: number): string => {
|
||||
if (m2 >= 1000) {
|
||||
return `${(m2 / 1_000_000).toFixed(1)} km²`;
|
||||
}
|
||||
return `~${Math.round(m2)} m²`;
|
||||
};
|
||||
|
||||
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)' }}>RTSP→HLS</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>
|
||||
RTSP→HLS
|
||||
</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
불러오는 중...
Reference in New Issue
Block a user