Compare commits

...

17 커밋

작성자 SHA1 메시지 날짜
51da334e7a Merge branch 'develop' of https://gitea.gc-si.dev/gc/wing-ops into feature/wing-disign-system 2026-03-16 11:02:57 +09:00
2d9bf0a8e0 feat(design-system): 디자인 시스템 구축,디자인 가이드 문서 작성 2026-03-16 10:34:28 +09:00
891db2a894 Merge pull request 'docs: ������ ��Ʈ ���� (2026-03-13)' (#89) from release/2026-03-13-notes into develop 2026-03-13 14:57:12 +09:00
4e0bb23dab docs: 릴리즈 노트 정리 (2026-03-13)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:56:05 +09:00
1bfc06c6c5 Merge pull request 'feat(prediction): 오염분석 다각형/원 분석 기능 구현' (#87) from feature/prediction-pollution-analysis into develop 2026-03-13 14:48:46 +09:00
740b8acf1d chore: develop 머지 + RELEASE-NOTES 충돌 해결 2026-03-13 14:46:50 +09:00
d693c6865f chore: 팀 워크플로우 동기화 (v1.6.1) 2026-03-13 14:30:43 +09:00
a40daf2263 docs: 릴리즈 노트 업데이트 2026-03-13 14:14:57 +09:00
6864f6dab5 fix(useSubMenu): useEffect import 추가 2026-03-13 14:10:15 +09:00
421f5f8b52 Merge branch 'feature/prediction-pollution-analysis' of https://gitea.gc-si.dev/gc/wing-ops into feature/prediction-pollution-analysis
# Conflicts:
#	docs/RELEASE-NOTES.md
#	frontend/src/common/components/map/MapView.tsx
#	frontend/src/tabs/prediction/components/OilSpillView.tsx
#	frontend/src/tabs/prediction/components/RightPanel.tsx
2026-03-13 14:00:47 +09:00
a8ba29fd4c Merge pull request 'feat(prediction): 시뮬레이션 에러 모달 추가 및 보고서 해안부착 현황 개선' (#88) from feature/function_develop into develop 2026-03-13 13:41:41 +09:00
59f4753c12 Merge branch 'develop' of https://gitea.gc-si.dev/gc/wing-ops into feature/function_develop
# Conflicts:
#	docs/RELEASE-NOTES.md
2026-03-13 13:31:41 +09:00
fb2dcce1f5 Merge branch 'feature/function_develop' into develop 2026-03-13 13:27:55 +09:00
827dab27a0 docs: 릴리즈 노트 업데이트 2026-03-13 13:17:01 +09:00
Nan Kyung Lee
efc8f18bb9 chore: 팀 워크플로우 동기화 (v1.6.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:26:21 +09:00
Nan Kyung Lee
dc82574635 feat(prediction): 오염분석 다각형/원 분석 기능 구현
- 오염분석 섹션을 탭 UI로 개편 (다각형 분석 / 원 분석)
- 다각형 분석: 지도 클릭으로 꼭짓점 추가 후 분석 실행
- 원 분석: NM 프리셋 버튼(1·3·5·10·15·20·30·50) + 직접 입력, 사고지점 기준 자동 계산
- 분석 결과: 분석면적·오염비율·오염면적·해상잔존량·연안부착량·민감자원 개소 표시
- MapView: 다각형(PolygonLayer) / 원(ScatterplotLayer) 실시간 지도 시각화
- geo.ts: pointInPolygon, polygonAreaKm2, circleAreaKm2 유틸 함수 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:19:30 +09:00
9ddae7a973 feat(prediction): 시뮬레이션 에러 모달 추가 및 보고서 해안부착 현황 개선 2026-03-12 19:08:25 +09:00
63개의 변경된 파일3156개의 추가작업 그리고 395개의 파일을 삭제

파일 보기

@ -5,29 +5,29 @@
},
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(curl -s *)",
"Bash(fnm *)",
"Bash(git add *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git remote *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
"Bash(node *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
],
"deny": [
"Bash(git push --force*)",
@ -83,5 +83,7 @@
]
}
]
}
},
"deny": [],
"allow": []
}

파일 보기

@ -1,6 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-11",
"applied_date": "2026-03-13",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true

파일 보기

@ -18,6 +18,7 @@ interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
interface PredictionDetail {
@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
SELECT
A.ACDNT_SN,
A.ACDNT_NM,
A.ACDNT_STTS_CD,
A.OCCRN_DTM,
A.LAT,
A.LNG,
@ -186,6 +188,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
}));
}

파일 보기

@ -47,6 +47,30 @@ const UNIT_MAP: Record<string, string> = {
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
}
// ============================================================
// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼
// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다.
// ============================================================
async function rollbackNewRecords(
predExecSn: number | null,
newSpilDataSn: number | null,
newAcdntSn: number | null
): Promise<void> {
try {
if (predExecSn !== null) {
await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn])
}
if (newSpilDataSn !== null) {
await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn])
}
if (newAcdntSn !== null) {
await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn])
}
} catch (cleanupErr) {
console.error('[simulation] 롤백 실패:', cleanupErr)
}
}
// ============================================================
// POST /api/simulation/run
// 확산 시뮬레이션 실행 (OpenDrift)
@ -92,9 +116,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
}
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
try {
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, startTime }),
signal: AbortSignal.timeout(5000),
})
if (!checkRes.ok) {
return res.status(409).json({
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
message: 'NC 파일이 준비되지 않았습니다.',
})
}
} catch {
// Python 서버 미기동 — 5번에서 처리
}
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
let resolvedSpilDataSn: number | null = null
// 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상)
let newlyCreatedAcdntSn: number | null = null
let newlyCreatedSpilDataSn: number | null = null
if (!resolvedAcdntSn && acdntNm) {
try {
@ -116,6 +161,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
[acdntNm.trim(), occrn, lat, lon]
)
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
newlyCreatedAcdntSn = resolvedAcdntSn
const spilRes = await wingPool.query(
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
@ -131,30 +177,13 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
]
)
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
newlyCreatedSpilDataSn = resolvedSpilDataSn
} catch (dbErr) {
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
return res.status(500).json({ error: '사고 정보 생성 실패' })
}
}
// 2. Python NC 파일 존재 여부 확인
try {
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, startTime }),
signal: AbortSignal.timeout(5000),
})
if (!checkRes.ok) {
return res.status(409).json({
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
message: 'NC 파일이 준비되지 않았습니다.',
})
}
} catch {
// Python 서버 미기동 — 5번에서 처리
}
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
if (resolvedAcdntSn && !resolvedSpilDataSn) {
try {
@ -215,6 +244,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[errData.error || '분석 서버 포화', predExecSn]
)
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
}
@ -229,6 +259,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
}

파일 보기

@ -0,0 +1,462 @@
# WING-OPS 디자인 시스템 (백업)
Tailwind CSS + @apply 기반 디자인 시스템. `wing-*` CSS 클래스와 React UI 컴포넌트로 구성.
---
## 브랜드 (Brand)
### 로고
해양 환경 위기대응을 위한 통합 솔루션 **WING**의 로고.
- **파일**: `frontend/public/wing_logo_white.svg`
- **네이티브 크기**: 280 × 20px (비율 14:1)
- **색상**: 단색 흰색 (다크 배경 전용)
#### 로고 규격
| 용도 | 높이 | Tailwind | 비고 |
|------|------|----------|------|
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
#### 여백 규칙 (Clear Space)
- 최소 여백: 로고 높이의 **50%** (상하좌우)
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
### 테마 컬러
다크 테마 기반. 게시판(Board) 메뉴에서 추출한 컬러 체계.
#### 배경 (Background) — 딥 네이비
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `bg-bg-0` | `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
| `bg-bg-1` | `--bg1` | `#0f1524` | 패널, 모달, 푸터 배경 |
| `bg-bg-2` | `--bg2` | `#121929` | 테이블 헤더, elevated 영역 |
| `bg-bg-3` | `--bg3` | `#1a2236` | 카드, 보조 버튼, 비활성 요소 |
| `bg-hover` | `--bgH` | `#1e2844` | 행 hover |
#### 텍스트 (Text)
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `text-text-1` | `--t1` | `#edf0f7` | 주 텍스트 (제목, 본문) |
| `text-text-2` | `--t2` | `#b0b8cc` | 보조 텍스트 (라벨, 설명) |
| `text-text-3` | `--t3` | `#8690a6` | 비활성/메타 (날짜, 조회수) |
#### 브랜드 강조색 (Accent)
| 토큰 | HEX | 용도 |
|------|-----|------|
| `primary-cyan` | `#06b6d4` | 주 강조색 — 활성 상태, 링크, CTA |
| `primary-blue` | `#3b82f6` | 보조 강조색 — 그라데이션 끝점 |
| **Primary Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | Primary 버튼 |
#### 시맨틱 (Semantic)
| 토큰 | HEX | 용도 |
|------|-----|------|
| `red` / `danger` | `#ef4444` | 삭제, 필수 표시(*) |
| `orange` | `#f97316` | 경고 |
| `yellow` | `#eab308` | 주의 |
| `green` | `#22c55e` | 성공, 정상 |
| `purple` | `#a855f7` | 특수 강조 |
#### 테두리 (Border)
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `border` | `--bd` | `#1e2a42` | 기본 구분선 |
| `border-light` | `--bdL` | `#2a3a5c` | 밝은 테두리 |
#### 오버레이 (Overlay)
| 토큰 | 값 | 용도 |
|------|-----|------|
| `overlay` | `rgba(0, 0, 0, 0.55)` | 모달 배경 오버레이 |
---
## 디자인 원칙
### 컬러 사용 규칙
- **상태 표현** (위험/정상/주의) → 컬러 배지 사용 (red, green, yellow)
- **단순 분류** (공지사항, 자료실, Q&A) → 기본 텍스트 컬러 또는 neutral 배지
- **강조** (고정글, 선택 항목) → accent 컬러 (cyan)
- **원칙**: 색상은 정보를 전달할 때만 사용. 장식 목적 금지.
---
## 1. 토큰
### 컬러 팔레트
| CSS 변수 | 값 | 용도 |
|----------|-----|------|
| `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
| `--bg1` | `#0f1524` | 기본 패널 배경 |
| `--bg2` | `#121929` | 테이블 헤더, elevated |
| `--bg3` | `#1a2236` | 카드, 섹션 배경 |
| `--bgH` | `#1e2844` | hover 상태 |
| `--bd` | `#1e2a42` | 기본 테두리 |
| `--bdL` | `#2a3a5c` | 밝은 테두리 |
| `--t1` | `#edf0f7` | 기본 텍스트 (밝음) |
| `--t2` | `#b0b8cc` | 보조 텍스트 |
| `--t3` | `#8690a6` | 비활성/메타 텍스트 |
| `--cyan` | `#06b6d4` | Primary accent |
| `--blue` | `#3b82f6` | Secondary accent |
| `--purple` | `#a855f7` | 특수 강조 |
| `--red` | `#ef4444` | 위험/삭제 |
| `--orange` | `#f97316` | 경고 |
| `--yellow` | `#eab308` | 주의 |
| `--green` | `#22c55e` | 성공/정상 |
### Z-Index 스케일
| 변수 | 값 | 용도 |
|------|-----|------|
| `--z-dropdown` | 100 | 드롭다운, 콤보박스 |
| `--z-sticky` | 200 | sticky 헤더 |
| `--z-overlay` | 1000 | 오버레이 |
| `--z-modal` | 10000 | 모달 |
| `--z-toast` | 10100 | 토스트 알림 |
### 패널 너비
| 변수 | 값 | 용도 |
|------|-----|------|
| `--panel-narrow` | 280px | 좁은 사이드패널 |
| `--panel-default` | 300px | 기본 사이드패널 |
| `--panel-wide` | 340px | 넓은 사이드패널 |
### 타이포그래피 (Tailwind)
| 클래스 | 크기 | 용도 |
|--------|------|------|
| `text-wing-meta` | 9px | 메타 텍스트, 날짜, 부가 정보 |
| `text-wing-caption` | 10px | 캡션, 설명, 라벨 부연 |
| `text-wing-body` | 11px | 본문, 라벨, 값 (가장 많이 사용) |
| `text-wing-heading` | 13px | 섹션 헤더 |
| `text-wing-title` | 15px | 페이지/모달 타이틀 |
---
## 2. CSS 클래스 (`wing-*`)
모든 클래스는 `frontend/src/common/styles/wing.css`에 정의.
### Layout
| 클래스 | 설명 |
|--------|------|
| `wing-panel` | flex column, full height, overflow hidden |
| `wing-panel-scroll` | flex-1, overflow-y-auto, thin scrollbar |
| `wing-panel-right` | 우측 사이드패널 (border-l, 300px) |
| `wing-panel-left` | 좌측 사이드패널 (border-r, 300px) |
| `wing-header-bar` | 헤더 바 (flex between, border-b, px-5) |
| `wing-sidebar` | 사이드바 (flex col, border-r, bg1) |
### Card / Section
| 클래스 | 설명 |
|--------|------|
| `wing-card` | 카드 (rounded-md, p-4, border, bg3) |
| `wing-card-sm` | 작은 카드 (rounded-sm, p-3) |
| `wing-section` | 섹션 (rounded-md, p-4, mb-3) |
| `wing-section-header` | 섹션 제목 (13px bold) |
| `wing-section-desc` | 섹션 설명 (10px, t3 색상) |
### Typography
| 클래스 | 설명 |
|--------|------|
| `wing-title` | 15px bold korean |
| `wing-subtitle` | 10px korean, t3 색상 |
| `wing-label` | 11px semibold korean |
| `wing-value` | 11px semibold mono |
| `wing-meta` | 9px korean, t3 색상 |
### Button
| 클래스 | 설명 |
|--------|------|
| `wing-btn` | 기본 버튼 (px-3, py-1.5, 11px) |
| `wing-btn-primary` | cyan→blue gradient, white |
| `wing-btn-secondary` | bg3, border, t2 색상 |
| `wing-btn-outline` | transparent, border |
| `wing-btn-pdf` | blue 테마 |
| `wing-btn-danger` | red 테마 |
### Input / Select / Textarea
| 클래스 | 설명 |
|--------|------|
| `wing-input` | 기본 입력 (w-full, 11px, cyan focus) |
| `wing-select` | 셀렉트 (커스텀 화살표 포함) |
| `wing-textarea` | 텍스트영역 (resize-vertical, min-h 80px) |
| `wing-input-search` | 검색 입력 (256px 고정) |
### Table
| 클래스 | 설명 |
|--------|------|
| `wing-table` | 테이블 (w-full, 10px, collapse) |
| `wing-table-head` | 헤더 셀 (bg2, t3, bold) |
| `wing-table-cell` | 데이터 셀 (t2, border-b) |
| `wing-table-row` | 행 hover (bgH, cursor-pointer) |
### Badge
| 클래스 | 설명 |
|--------|------|
| `wing-badge` | 기본 배지 (inline-flex, 9px bold) |
| `wing-badge-neutral` | 회색 (단순 분류용 기본값) |
| `wing-badge-red` | 위험/삭제 |
| `wing-badge-blue` | 정보 |
| `wing-badge-green` | 성공/정상 |
| `wing-badge-yellow` | 주의 |
| `wing-badge-purple` | 특수 |
| `wing-badge-cyan` | 주요 |
### Modal
| 클래스 | 설명 |
|--------|------|
| `wing-overlay` | 오버레이 (fixed, blur, z-modal) |
| `wing-modal` | 모달 컨테이너 (rounded-xl, bg1) |
| `wing-modal-header` | 모달 헤더 (flex between, border-b) |
| `wing-modal-body` | 모달 본문 (flex-1, scroll) |
| `wing-modal-footer` | 모달 푸터 (flex end, border-t) |
| `wing-modal-sm` | 400px |
| `wing-modal-md` | 560px |
| `wing-modal-lg` | 720px |
### Tab
| 클래스 | 설명 |
|--------|------|
| `wing-tab-bar` | 탭 바 (flex, rounded-lg, border) |
| `wing-tab` | 탭 아이템 (flex-1, text-center) |
| `wing-tab.active` | 활성 탭 (cyan border/bg/text) |
### Utility
| 클래스 | 설명 |
|--------|------|
| `wing-divider` | 구분선 (1px, full width) |
| `wing-info-row` | 키-값 행 (flex between) |
| `wing-info-label` | 키 라벨 (10px, t3) |
| `wing-info-value` | 값 (11px, semibold mono) |
---
## 3. React 컴포넌트
위치: `frontend/src/common/components/ui/`
### Modal
```tsx
import Modal from '@common/components/ui/Modal';
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="제목"
size="md"
footer={
<>
<button className="wing-btn wing-btn-secondary" onClick={handleCancel}>취소</button>
<button className="wing-btn wing-btn-primary" onClick={handleConfirm}>확인</button>
</>
}
>
<p>모달 내용</p>
</Modal>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `isOpen` | `boolean` | 필수 | 표시 여부 |
| `onClose` | `() => void` | 필수 | 닫기 콜백 |
| `title` | `string` | 필수 | 헤더 제목 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 너비 (400/560/720px) |
| `children` | `ReactNode` | 필수 | 본문 |
| `footer` | `ReactNode` | - | 하단 버튼 영역 |
| `closeOnBackdrop` | `boolean` | `true` | 배경 클릭 닫기 |
### Pagination
```tsx
import Pagination from '@common/components/ui/Pagination';
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `currentPage` | `number` | 필수 | 현재 페이지 (1-based) |
| `totalPages` | `number` | 필수 | 전체 페이지 수 |
| `onPageChange` | `(page: number) => void` | 필수 | 페이지 변경 콜백 |
| `showFirstLast` | `boolean` | `true` | 처음/끝 버튼 표시 |
### DataTable
```tsx
import DataTable, { Column } from '@common/components/ui/DataTable';
interface Post {
id: number;
title: string;
author: string;
createdAt: string;
}
const columns: Column<Post>[] = [
{ key: 'id', label: '번호', width: '60px', align: 'center' },
{ key: 'title', label: '제목' },
{ key: 'author', label: '작성자', width: '100px' },
{ key: 'createdAt', label: '작성일', width: '120px',
render: (val) => new Date(val as string).toLocaleDateString() },
];
<DataTable
columns={columns}
data={posts}
onRowClick={(post) => navigate(`/board/${post.id}`)}
/>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `columns` | `Column<T>[]` | 필수 | 컬럼 정의 |
| `data` | `T[]` | 필수 | 데이터 배열 |
| `onRowClick` | `(row: T) => void` | - | 행 클릭 콜백 |
| `stickyHeader` | `boolean` | `true` | 헤더 고정 |
| `emptyMessage` | `string` | `'데이터가 없습니다.'` | 빈 상태 메시지 |
### SidePanel
```tsx
import SidePanel from '@common/components/ui/SidePanel';
<SidePanel
position="right"
width="default"
header={<span className="wing-title">상세 정보</span>}
footer={
<button className="wing-btn wing-btn-primary w-full">저장</button>
}
>
<div className="p-3">
<div className="wing-info-row">
<span className="wing-info-label">상태</span>
<Badge color="green">정상</Badge>
</div>
</div>
</SidePanel>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `position` | `'left' \| 'right'` | 필수 | 배치 방향 |
| `width` | `'narrow' \| 'default' \| 'wide'` | `'default'` | 너비 (280/300/340px) |
| `header` | `ReactNode` | - | 헤더 영역 |
| `footer` | `ReactNode` | - | 하단 영역 |
### Badge
```tsx
import Badge from '@common/components/ui/Badge';
<Badge color="green">정상</Badge> {/* 상태 표현 */}
<Badge color="red">위험</Badge> {/* 상태 표현 */}
<Badge>공지사항</Badge> {/* 단순 분류 → neutral */}
<Badge>자료실</Badge> {/* 단순 분류 → neutral */}
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `color` | `'red' \| 'blue' \| 'green' \| 'yellow' \| 'purple' \| 'cyan' \| 'neutral'` | `'neutral'` | 배지 색상 |
---
## 4. 적용 예시: Before → After
### Before (raw Tailwind 복붙)
```tsx
{/* 검색 */}
<input
type="text"
placeholder="검색..."
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1
placeholder-text-3 focus:border-primary-cyan focus:outline-none"
/>
{/* 테이블 */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-xs font-bold text-text-3">제목</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border hover:bg-bg-2 cursor-pointer">
<td className="px-4 py-4 text-sm text-text-2">{item.title}</td>
</tr>
</tbody>
</table>
{/* 배지 — 의미 없는 컬러 분화 */}
<span className="px-2.5 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
공지사항
</span>
{/* 페이지네이션 — 직접 구현 */}
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">이전</button>
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">다음</button>
```
### After (디자인 시스템)
```tsx
{/* 검색 */}
<input type="text" placeholder="검색..." className="wing-input-search" />
{/* 테이블 — React 컴포넌트 */}
<DataTable columns={columns} data={posts} onRowClick={handleClick} />
{/* 배지 — neutral로 통일 */}
<Badge>공지사항</Badge>
<Badge>자료실</Badge>
<Badge color="green">진행중</Badge> {/* 상태만 컬러 */}
{/* 페이지네이션 — React 컴포넌트 */}
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
```
---
## 5. 마이그레이션 가이드
### 작업 순서
1. 파일 내 raw Tailwind 문자열을 `wing-*` 클래스로 교체
2. 반복되는 행동 패턴 (모달, 페이지네이션, 테이블)을 React 컴포넌트로 교체
3. 의미 없는 컬러 배지를 `<Badge>` (neutral)로 교체
4. 브라우저에서 시각적 동일성 확인
### 판단 기준
```
순수 시각 + 5회 이상 반복 → wing-* CSS 클래스
동작 포함 + 5회 이상 반복 → React 컴포넌트
1-2회 사용 → 인라인 Tailwind 유지
도메인 전용 시각 → components.css (유지)
```

169
docs/DESIGN-SYSTEM.md Normal file
파일 보기

@ -0,0 +1,169 @@
# WING 디자인 시스템
해양 환경 위기대응 통합 솔루션 **WING**의 디자인 시스템.
---
## 브랜드 (Brand)
### 로고
해양에서 발생하는 상황을 종합적으로 지원하는 솔루션 WING의 로고.
- **파일**: `frontend/public/wing_logo_white.svg`
- **네이티브 크기**: 280 × 20px (비율 14:1)
- **색상**: 단색 흰색 (다크 배경 전용)
#### 로고 규격
| 용도 | 높이 | Tailwind | 비고 |
|------|------|----------|------|
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
#### 여백 규칙 (Clear Space)
- 최소 여백: 로고 높이의 **50%** (상하좌우)
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
---
## 파운데이션 (Foundations)
### 브랜드 컬러 (Brand Color)
현재 다크 테마만 구현되어 있으며, 라이트 팔레트는 향후 전환용으로 정의해 둔다.
> **현재 상태**: 다크 테마 단일 고정 (테마 전환 인프라 미구축)
#### 배경 (Background)
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--bg0` | `#0a0e1a` | `#ffffff` | 최하위 배경 (body, input) |
| `--bg1` | `#0f1524` | `#f8f9fb` | 패널, 모달, 푸터 배경 |
| `--bg2` | `#121929` | `#f0f2f5` | 테이블 헤더, elevated 영역 |
| `--bg3` | `#1a2236` | `#e8ebf0` | 카드, 보조 버튼, 비활성 요소 |
| `--bgH` | `#1e2844` | `#dde1e8` | 행 hover |
#### 텍스트 (Text)
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--t1` | `#edf0f7` | `#1a1d26` | 주 텍스트 (제목, 본문) |
| `--t2` | `#b0b8cc` | `#4a5568` | 보조 텍스트 (라벨, 설명) |
| `--t3` | `#8690a6` | `#8690a6` | 비활성/메타 — 양 테마 공유 |
#### Accent Color
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--cyan` | `#06b6d4` | `#0891b2` | 주 강조색 — 활성 상태, 링크, CTA |
| `--blue` | `#3b82f6` | `#2563eb` | 보조 강조색 — 그라데이션 끝점 |
| **Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | `linear-gradient(135deg, #0891b2, #2563eb)` | Primary 버튼 |
> 라이트 테마에서 강조색을 한 단계 진하게 적용하여 흰 배경 위 가독성을 확보한다.
> **보조 컬러 검토**: 필요에 따라 시맨틱 컬러(red `#ef4444`, orange `#f97316`, yellow `#eab308`, green `#22c55e`, purple `#a855f7`)를 보조 컬러로 추가 검토 예정.
---
### 타이포그래피 (Typography)
#### 폰트 패밀리
| 토큰 | CSS 변수 | 폰트 | 용도 |
|------|----------|------|------|
| `font-sans` | — | Outfit, Noto Sans KR, sans-serif | 기본 UI (body) |
| `font-korean` | `--fK` | Noto Sans KR, sans-serif | 한글 강조 |
| `font-mono` | `--fM` | JetBrains Mono, monospace | 수치, 코드 |
#### 폰트 스케일
| 클래스 | 크기 | Line Height | Weight | 용도 |
|--------|------|-------------|--------|------|
| `text-wing-meta` | 9px | 1.4 | 400 | 메타 텍스트, 날짜, 부가 정보 |
| `text-wing-caption` | 10px | 1.4 | 400 | 캡션, 설명, 라벨 부연 |
| `text-wing-body` | 11px | 1.5 | 400 | 본문, 라벨, 값 (가장 많이 사용) |
| `text-wing-heading` | 13px | 1.4 | 700 | 섹션 헤더 |
| `text-wing-title` | 15px | 1.3 | 700 | 페이지/모달 타이틀 |
> 11px(`text-wing-body`)이 기본 본문 크기. 정보 밀도가 높은 운영 시스템 특성에 맞춘 compact 설계.
---
### 아이콘 (Icons)
- **라이브러리**: `lucide-react` (^0.564.0)
- **스타일**: Stroke 기반, 일관된 2px 선 두께
| 크기 | px | 용도 |
|------|-----|------|
| Small | 16px | 인라인 텍스트, 버튼 내부 |
| Default | 18px | 일반 UI 아이콘 |
| Large | 20px | 강조, 헤더 영역 |
```tsx
import { Search, ChevronDown, X } from 'lucide-react';
<Search size={16} /> {/* 인라인 */}
<ChevronDown size={18} /> {/* 일반 */}
<X size={20} /> {/* 강조 */}
```
---
### 간격 (Spacing)
4px 그리드 기반. Tailwind 유틸리티 클래스 사용.
| 토큰 | 값 | Tailwind | 주요 사용처 |
|------|-----|----------|------------|
| `spacing-1` | 4px | `p-1`, `gap-1` | 최소 간격, 아이콘-텍스트 |
| `spacing-1.5` | 6px | `py-1.5`, `gap-1.5` | 인풋 수직 패딩, 버튼 수직 패딩 |
| `spacing-2` | 8px | `p-2`, `gap-2` | 테이블 셀, 버튼 그룹 간격 |
| `spacing-3` | 12px | `p-3`, `gap-3` | 소형 카드 패딩 |
| `spacing-4` | 16px | `p-4`, `gap-4` | 카드/섹션 패딩 |
| `spacing-5` | 20px | `px-5` | 헤더/모달 수평 패딩 |
---
### 테두리 (Border)
#### 색상
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--bd` | `#1e2a42` | `#d0d5dd` | 기본 구분선 |
| `--bdL` | `#2a3a5c` | `#e0e4ea` | 밝은 테두리 |
#### Radius
| 토큰 | 값 | Tailwind | 용도 |
|------|-----|----------|------|
| `radius-xs` | 4px | `rounded` | 배지, 최소 요소 |
| `radius-sm` | 6px | `rounded-sm` | 버튼, 인풋, 소형 카드 (가장 많이 사용) |
| `radius-md` | 10px | `rounded-md` | 카드, 섹션 |
| `radius-lg` | 12px | `rounded-xl` | 모달 |
> `rounded-sm`(6px)이 프로젝트 전반에서 지배적인 기본 반경값.
---
### 단위 체계 (Units)
> **현재 상태**: px 기반 단일 체계
> **향후 방향**: rem 전환 검토 (접근성/반응성 고려)
| 영역 | 현재 단위 | 비고 |
|------|-----------|------|
| font-size | px | `9px`~`15px`, Tailwind arbitrary `text-[11px]` |
| spacing/padding | px | raw CSS `6px 10px` 등 |
| layout 치수 | px | 패널 280~340px, 모달 400~720px |
| border-radius | px | `6px`, `10px`, `12px` |
| html font-size | 미설정 | rem 기준점 없음 (브라우저 기본 16px) |
- Tailwind 기본 유틸리티(`px-5`, `text-sm` 등)는 내부적으로 rem을 사용하나, 이는 의도적 설계가 아닌 Tailwind 부산물
- rem 전환 시 로드맵: `html { font-size }` 기준 설정 → Tailwind config rem 토큰 → wing.css 점진적 전환

파일 보기

@ -4,27 +4,21 @@
## [Unreleased]
## [2026-03-13]
### 추가
- 오염분석 다각형/원 분석 기능 구현
- 시뮬레이션 에러 모달 추가
- 해류 캔버스 파티클 레이어 추가
## [2026-03-11.2]
### 추가
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
- flyTo 완료 후 자동 재생 기능
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
### 수정
- useSubMenu useEffect import 누락 수정
### 변경
- 이미지 분석/보고서/항공 UI 개선
- CCTV/관리자 고도화
- 보고서 해안부착 현황 개선
### 기타
- 팀 워크플로우 v1.6.1 적용일 갱신
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결)
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
- 팀 워크플로우 동기화 (v1.6.1)
## [2026-03-11]
@ -36,18 +30,27 @@
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
- 유류오염보장계약 시드 데이터 추가 (1391건)
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
- flyTo 완료 후 자동 재생 기능
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
### 수정
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
### 변경
- 이미지 분석/보고서/항공 UI 개선
- CCTV/관리자 고도화
### 문서
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
### 기타
- CLAUDE_BOT_TOKEN 갱신
- 팀 워크플로우 v1.6.1 동기화
- 팀 워크플로우 v1.6.0 동기화
- 팀 워크플로우 v1.5.0 동기화
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신)
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
## [2026-03-01]

파일 보기

@ -25,6 +25,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
@ -3848,6 +3849,12 @@
"node": ">=0.10.0"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",

파일 보기

@ -27,6 +27,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",

파일 보기

@ -18,6 +18,7 @@ import { IncidentsView } from '@tabs/incidents'
import { AdminView } from '@tabs/admin'
import { ScatView } from '@tabs/scat'
import { RescueView } from '@tabs/rescue'
import { ShowcaseView } from '@tabs/showcase'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
@ -97,6 +98,8 @@ function App() {
return <AdminView />
case 'rescue':
return <RescueView />
case 'showcase':
return <ShowcaseView />
default:
return <div className="flex items-center justify-center h-full text-text-3"> ...</div>
}

파일 보기

@ -73,6 +73,25 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
</button>
)
})}
{/* 실시간 상황관리 */}
<button
onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'http://localhost:5174', '_blank')}
className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
font-korean tracking-[0.2px] font-semibold
border-l border-l-[rgba(239,68,68,0.25)] ml-1
text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]
flex items-center gap-1.5
`}
title="실시간 상황관리"
>
<span className="hidden xl:flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
</span>
<span className="xl:hidden text-[16px] leading-none">🛰</span>
</button>
</div>
</div>
@ -88,6 +107,17 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
🔔
</button>
<button
onClick={() => onTabChange('showcase')}
title="쇼케이스"
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
activeTab === 'showcase'
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
}`}
>
🎨
</button>
{hasPermission('admin') && (
<button
onClick={() => onTabChange('admin')}

파일 보기

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
@ -189,6 +189,15 @@ interface MapViewProps {
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
showCurrent?: boolean
showWind?: boolean
showBeached?: boolean
showTimeLabel?: boolean
simulationStartTime?: string
drawAnalysisMode?: 'polygon' | 'circle' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -311,6 +320,15 @@ export function MapView({
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@ -393,8 +411,10 @@ export function MapView({
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
// 1순위: stranded 입자 → 빨간색
if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number]
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색
if (d.stranded === 1) return showBeached
? [239, 68, 68, 220] as [number, number, number, number]
: [130, 130, 130, 70] as [number, number, number, number]
// 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
@ -427,7 +447,7 @@ export function MapView({
}
},
updateTriggers: {
getFillColor: [selectedModels, currentTime],
getFillColor: [selectedModels, currentTime, showBeached],
},
})
)
@ -529,6 +549,91 @@ export function MapView({
)
}
// --- 오염분석 다각형 그리기 ---
if (analysisPolygonPoints.length > 0) {
if (analysisPolygonPoints.length >= 3) {
result.push(
new PolygonLayer({
id: 'analysis-polygon-fill',
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [168, 85, 247, 40],
getLineColor: [168, 85, 247, 220],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
result.push(
new PathLayer({
id: 'analysis-polygon-outline',
data: [{
path: [
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
],
}],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [168, 85, 247, 220],
getWidth: 2,
getDashArray: [8, 4],
dashJustified: true,
widthMinPixels: 2,
})
)
result.push(
new ScatterplotLayer({
id: 'analysis-polygon-points',
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 5,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
// --- 오염분석 원 그리기 ---
if (analysisCircleCenter) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-center',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 6,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 9,
})
)
if (analysisCircleRadiusM > 0) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-area',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: analysisCircleRadiusM,
radiusUnits: 'meters',
getFillColor: [168, 85, 247, 35],
getLineColor: [168, 85, 247, 200],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
}
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
@ -782,8 +887,39 @@ export function MapView({
)
}
// --- 시간 표시 라벨 (TextLayer) ---
if (visibleCenters.length > 0 && showTimeLabel) {
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
const pad = (n: number) => String(n).padStart(2, '0');
result.push(
new TextLayer({
id: 'time-labels',
data: visibleCenters,
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
getText: (d: (typeof visibleCenters)[0]) => {
if (baseTime) {
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
return `+${d.time}h`;
},
getSize: 12,
getColor: [255, 220, 50, 220] as [number, number, number, number],
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
getText: [simulationStartTime, currentTime],
},
})
)
}
// --- 바람 화살표 (TextLayer) ---
if (incidentCoord && windData.length > 0) {
if (incidentCoord && windData.length > 0 && showWind) {
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
@ -829,6 +965,8 @@ export function MapView({
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
])
// 3D 모드에 따른 지도 스타일 전환
@ -844,7 +982,7 @@ export function MapView({
}}
mapStyle={currentMapStyle}
className="w-full h-full"
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
onClick={handleMapClick}
attributionControl={false}
preserveDrawingBuffer={true}
@ -887,7 +1025,7 @@ export function MapView({
<DeckGLOverlay layers={deckLayers} />
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && (
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
)}
@ -928,6 +1066,16 @@ export function MapView({
({drawingPoints.length} )
</div>
)}
{drawAnalysisMode === 'polygon' && (
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
({analysisPolygonPoints.length})
</div>
)}
{drawAnalysisMode === 'circle' && (
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
</div>
)}
{/* 기상청 연계 정보 */}
<WeatherInfoPanel position={currentPosition} />

파일 보기

@ -0,0 +1,17 @@
import { cn } from '@common/utils/cn';
interface BadgeProps {
color?: 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'cyan' | 'neutral';
children: React.ReactNode;
className?: string;
}
const Badge = ({ color = 'neutral', children, className }: BadgeProps) => {
return (
<span className={cn('wing-badge', `wing-badge-${color}`, className)}>
{children}
</span>
);
};
export default Badge;

파일 보기

@ -0,0 +1,21 @@
import { cn } from '@common/utils/cn';
interface CardProps {
children: React.ReactNode;
hover?: boolean;
onClick?: () => void;
className?: string;
}
const Card = ({ children, hover = true, onClick, className }: CardProps) => {
return (
<div
className={cn('wing-card', hover && 'wing-card-hover', onClick && 'cursor-pointer', className)}
onClick={onClick}
>
{children}
</div>
);
};
export default Card;

파일 보기

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@common/utils/cn';
export interface Column<T> {
key: keyof T | string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
render?: (value: unknown, row: T) => React.ReactNode;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
onRowClick?: (row: T) => void;
stickyHeader?: boolean;
emptyMessage?: string;
className?: string;
}
const alignClass = (align?: 'left' | 'center' | 'right'): string => {
if (align === 'center') return 'text-center';
if (align === 'right') return 'text-right';
return 'text-left';
};
const DataTable = <T extends object>({
columns,
data,
onRowClick,
stickyHeader = true,
emptyMessage = '데이터가 없습니다.',
className,
}: DataTableProps<T>) => {
return (
<div className={cn('w-full overflow-auto', className)}>
<table className='wing-table'>
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
className={cn('wing-table-head', alignClass(col.align))}
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className='wing-table-cell text-center'
style={{ color: 'var(--t3)' }}
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, rowIdx) => (
<tr
key={rowIdx}
className={cn(onRowClick && 'wing-table-row')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => {
const rawValue = (row as Record<string, unknown>)[String(col.key)];
const cellContent = col.render
? col.render(rawValue, row)
: (rawValue as React.ReactNode);
return (
<td
key={String(col.key)}
className={cn('wing-table-cell', alignClass(col.align))}
style={col.width ? { width: col.width } : undefined}
>
{cellContent}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
);
};
export default DataTable;

파일 보기

@ -0,0 +1,73 @@
import { useEffect } from 'react';
import { cn } from '@common/utils/cn';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
footer?: React.ReactNode;
closeOnBackdrop?: boolean;
className?: string;
}
const Modal = ({
isOpen,
onClose,
title,
size = 'md',
children,
footer,
closeOnBackdrop = true,
className,
}: ModalProps) => {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleBackdropClick = () => {
if (closeOnBackdrop) onClose();
};
const handleModalClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
const sizeClass =
size === 'sm' ? 'wing-modal-sm' :
size === 'lg' ? 'wing-modal-lg' :
'wing-modal-md';
return (
<div className='wing-overlay' onClick={handleBackdropClick}>
<div
className={cn('wing-modal', 'flex flex-col', sizeClass, className)}
onClick={handleModalClick}
>
<div className='wing-modal-header'>
<span className='wing-title'>{title}</span>
<button className='wing-btn' onClick={onClose}>×</button>
</div>
<div className='wing-modal-body'>{children}</div>
{footer && <div className='wing-modal-footer'>{footer}</div>}
</div>
</div>
);
};
export default Modal;

파일 보기

@ -0,0 +1,123 @@
import { cn } from '@common/utils/cn';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showFirstLast?: boolean;
className?: string;
}
const getPageNumbers = (currentPage: number, totalPages: number): (number | '...')[] => {
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const delta = 2;
const left = currentPage - delta;
const right = currentPage + delta;
if (left > 1) {
pages.push(1);
if (left > 2) pages.push('...');
}
for (let i = Math.max(1, left); i <= Math.min(totalPages, right); i++) {
pages.push(i);
}
if (right < totalPages) {
if (right < totalPages - 1) pages.push('...');
pages.push(totalPages);
}
return pages;
};
const Pagination = ({
currentPage,
totalPages,
onPageChange,
showFirstLast = true,
className,
}: PaginationProps) => {
if (totalPages <= 1) return null;
const isFirst = currentPage === 1;
const isLast = currentPage === totalPages;
const pageNumbers = getPageNumbers(currentPage, totalPages);
const navBtnClass = (disabled: boolean) =>
cn(
'wing-btn wing-btn-secondary',
disabled && 'opacity-40 cursor-not-allowed',
);
return (
<div className={cn('flex items-center justify-center gap-1', className)}>
{showFirstLast && (
<button
className={navBtnClass(isFirst)}
onClick={() => !isFirst && onPageChange(1)}
disabled={isFirst}
aria-label='첫 페이지'
>
«
</button>
)}
<button
className={navBtnClass(isFirst)}
onClick={() => !isFirst && onPageChange(currentPage - 1)}
disabled={isFirst}
aria-label='이전 페이지'
>
</button>
{pageNumbers.map((page, idx) =>
page === '...' ? (
<span
key={`ellipsis-${idx}`}
className='wing-btn wing-btn-secondary opacity-50 cursor-default'
>
</span>
) : (
<button
key={page}
className={cn(
'wing-btn',
page === currentPage ? 'wing-btn-primary' : 'wing-btn-secondary',
)}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
),
)}
<button
className={navBtnClass(isLast)}
onClick={() => !isLast && onPageChange(currentPage + 1)}
disabled={isLast}
aria-label='다음 페이지'
>
</button>
{showFirstLast && (
<button
className={navBtnClass(isLast)}
onClick={() => !isLast && onPageChange(totalPages)}
disabled={isLast}
aria-label='마지막 페이지'
>
»
</button>
)}
</div>
);
};
export default Pagination;

파일 보기

@ -0,0 +1,45 @@
import React from 'react';
import { cn } from '@common/utils/cn';
interface SidePanelProps {
position: 'left' | 'right';
width?: 'narrow' | 'default' | 'wide';
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
const WIDTH_MAP: Record<NonNullable<SidePanelProps['width']>, string> = {
narrow: 'var(--panel-narrow)',
default: 'var(--panel-default)',
wide: 'var(--panel-wide)',
};
const SidePanel = ({
position,
width = 'default',
header,
footer,
children,
className,
}: SidePanelProps) => {
const panelClass = position === 'left' ? 'wing-panel-left' : 'wing-panel-right';
const style: React.CSSProperties =
width !== 'default' ? { width: WIDTH_MAP[width] } : {};
return (
<div className={cn(panelClass, className)} style={style}>
{header && <div className='wing-header-bar'>{header}</div>}
<div className='wing-panel-scroll'>{children}</div>
{footer && (
<div className='flex flex-col gap-1.5 p-2.5 border-t border-border'>
{footer}
</div>
)}
</div>
);
};
export default SidePanel;

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
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'
@ -38,7 +38,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
],
aerial: [
{ id: 'media', label: '영상사진관리', icon: '📷' },
{ id: 'analysis', label: '유출유면적분석', icon: '🧩' },
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
{ id: 'satellite', label: '위성요청', icon: '🛰' },
@ -91,17 +91,10 @@ function subscribe(listener: () => void) {
}
export function useSubMenu(mainTab: MainTab) {
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasPermission = useAuthStore((s) => s.hasPermission)
useEffect(() => {
const unsubscribe = subscribe(() => {
setActiveSubTabLocal(subMenuState[mainTab])
})
return unsubscribe
}, [mainTab])
const setActiveSubTab = (subTab: string) => {
setSubTab(mainTab, subTab)
}

파일 보기

@ -23,6 +23,22 @@
--fM: JetBrains Mono, monospace;
--rS: 6px;
--rM: 8px;
/* ── Z-index 스케일 ── */
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 1000;
--z-modal: 10000;
--z-toast: 10100;
/* ── Panel Width ── */
--panel-narrow: 280px;
--panel-default: 300px;
--panel-wide: 340px;
/* ── Transition ── */
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
}
* {

파일 보기

@ -24,6 +24,7 @@
}
/* ═══ Prediction Input Form ═══ */
/* @deprecated: wing-input 사용 권장 */
.prd-i {
width: 100%;
padding: 6px 10px;
@ -45,6 +46,7 @@
}
/* Select Dropdown */
/* @deprecated: wing-select 사용 권장 */
select.prd-i {
cursor: pointer;
padding-right: 30px;
@ -219,6 +221,7 @@
}
/* ═══ Buttons ═══ */
/* @deprecated: wing-btn + wing-btn-primary/secondary 사용 권장 */
.prd-btn {
width: 100%;
padding: 10px;

파일 보기

@ -28,6 +28,11 @@
.wing-card {
@apply rounded-md p-4 border border-border;
background: var(--bg3);
transition: border-color var(--transition-fast);
}
.wing-card-hover:hover {
border-color: rgba(6, 182, 212, 0.4);
}
.wing-card-sm {
@ -83,7 +88,42 @@
/* ── Badge ── */
.wing-badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean;
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-bold font-korean;
}
.wing-badge-neutral {
background: rgba(134, 144, 166, 0.15);
color: var(--t2);
}
.wing-badge-red {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
}
.wing-badge-blue {
background: rgba(59, 130, 246, 0.15);
color: var(--blue);
}
.wing-badge-green {
background: rgba(34, 197, 94, 0.15);
color: var(--green);
}
.wing-badge-yellow {
background: rgba(234, 179, 8, 0.15);
color: var(--yellow);
}
.wing-badge-purple {
background: rgba(168, 85, 247, 0.15);
color: var(--purple);
}
.wing-badge-cyan {
background: rgba(6, 182, 212, 0.15);
color: var(--cyan);
}
/* ── Button ── */
@ -151,11 +191,11 @@
/* ── Table ── */
.wing-table {
@apply w-full text-[10px] font-korean;
@apply w-full text-sm font-korean;
border-collapse: collapse;
}
.wing-th {
.wing-table-head {
@apply text-left font-semibold;
padding: 8px 10px;
color: var(--t3);
@ -163,17 +203,26 @@
border-bottom: 1px solid var(--bd);
}
.wing-td {
.wing-table-cell {
padding: 8px 10px;
color: var(--t2);
border-bottom: 1px solid var(--bd);
}
.wing-tr-hover:hover {
.wing-table-row {
transition: background 0.15s;
}
.wing-table-row:hover {
background: var(--bgH);
cursor: pointer;
}
/* 기존 alias (하위 호환) */
.wing-th { @apply text-left font-semibold; padding: 8px 10px; color: var(--t3); background: var(--bg2); border-bottom: 1px solid var(--bd); }
.wing-td { padding: 8px 10px; color: var(--t2); border-bottom: 1px solid var(--bd); }
.wing-tr-hover:hover { background: var(--bgH); cursor: pointer; }
/* ── Tab Bar ── */
.wing-tab-bar {
@apply flex gap-0.5 rounded-lg p-1 border border-border;
@ -201,7 +250,7 @@
/* ── Modal ── */
.wing-overlay {
@apply fixed inset-0 flex items-center justify-center;
z-index: 10000;
z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
@ -219,6 +268,127 @@
padding-bottom: 14px;
}
/* ── Select ── */
.wing-select {
@apply w-full rounded-sm text-[11px] font-korean outline-none;
padding: 6px 10px;
padding-right: 30px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: 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");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px;
transition: border-color 0.2s;
}
.wing-select:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.wing-select:focus {
border-color: var(--cyan);
}
.wing-select option {
background: #1a1f2e;
color: var(--t1);
padding: 10px;
font-size: 11px;
font-family: 'Noto Sans KR', sans-serif;
}
/* ── Textarea ── */
.wing-textarea {
@apply w-full rounded-sm text-[11px] font-korean outline-none;
padding: 8px 10px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
resize: vertical;
min-height: 80px;
}
.wing-textarea:focus {
border-color: var(--cyan);
}
.wing-textarea::placeholder {
color: var(--t3);
}
/* ── Search Input ── */
.wing-input-search {
@apply rounded-sm text-[11px] font-korean outline-none;
width: 256px;
padding: 6px 10px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
}
.wing-input-search:focus {
border-color: var(--cyan);
}
.wing-input-search::placeholder {
color: var(--t3);
}
/* ── Side Panel ── */
.wing-panel-right {
@apply flex flex-col h-full overflow-hidden;
width: var(--panel-default);
border-left: 1px solid var(--bd);
background: var(--bg1);
}
.wing-panel-left {
@apply flex flex-col h-full overflow-hidden;
width: var(--panel-default);
border-right: 1px solid var(--bd);
background: var(--bg1);
}
/* ── Modal Size Variants ── */
.wing-modal-sm { width: 400px; max-height: 90vh; }
.wing-modal-md { width: 560px; max-height: 90vh; }
.wing-modal-lg { width: 720px; max-height: 90vh; }
/* ── Modal Body / Footer ── */
.wing-modal-body {
@apply flex-1 overflow-y-auto px-5 py-4;
scrollbar-width: thin;
scrollbar-color: var(--bdL) transparent;
}
.wing-modal-footer {
@apply flex justify-end gap-2 px-5 border-t border-border;
padding-top: 12px;
padding-bottom: 12px;
}
/* ── Info Row (wing-kv alias) ── */
.wing-info-row {
@apply flex items-center justify-between;
padding: 6px 0;
}
.wing-info-label {
@apply text-[10px] font-korean;
color: var(--t3);
}
.wing-info-value {
@apply text-[11px] font-semibold font-mono;
}
/* ── Utility ── */
.wing-divider {
@apply w-full;

파일 보기

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

파일 보기

@ -186,6 +186,47 @@ export function generateAIBoomLines(
return boomLines
}
/** Ray casting — 점이 다각형 내부인지 판정 */
export function pointInPolygon(
point: { 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
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
}
return inside
}
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
if (polygon.length < 3) return 0
const n = polygon.length
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
const cosLat = Math.cos(latCenter * DEG2RAD)
let area = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
area += x1 * y2 - x2 * y1
}
return Math.abs(area) / 2
}
/** 원 면적 (km²) */
export function circleAreaKm2(radiusM: number): number {
return Math.PI * (radiusM / 1000) ** 2
}
/** 차단 시뮬레이션 실행 */
export function runContainmentAnalysis(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,

파일 보기

@ -184,7 +184,7 @@ export function MediaManagement() {
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
className="prd-i py-1.5 w-auto"
className="wing-select py-1.5 w-auto"
>
<option value="latest"></option>
<option value="name"></option>
@ -370,7 +370,7 @@ export function MediaManagement() {
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<select className="wing-select w-full">
<option> (DJI M300 RTK)</option>
<option> (DJI Mavic 3E)</option>
<option> (CN-235)</option>
@ -382,7 +382,7 @@ export function MediaManagement() {
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<select className="wing-select w-full">
<option> (2026-01-18)</option>
<option> (2026-01-18)</option>
<option> (2026-01-18)</option>
@ -391,7 +391,7 @@ export function MediaManagement() {
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"></label>
<textarea
className="prd-i w-full h-[60px] resize-y"
className="wing-input w-full h-[60px] resize-y"
placeholder="촬영 조건, 비고 등..."
/>
</div>

파일 보기

@ -1,20 +1,29 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import * as exifr from 'exifr';
import { stitchImages } from '../services/aerialApi';
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
import { navigateToTab } from '@common/hooks/useSubMenu';
import { decimalToDMS } from '@common/utils/coordinates';
const MAX_IMAGES = 6;
interface GpsInfo {
lat: number | null;
lon: number | null;
}
export function OilAreaAnalysis() {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]);
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
const [isStitching, setIsStitching] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const processedFilesRef = useRef<Set<File>>(new Set());
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
useEffect(() => {
@ -25,6 +34,34 @@ export function OilAreaAnalysis() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출
useEffect(() => {
selectedFiles.forEach((file, i) => {
if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file);
exifr.gps(file)
.then(gps => {
setImageGpsInfos(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = gps
? { lat: gps.latitude, lon: gps.longitude }
: { lat: null, lon: null };
return updated;
});
})
.catch(() => {
setImageGpsInfos(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = { lat: null, lon: null };
return updated;
});
});
});
}, [selectedFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const incoming = Array.from(e.target.files ?? []);
@ -56,6 +93,7 @@ export function OilAreaAnalysis() {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageGpsInfos(prev => prev.filter((_, i) => i !== idx));
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
@ -112,7 +150,7 @@ export function OilAreaAnalysis() {
<div className="flex gap-5 h-full overflow-hidden">
{/* ── Left Panel ── */}
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-[11px] text-text-3 mb-4 font-korean">
.
</div>
@ -201,15 +239,34 @@ export function OilAreaAnalysis() {
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
<div
key={i}
className="bg-bg-3 border border-border rounded-sm overflow-hidden"
className="bg-bg-3 border border-border rounded-sm overflow-hidden flex flex-col"
style={{ height: '300px' }}
>
{previewUrls[i] ? (
<img
src={previewUrls[i]}
alt={selectedFiles[i]?.name ?? ''}
className="w-full h-full object-cover"
/>
<>
<div className="flex-1 min-h-0 overflow-hidden">
<img
src={previewUrls[i]}
alt={selectedFiles[i]?.name ?? ''}
className="w-full h-full object-cover"
/>
</div>
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
{selectedFiles[i]?.name}
</div>
{imageGpsInfos[i] === undefined ? (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS ...</div>
) : imageGpsInfos[i]?.lat !== null ? (
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
{decimalToDMS(imageGpsInfos[i]!.lat!, true)}<br />
{decimalToDMS(imageGpsInfos[i]!.lon!, false)}
</div>
) : (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS </div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
{i + 1}

파일 보기

@ -116,9 +116,9 @@ function AssetManagement() {
placeholder="기관명 검색..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="prd-i w-40 py-1.5 px-2.5"
className="wing-input w-40 py-1.5 px-2.5"
/>
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option>
<option value="남해"></option>
<option value="서해"></option>
@ -126,7 +126,7 @@ function AssetManagement() {
<option value="동해"></option>
<option value="제주"></option>
</select>
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option>
<option value="해경관할"></option>
<option value="해경경찰서"></option>
@ -140,7 +140,7 @@ function AssetManagement() {
<option value="해군"></option>
<option value="기타"></option>
</select>
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option>
<option value="vessel"></option>
<option value="skimmer"></option>

파일 보기

@ -37,7 +37,7 @@ function AssetUpload() {
{/* Asset Classification */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<select className="wing-select w-full">
<option></option>
<option></option>
<option></option>
@ -50,7 +50,7 @@ function AssetUpload() {
{/* Jurisdiction */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<select className="wing-select w-full">
<option> - </option>
<option> - </option>
<option> - </option>

파일 보기

@ -173,7 +173,7 @@ function ShipInsurance() {
</div>
<div>
<label className="block text-[10px] font-semibold text-text-3 mb-1"></label>
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border">
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="wing-select min-w-[120px] border-border">
<option value=""></option>
<option value="일반선박"></option>
<option value="유조선"></option>
@ -181,7 +181,7 @@ function ShipInsurance() {
</div>
<div>
<label className="block text-[10px] font-semibold text-text-3 mb-1"></label>
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border">
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="wing-select min-w-[160px] border-border">
<option value=""></option>
<option></option>
<option></option>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useAuthStore } from '@common/store/authStore'
import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
import Badge from '@common/components/ui/Badge'
// 카테고리 코드 → 표시명
const CATEGORY_LABELS: Record<string, string> = {
@ -10,14 +11,6 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼',
}
// 카테고리별 배지 색상
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
}
interface BoardDetailViewProps {
postSn: number
onBack: () => void
@ -64,10 +57,10 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
return (
<div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<div className="wing-header-bar">
<button
onClick={onBack}
className="flex items-center gap-2 text-sm font-semibold text-text-2 hover:text-text-1 transition-colors"
className="wing-btn wing-btn-secondary flex items-center gap-2"
>
<span></span>
<span></span>
@ -76,13 +69,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
className="wing-btn wing-btn-secondary"
>
</button>
<button
onClick={onDelete}
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
className="wing-btn bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
>
</button>
@ -96,13 +89,9 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
{/* 게시글 헤더 */}
<div className="pb-6 border-b border-border">
<div className="flex items-center gap-3 mb-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
{post.pinnedYn === 'Y' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
📌
</span>
<Badge color="yellow">📌 </Badge>
)}
</div>
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '@common/store/authStore';
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
import Badge from '@common/components/ui/Badge';
// 카테고리 코드 ↔ 표시명 매핑
const CATEGORY_MAP: Record<string, string> = {
@ -17,13 +18,6 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
{ label: 'Q&A', code: 'QNA' },
];
const CATEGORY_STYLE: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-blue-500/20 text-blue-400',
QNA: 'bg-green-500/20 text-green-400',
MANUAL: 'bg-yellow-500/20 text-yellow-400',
};
const PAGE_SIZE = 20;
interface BoardListTableProps {
@ -97,7 +91,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
return (
<div className="flex flex-col h-full bg-bg-0">
{/* Header with Search and Write Button */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<div className="wing-header-bar">
<div className="flex items-center gap-4">
<div className="flex gap-2">
{CATEGORY_FILTER.map((cat) => (
@ -123,13 +117,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
className="wing-input-search"
/>
{canWrite && (
<button
onClick={onWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
className="wing-btn wing-btn-primary flex items-center gap-2"
>
<span>+</span>
<span></span>
@ -146,15 +140,15 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
</div>
) : (
<>
<table className="w-full border-collapse">
<table className="wing-table">
<thead>
<tr className="border-b-2 border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24"></th>
<th className="wing-table-head w-20"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head w-24"></th>
</tr>
</thead>
<tbody>
@ -162,27 +156,19 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<tr
key={post.sn}
onClick={() => onPostClick(post.sn)}
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
className="wing-table-row"
>
<td className="px-4 py-4 text-sm text-text-1">
<td className="wing-table-cell text-text-1">
{post.pinnedYn === 'Y' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
</span>
<Badge color="cyan"></Badge>
) : (
post.sn
)}
</td>
<td className="px-4 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
}`}
>
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
</span>
<td className="wing-table-cell">
<Badge>{CATEGORY_MAP[post.categoryCd] || post.categoryCd}</Badge>
</td>
<td className="px-4 py-4">
<td className="wing-table-cell">
<span
className={`text-sm ${
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
@ -191,9 +177,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
{post.title}
</span>
</td>
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
<td className="wing-table-cell">{post.authorName}</td>
<td className="wing-table-cell text-text-3">{formatDate(post.regDtm)}</td>
<td className="wing-table-cell text-text-3">{post.viewCnt}</td>
</tr>
))}
</tbody>
@ -214,7 +200,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
className="wing-btn wing-btn-secondary disabled:opacity-40"
>
</button>
@ -222,10 +208,10 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded ${
className={`wing-btn ${
page === p
? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
? 'wing-btn-primary font-semibold'
: 'wing-btn-secondary'
}`}
>
{p}
@ -234,7 +220,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
className="wing-btn wing-btn-secondary disabled:opacity-40"
>
</button>

파일 보기

@ -3,6 +3,8 @@ import { useSubMenu } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore'
import { BoardWriteForm } from './BoardWriteForm'
import { BoardDetailView } from './BoardDetailView'
import Badge from '@common/components/ui/Badge'
import Card from '@common/components/ui/Card'
import {
fetchBoardPosts,
deleteBoardPost,
@ -33,14 +35,6 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼',
}
// 카테고리별 배지 색상
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
}
const PAGE_SIZE = 20
export function BoardView() {
@ -190,15 +184,6 @@ export function BoardView() {
const filteredManuals = manualList
const catColor = (cat: string) => {
switch (cat) {
case '방제매뉴얼': return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' }
case '대응매뉴얼': return { bg: 'rgba(249,115,22,.15)', text: '#f97316' }
case '교육자료': return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' }
case '법령·규정': return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' }
default: return { bg: 'rgba(100,100,100,.15)', text: '#999' }
}
}
if (activeSubTab === 'manual') {
return (
@ -206,7 +191,7 @@ export function BoardView() {
<div className="flex-1 relative overflow-hidden">
<div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
<div className="wing-header-bar">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-lg">📘</span>
@ -216,12 +201,11 @@ export function BoardView() {
<div className="flex gap-1 ml-4">
{manualCategories.map(cat => (
<button key={cat} onClick={() => setManualCategory(cat)}
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
style={{
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg3)',
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--bd)',
color: manualCategory === cat ? 'var(--cyan)' : 'var(--t3)',
}}>
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all border ${
manualCategory === cat
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
: 'bg-bg-3 border-border text-text-3'
}`}>
{cat}
</button>
))}
@ -229,10 +213,9 @@ export function BoardView() {
</div>
<div className="flex items-center gap-3">
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', outline: 'none' }} />
className="wing-input-search" />
<button onClick={() => setShowUploadModal(true)}
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
className="wing-btn wing-btn-primary flex items-center gap-1.5">
📤
</button>
</div>
@ -246,33 +229,18 @@ export function BoardView() {
</div>
) : (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
{filteredManuals.map(file => {
const cc = catColor(file.catgNm)
return (
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
background: 'var(--bg3)', border: '1px solid var(--bd)',
cursor: 'pointer',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
>
{filteredManuals.map(file => (
<Card key={file.manualSn}>
<div className="flex items-center justify-between mb-3">
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
{file.catgNm}
</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
{file.version}
</span>
<Badge>{file.catgNm}</Badge>
<Badge>{file.version}</Badge>
</div>
<div className="text-[12px] font-bold mb-3 leading-[1.5]">
{file.title}
</div>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
<span style={{ fontSize: 12 }}>📄</span>
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
<Badge>📄 {file.fileTp || 'PDF'}</Badge>
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
</div>
<div className="flex items-center justify-end gap-1 mb-2">
<button onClick={(e) => {
@ -287,8 +255,7 @@ export function BoardView() {
})
setShowUploadModal(true)
}}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)', color: '#3b82f6', cursor: 'pointer' }}
className="wing-btn wing-btn-secondary text-[10px]"
title="수정">
</button>
@ -303,19 +270,18 @@ export function BoardView() {
}
}
}}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: '#ef4444', cursor: 'pointer' }}
className="wing-btn text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
title="삭제">
🗑
</button>
</div>
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
<div className="flex items-center justify-between pt-3 border-t border-border">
<div className="flex items-center gap-3 text-[10px] text-text-3">
<span>{file.authorNm}</span>
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
{file.dwnldCnt}
</span>
<button onClick={async (e) => {
@ -350,17 +316,13 @@ export function BoardView() {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
className="px-3 py-1 rounded text-[10px] font-semibold transition-all" style={{
background: 'rgba(6,182,212,.1)', border: '1px solid rgba(6,182,212,.25)',
color: '#22d3ee', cursor: 'pointer',
}}>
className="wing-btn wing-btn-primary text-[10px]">
📥
</button>
</div>
</div>
</div>
)
})}
</Card>
))}
</div>
)}
@ -392,16 +354,14 @@ export function BoardView() {
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label>
<div className="flex gap-1.5">
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
const cc = catColor(cat)
const isActive = uploadForm.category === cat
return (
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
style={{
background: isActive ? cc.bg : 'var(--bg3)',
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--bd)',
color: isActive ? cc.text : 'var(--t3)',
}}>
className={`flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer border ${
isActive
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
: 'bg-bg-3 border-border text-text-3'
}`}>
{cat}
</button>
)
@ -412,13 +372,13 @@ export function BoardView() {
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label>
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
</div>
<div>
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label>
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
</div>
<div>
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label>
@ -458,7 +418,7 @@ export function BoardView() {
</div>
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-3 border border-border text-text-3 cursor-pointer">
className="wing-btn wing-btn-secondary">
</button>
<button onClick={async () => {
@ -491,7 +451,7 @@ export function BoardView() {
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
}
}}
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer" style={{ background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee' }}>
className="wing-btn wing-btn-primary px-6 py-2">
{editingManualId ? '✏️ 수정' : '📤 업로드'}
</button>
</div>
@ -547,7 +507,7 @@ export function BoardView() {
<div className="flex-1 relative overflow-hidden">
<div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<div className="wing-header-bar">
<div className="text-sm text-text-3">
<span className="text-text-1 font-semibold">{totalCount}</span>
</div>
@ -558,12 +518,12 @@ export function BoardView() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
className="wing-input-search"
/>
{hasPermission(getWriteResource(), 'CREATE') && (
<button
onClick={handleWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
className="wing-btn wing-btn-primary"
>
</button>
@ -579,42 +539,40 @@ export function BoardView() {
</div>
) : (
<>
<table className="w-full border-collapse">
<table className="wing-table">
<thead>
<tr className="border-b-2 border-border">
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16"></th>
<th className="wing-table-head text-center w-16"></th>
<th className="wing-table-head text-center w-24"></th>
<th className="wing-table-head"></th>
<th className="wing-table-head text-center w-24"></th>
<th className="wing-table-head text-center w-28"></th>
<th className="wing-table-head text-center w-16"></th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr
key={post.sn}
className="border-b border-border hover:bg-bg-2 transition-colors"
className="wing-table-row"
>
<td className="px-4 py-4 text-sm text-text-1 text-center">{post.sn}</td>
<td className="px-4 py-4 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
<td className="wing-table-cell text-text-1 text-center">{post.sn}</td>
<td className="wing-table-cell text-center">
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
</td>
<td
className="px-4 py-4 cursor-pointer"
className="wing-table-cell cursor-pointer"
onClick={() => handlePostClick(post.sn)}
>
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
{post.pinnedYn === 'Y' && '📌 '}{post.title}
</span>
</td>
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td>
<td className="px-4 py-4 text-sm text-text-3 text-center">
<td className="wing-table-cell text-center">{post.authorName}</td>
<td className="wing-table-cell text-text-3 text-center">
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.viewCnt}</td>
<td className="wing-table-cell text-text-3 text-center">{post.viewCnt}</td>
</tr>
))}
</tbody>
@ -636,10 +594,10 @@ export function BoardView() {
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded transition-colors ${
className={`wing-btn ${
p === page
? 'bg-primary-cyan/20 text-primary-cyan font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
? 'wing-btn-primary font-semibold'
: 'wing-btn-secondary'
}`}
>
{p}

파일 보기

@ -121,7 +121,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
return (
<div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<div className="wing-header-bar">
<h2 className="text-lg font-semibold text-text-1">
{isEditMode ? '게시글 수정' : '게시글 작성'}
</h2>
@ -140,7 +140,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
value={categoryCd}
onChange={(e) => setCategoryCd(e.target.value)}
disabled={isEditMode}
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none disabled:opacity-50"
className="wing-select w-full disabled:opacity-50"
>
{CATEGORY_OPTIONS.map(opt => (
<option key={opt.code} value={opt.code}>{opt.label}</option>
@ -159,7 +159,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
placeholder="제목을 입력하세요"
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
className="wing-input w-full"
/>
</div>
@ -174,7 +174,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
maxLength={10000}
placeholder="내용을 입력하세요"
rows={15}
className="w-full px-4 py-3 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none resize-none"
className="wing-textarea w-full"
/>
</div>
@ -192,7 +192,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
/>
<label
htmlFor="file-upload"
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 cursor-pointer transition-colors"
className="wing-btn wing-btn-secondary cursor-pointer"
>
</label>
@ -207,14 +207,14 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
<button
type="button"
onClick={onCancel}
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
className="wing-btn wing-btn-secondary"
>
</button>
<button
type="submit"
disabled={isLoading}
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
className="wing-btn wing-btn-primary disabled:opacity-50"
>
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
</button>

파일 보기

@ -229,7 +229,7 @@ export function HNSLeftPanel({
{/* 사고명 직접 입력 */}
<input
className="prd-i w-full"
className="wing-input w-full"
value={accidentName}
onChange={(e) => setAccidentName(e.target.value)}
placeholder="사고명 직접 입력"
@ -237,7 +237,7 @@ export function HNSLeftPanel({
{/* 또는 사고 리스트에서 선택 */}
<ComboBox
className="prd-i"
className="wing-input"
value={selectedIncidentSn}
onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택"
@ -252,13 +252,13 @@ export function HNSLeftPanel({
<label className="text-[10px] text-text-3 block mb-0.5"> </label>
<div className="grid grid-cols-2 gap-1">
<input
className="prd-i"
className="wing-input"
type="date"
value={accidentDate}
onChange={(e) => setAccidentDate(e.target.value)}
/>
<input
className="prd-i"
className="wing-input"
type="time"
value={accidentTime}
onChange={(e) => setAccidentTime(e.target.value)}
@ -269,7 +269,7 @@ export function HNSLeftPanel({
{/* 좌표 + 지도 버튼 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input
className="prd-i flex-1 font-mono"
className="wing-input flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord?.lat.toFixed(4) ?? ''}
@ -280,7 +280,7 @@ export function HNSLeftPanel({
}}
/>
<input
className="prd-i flex-1 font-mono"
className="wing-input flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord?.lon.toFixed(4) ?? ''}
@ -304,7 +304,7 @@ export function HNSLeftPanel({
{/* 유출형태 + 물질명 */}
<div className="grid grid-cols-2 gap-1">
<ComboBox
className="prd-i"
className="wing-input"
value={releaseType}
onChange={(v) => setReleaseType(v as ReleaseType)}
options={[
@ -314,7 +314,7 @@ export function HNSLeftPanel({
]}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={substance}
onChange={handleSubstanceChange}
options={[
@ -335,14 +335,14 @@ export function HNSLeftPanel({
{/* 유출량 + 단위 + 예측시간 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
<input
className="prd-i font-mono"
className="wing-input font-mono"
type="number"
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
onChange={() => {}}
options={
@ -352,7 +352,7 @@ export function HNSLeftPanel({
}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={predictionTime}
onChange={setPredictionTime}
options={[
@ -371,7 +371,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> </label>
<ComboBox
className="prd-i"
className="wing-input"
value={algorithm}
onChange={setAlgorithm}
options={[
@ -385,7 +385,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> </label>
<ComboBox
className="prd-i"
className="wing-input"
value={criteriaModel}
onChange={setCriteriaModel}
options={[
@ -419,7 +419,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={emissionRate}
onChange={(e) => setEmissionRate(e.target.value)}
@ -430,7 +430,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (s)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={releaseDuration}
onChange={(e) => setReleaseDuration(e.target.value)}
@ -442,7 +442,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)}
@ -459,7 +459,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={totalRelease}
onChange={(e) => setTotalRelease(e.target.value)}
@ -470,7 +470,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)}
@ -488,7 +488,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={emissionRate}
onChange={(e) => setEmissionRate(e.target.value)}
@ -499,7 +499,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={poolRadius}
onChange={(e) => setPoolRadius(e.target.value)}
@ -511,7 +511,7 @@ export function HNSLeftPanel({
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input
className="prd-i w-full font-mono"
className="wing-input w-full font-mono"
type="number"
value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)}
@ -588,7 +588,7 @@ export function HNSLeftPanel({
{/* 실행 버튼 */}
<div className="flex flex-col gap-1 mt-2">
<button
className="prd-btn pri"
className="wing-btn wing-btn-primary"
style={{ padding: '7px', fontSize: '11px' }}
onClick={onRunPrediction}
disabled={isRunningPrediction}
@ -596,7 +596,7 @@ export function HNSLeftPanel({
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
</button>
<button
className="prd-btn sec"
className="wing-btn wing-btn-secondary"
style={{ padding: '7px', fontSize: '11px' }}
onClick={handleReset}
>

파일 보기

@ -109,7 +109,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
{/* HNS 물질 */}
<FG label="HNS 물질">
<ComboBox
className="prd-i"
className="wing-input"
value={substance}
onChange={setSubstance}
options={[
@ -127,7 +127,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
<div className="grid grid-cols-2 gap-[10px]">
<FG label="유출 유형">
<ComboBox
className="prd-i"
className="wing-input"
value={releaseType}
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
options={[
@ -139,7 +139,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
</FG>
<FG label={`${amountLabel} (${amountUnit})`}>
<input
className="prd-i font-mono"
className="wing-input font-mono"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
@ -152,7 +152,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
<div className="grid grid-cols-2 gap-[10px]">
<FG label="예측 알고리즘">
<ComboBox
className="prd-i"
className="wing-input"
value={algorithm}
onChange={setAlgorithm}
options={[
@ -165,7 +165,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
</FG>
<FG label="예측 시간">
<ComboBox
className="prd-i"
className="wing-input"
value={predictionTime}
onChange={setPredictionTime}
options={[

파일 보기

@ -148,7 +148,7 @@ export function HNSScenarioView() {
<select
value={selectedIncident}
onChange={(e) => setSelectedIncident(Number(e.target.value))}
className="prd-i w-[280px] text-[11px]"
className="wing-select w-[280px] text-[11px]"
>
{incidents.length === 0
? <option value={0}> </option>
@ -708,16 +708,16 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
{/* 기본 정보 */}
<ModalSection title="기본 정보">
<ModalField label="시나리오명">
<input className="prd-i" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
<input className="wing-input" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
</ModalField>
<div className="grid grid-cols-2 gap-2">
<ModalField label="시간 단계">
<select className="prd-i" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
<select className="wing-select" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
{['T+0h', 'T+1h', 'T+3h', 'T+6h', 'T+12h', 'T+24h'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
</ModalField>
<ModalField label="기준 시각">
<input className="prd-i" type="text" defaultValue="2024-11-03 08:00" />
<input className="wing-input" type="text" defaultValue="2024-11-03 08:00" />
</ModalField>
</div>
</ModalSection>
@ -725,7 +725,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
{/* 물질·유출 조건 */}
<ModalSection title="물질 · 유출 조건">
<ModalField label="HNS 물질">
<select className="prd-i" value={material} onChange={e => setMaterial(e.target.value)}>
<select className="wing-select" value={material} onChange={e => setMaterial(e.target.value)}>
{MATERIALS.map(m => <option key={m.key} value={m.key}>{m.name} ({m.key})</option>)}
</select>
</ModalField>
@ -750,7 +750,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
</div>
<div className="grid grid-cols-2 gap-2">
<ModalField label="유출 유형">
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
<select className="wing-select" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
<option value="instant"> </option>
<option value="continuous"> </option>
<option value="semi"></option>
@ -758,8 +758,8 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
</ModalField>
<ModalField label="유출량">
<div className="flex gap-1">
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
<select className="prd-i w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
<input className="wing-input flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
<select className="wing-select w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
@ -771,31 +771,31 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
<ModalSection title="기상 조건">
<div className="grid grid-cols-3 gap-2">
<ModalField label="풍향">
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
<select className="wing-select" value={windDir} onChange={e => setWindDir(e.target.value)}>
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
</ModalField>
<ModalField label="풍속 (m/s)">
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
<input className="wing-input" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
</ModalField>
<ModalField label="기온 (°C)">
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
<input className="wing-input" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
</ModalField>
</div>
<div className="grid grid-cols-2 gap-2">
<ModalField label="대기안정도 (Pasquill)">
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
<select className="wing-select" value={stability} onChange={e => setStability(e.target.value)}>
{['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)'].map(s => <option key={s[0]} value={s[0]}>{s}</option>)}
</select>
</ModalField>
<ModalField label="확산 모델">
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
<select className="wing-select" value={model} onChange={e => setModel(e.target.value)}>
{['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian'].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</ModalField>
</div>
<ModalField label="예측 시간">
<select className="prd-i" value={predTime} onChange={e => setPredTime(e.target.value)}>
<select className="wing-select" value={predTime} onChange={e => setPredTime(e.target.value)}>
{['1', '3', '6', '12', '24', '48'].map(h => <option key={h} value={h}>{h}</option>)}
</select>
</ModalField>

파일 보기

@ -53,7 +53,7 @@ export function LeftPanel({
predictionInput: true,
incident: false,
impactResources: false,
infoLayer: true,
infoLayer: false,
oilBoom: false,
})
@ -112,45 +112,73 @@ export function LeftPanel({
</div>
{expandedSections.incident && (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
</div>
selectedAnalysis ? (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
{(() => {
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
ACTIVE: {
label: '진행중',
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
dot: 'bg-status-red animate-pulse',
},
INVESTIGATING: {
label: '조사중',
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
dot: 'bg-status-orange animate-pulse',
},
CLOSED: {
label: '종료',
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
dot: 'bg-text-3',
},
}
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
{s.label}
</div>
)
})()}
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
</div>
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
<p className="text-[11px] text-text-3 font-korean text-center py-2"> .</p>
</div>
)
)}
</div>

파일 보기

@ -16,8 +16,9 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
import { useSimulationStatus } from '../hooks/useSimulationStatus'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api'
import { generateAIBoomLines } from '@common/utils/geo'
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
@ -35,6 +36,13 @@ export interface SensitiveResource {
arrivalTimeH: number
}
export interface DisplayControls {
showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속
showBeached: boolean; // 해안부착
showTimeLabel: boolean; // 시간 표시
}
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
@ -114,7 +122,8 @@ export function OilSpillView() {
const [windData, setWindData] = useState<WindPoint[][]>([])
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
const [simulationError, setSimulationError] = useState<string | null>(null)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [predictionTime, setPredictionTime] = useState(48)
const [accidentTime, setAccidentTime] = useState<string>('')
const [spillType, setSpillType] = useState('연속')
@ -142,6 +151,14 @@ export function OilSpillView() {
const [layerOpacity, setLayerOpacity] = useState(50)
const [layerBrightness, setLayerBrightness] = useState(50)
// 표시 정보 제어
const [displayControls, setDisplayControls] = useState<DisplayControls>({
showCurrent: true,
showWind: true,
showBeached: false,
showTimeLabel: false,
})
// 타임라인 플레이어 상태
const [isPlaying, setIsPlaying] = useState(false)
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
@ -175,6 +192,16 @@ export function OilSpillView() {
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
const { data: simStatus } = useSimulationStatus(currentExecSn)
// 오염분석 상태
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
// 원 분석용 derived 값 (state 아님)
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
const analysisCircleRadiusM = circleRadiusNm * 1852
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
@ -373,6 +400,7 @@ export function OilSpillView() {
if (simStatus.status === 'ERROR') {
setIsRunningSimulation(false);
setCurrentExecSn(null);
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
}
}, [simStatus, incidentCoord, algorithmSettings]);
@ -465,8 +493,7 @@ export function OilSpillView() {
setCenterPoints(cp ?? [])
setWindData(wd ?? [])
setHydrData(hd ?? [])
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
setBoomLines(booms)
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
@ -482,10 +509,9 @@ export function OilSpillView() {
}
// 데모 궤적 생성 (fallback)
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
@ -498,12 +524,65 @@ export function OilSpillView() {
const handleMapClick = (lon: number, lat: number) => {
if (isDrawingBoom) {
setDrawingPoints(prev => [...prev, { lat, lon }])
} else if (drawAnalysisMode === 'polygon') {
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
} else {
setIncidentCoord({ lon, lat })
setIsSelectingLocation(false)
}
}
const handleStartPolygonDraw = () => {
setDrawAnalysisMode('polygon')
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleRunPolygonAnalysis = () => {
if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
setAnalysisResult({
area: polygonAreaKm2(analysisPolygonPoints),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
setDrawAnalysisMode(null)
}
const handleRunCircleAnalysis = () => {
if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
).length
const sensitiveCount = sensitiveResources.filter(r =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
).length
setAnalysisResult({
area: circleAreaKm2(radiusM),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
}
const handleCancelAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
}
const handleClearAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
setIncidentCoord({ lat: result.lat, lon: result.lon })
setFlyToCoord({ lat: result.lat, lon: result.lon })
@ -598,9 +677,12 @@ export function OilSpillView() {
setIncidentName('');
}
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
} catch {
} catch (err) {
setIsRunningSimulation(false);
// 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리)
const msg =
(err as { message?: string })?.message
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
setSimulationError(msg);
}
}
@ -723,7 +805,7 @@ export function OilSpillView() {
enabledLayers={enabledLayers}
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom}
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
onMapClick={handleMapClick}
oilTrajectory={oilTrajectory}
selectedModels={selectedModels}
@ -739,6 +821,10 @@ export function OilSpillView() {
flyToTarget={flyToTarget}
fitBoundsTarget={fitBoundsTarget}
onIncidentFlyEnd={handleFlyEnd}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
analysisCircleCenter={analysisCircleCenter}
analysisCircleRadiusM={analysisCircleRadiusM}
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true,
@ -748,6 +834,11 @@ export function OilSpillView() {
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
} : undefined}
showCurrent={displayControls.showCurrent}
showWind={displayControls.showWind}
showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
@ -932,7 +1023,30 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
{activeSubTab === 'analysis' && (
<RightPanel
onOpenBacktrack={handleOpenBacktrack}
onOpenRecalc={() => setRecalcModalOpen(true)}
onOpenReport={handleOpenReport}
detail={analysisDetail}
summary={simulationSummary}
displayControls={displayControls}
onDisplayControlsChange={setDisplayControls}
analysisTab={analysisTab}
onSwitchAnalysisTab={setAnalysisTab}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
circleRadiusNm={circleRadiusNm}
onCircleRadiusChange={setCircleRadiusNm}
analysisResult={analysisResult}
incidentCoord={incidentCoord}
onStartPolygonDraw={handleStartPolygonDraw}
onRunPolygonAnalysis={handleRunPolygonAnalysis}
onRunCircleAnalysis={handleRunCircleAnalysis}
onCancelAnalysis={handleCancelAnalysis}
onClearAnalysis={handleClearAnalysis}
/>
)}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && (
@ -942,6 +1056,14 @@ export function OilSpillView() {
/>
)}
{/* 확산 예측 에러 팝업 */}
{simulationError && (
<SimulationErrorModal
message={simulationError}
onClose={() => setSimulationError(null)}
/>
)}
{/* 재계산 모달 */}
<RecalcModal
isOpen={recalcModalOpen}

파일 보기

@ -1,7 +1,6 @@
import { useState, useRef } from 'react'
import { decimalToDMS } from '@common/utils/coordinates'
import { ComboBox } from '@common/components/ui/ComboBox'
import { ALL_MODELS } from './OilSpillView'
import type { PredictionModel } from './OilSpillView'
import { analyzeImage } from '../services/predictionApi'
import type { ImageAnalyzeResult } from '../services/predictionApi'
@ -152,12 +151,12 @@ const PredictionInputSection = ({
{inputMode === 'direct' && (
<>
<input
className="prd-i"
className="wing-input"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
<input className="wing-input" placeholder="또는 사고 리스트에서 선택" readOnly />
</>
)}
@ -213,7 +212,7 @@ const PredictionInputSection = ({
{/* 분석 실행 버튼 */}
<button
className="prd-btn pri"
className="wing-btn wing-btn-primary"
style={{ padding: '7px', fontSize: '11px' }}
onClick={handleAnalyze}
disabled={!uploadedFile || isAnalyzing}
@ -264,7 +263,7 @@ const PredictionInputSection = ({
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-text-3 font-korean"> (KST)</label>
<input
className="prd-i"
className="wing-input"
type="datetime-local"
value={accidentTime}
onChange={(e) => onAccidentTimeChange(e.target.value)}
@ -276,7 +275,7 @@ const PredictionInputSection = ({
<div className="flex flex-col gap-1">
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input
className="prd-i"
className="wing-input"
type="number"
step="0.0001"
value={incidentCoord?.lat ?? ''}
@ -287,7 +286,7 @@ const PredictionInputSection = ({
placeholder="위도°"
/>
<input
className="prd-i"
className="wing-input"
type="number"
step="0.0001"
value={incidentCoord?.lon ?? ''}
@ -316,7 +315,7 @@ const PredictionInputSection = ({
{/* Oil Type + Oil Kind */}
<div className="grid grid-cols-2 gap-1">
<ComboBox
className="prd-i"
className="wing-input"
value={spillType}
onChange={onSpillTypeChange}
options={[
@ -326,7 +325,7 @@ const PredictionInputSection = ({
]}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={oilType}
onChange={onOilTypeChange}
options={[
@ -343,7 +342,7 @@ const PredictionInputSection = ({
{/* Volume + Unit + Duration */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
<input
className="prd-i"
className="wing-input"
placeholder="유출량"
type="number"
min="1"
@ -352,7 +351,7 @@ const PredictionInputSection = ({
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={spillUnit}
onChange={onSpillUnitChange}
options={[
@ -362,7 +361,7 @@ const PredictionInputSection = ({
]}
/>
<ComboBox
className="prd-i"
className="wing-input"
value={predictionTime}
onChange={(v) => onPredictionTimeChange(parseInt(v))}
options={[
@ -379,10 +378,13 @@ const PredictionInputSection = ({
<div className="h-px bg-border my-0.5" />
{/* Model Selection (다중 선택) */}
{/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
<div className="flex flex-wrap gap-[3px]">
{/* OpenDrift (KOSPS )
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */}
{/* OpenDrift (POSEIDON )
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */}
{([
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
] as const).map(m => (
<div
@ -402,6 +404,7 @@ const PredictionInputSection = ({
{m.id}
</div>
))}
{/* OpenDrift ( )
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
onClick={() => {
@ -415,11 +418,19 @@ const PredictionInputSection = ({
<span className="prd-md" style={{ background: 'var(--purple)' }} />
</div>
*/}
</div>
{/* 모델 미선택 경고 */}
{selectedModels.size === 0 && (
<p className="text-[10px] text-status-red font-korean">
.
</p>
)}
{/* Run Button */}
<button
className="prd-btn pri mt-0.5"
className="wing-btn wing-btn-primary mt-0.5"
style={{ padding: '7px', fontSize: '11px' }}
onClick={onRunSimulation}
disabled={isRunningSimulation}

파일 보기

@ -173,7 +173,7 @@ export function RecalcModal({
{/* 유종 */}
<FieldGroup label="유종">
<select
className="prd-i"
className="wing-select"
value={oilType}
onChange={(e) => setOilType(e.target.value)}
>
@ -188,12 +188,12 @@ export function RecalcModal({
<div className="flex gap-1.5">
<input
type="number"
className="prd-i flex-1"
className="wing-input flex-1"
value={spillAmount}
onChange={(e) => setSpillAmount(Number(e.target.value))}
/>
<select
className="prd-i"
className="wing-select"
value={spillUnit}
onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')}
style={{ width: '70px' }}
@ -208,7 +208,7 @@ export function RecalcModal({
{/* 유출 형태 */}
<FieldGroup label="유출 형태">
<select
className="prd-i"
className="wing-select"
value={spillType}
onChange={(e) => setSpillType(e.target.value)}
>
@ -221,7 +221,7 @@ export function RecalcModal({
{/* 예측 시간 */}
<FieldGroup label="예측 시간">
<select
className="prd-i"
className="wing-select"
value={predictionTime}
onChange={(e) => setPredictionTime(Number(e.target.value))}
>
@ -240,7 +240,7 @@ export function RecalcModal({
</div>
<input
type="number"
className="prd-i font-mono"
className="wing-input font-mono"
value={lat}
step={0.0001}
onChange={(e) => setLat(Number(e.target.value))}
@ -252,7 +252,7 @@ export function RecalcModal({
</div>
<input
type="number"
className="prd-i font-mono"
className="wing-input font-mono"
value={lon}
step={0.0001}
onChange={(e) => setLon(Number(e.target.value))}

파일 보기

@ -1,7 +1,47 @@
import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) {
interface AnalysisResult {
area: number
particleCount: number
particlePercent: number
sensitiveCount: number
}
interface RightPanelProps {
onOpenBacktrack?: () => void
onOpenRecalc?: () => void
onOpenReport?: () => void
detail?: PredictionDetail | null
summary?: SimulationSummary | null
displayControls?: DisplayControls
onDisplayControlsChange?: (controls: DisplayControls) => void
analysisTab?: 'polygon' | 'circle'
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
drawAnalysisMode?: 'polygon' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
circleRadiusNm?: number
onCircleRadiusChange?: (nm: number) => void
analysisResult?: AnalysisResult | null
incidentCoord?: { lat: number; lon: number } | null
onStartPolygonDraw?: () => void
onRunPolygonAnalysis?: () => void
onRunCircleAnalysis?: () => void
onCancelAnalysis?: () => void
onClearAnalysis?: () => void
}
export function RightPanel({
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
displayControls, onDisplayControlsChange,
analysisTab = 'polygon', onSwitchAnalysisTab,
drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange,
analysisResult,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis,
}: RightPanelProps) {
const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1]
const spill = detail?.spill
@ -24,20 +64,140 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 표시 정보 제어 */}
<Section title="표시 정보 제어">
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel> </CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<ControlledCheckbox
checked={displayControls?.showCurrent ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showWind ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showTimeLabel ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
> </ControlledCheckbox>
</div>
</Section>
{/* 오염분석 */}
<Section title="오염분석">
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
📐
</button>
{/* 탭 전환 */}
<div className="flex gap-[3px] mb-2">
{(['polygon', 'circle'] as const).map((tab) => (
<button
key={tab}
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
analysisTab === tab
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
}`}
>
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
</button>
))}
</div>
{/* 다각형 패널 */}
{analysisTab === 'polygon' && (
<div>
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
.
</p>
{!drawAnalysisMode && !analysisResult && (
<button
onClick={onStartPolygonDraw}
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
>
📐
</button>
)}
{drawAnalysisMode === 'polygon' && (
<div className="space-y-2">
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
<br />
<span className="text-text-3"> {analysisPolygonPoints.length} </span>
</div>
<div className="flex gap-1.5">
<button
onClick={onRunPolygonAnalysis}
disabled={analysisPolygonPoints.length < 3}
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
>
</button>
<button
onClick={onCancelAnalysis}
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
>
</button>
</div>
</div>
)}
{analysisResult && !drawAnalysisMode && (
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
)}
</div>
)}
{/* 원 분석 패널 */}
{analysisTab === 'circle' && (
<div>
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
(NM) .
</p>
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5"> (NM)</div>
<div className="flex flex-wrap gap-1 mb-2">
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
<button
key={nm}
onClick={() => onCircleRadiusChange?.(nm)}
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
circleRadiusNm === nm
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
}`}
>
{nm}
</button>
))}
</div>
<div className="flex items-center gap-1.5 mb-2.5">
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap"> </span>
<input
type="number"
min="0.1"
max="100"
step="0.1"
value={circleRadiusNm}
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
style={{ colorScheme: 'dark' }}
/>
<span className="text-[9px] text-text-3 font-korean">NM</span>
<button
onClick={onRunCircleAnalysis}
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
>
</button>
</div>
{analysisResult && (
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
)}
</div>
)}
</Section>
{/* 오염 종합 상황 */}
@ -220,18 +380,33 @@ function Section({
)
}
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
function ControlledCheckbox({
checked,
onChange,
children,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
children: string;
disabled?: boolean;
}) {
return (
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
<label
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
}`}
>
<input
type="checkbox"
defaultChecked={checked}
className="w-[13px] h-[13px]"
className="accent-[var(--cyan)]"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="w-[13px] h-[13px] accent-[var(--cyan)]"
/>
{children}
</label>
)
);
}
function StatBox({
@ -425,3 +600,78 @@ function InsuranceCard({
</div>
)
}
function PollResult({
result,
summary,
onClear,
onRerun,
radiusNm,
}: {
result: AnalysisResult
summary?: SimulationSummary | null
onClear?: () => void
onRerun?: () => void
radiusNm?: number
}) {
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
return (
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
{radiusNm && (
<div className="flex justify-between items-center mb-2">
<span className="text-[10px] font-semibold text-text-1 font-korean"> </span>
<span className="text-[9px] font-semibold text-primary-cyan font-mono"> {radiusNm} NM</span>
</div>
)}
<div className="grid grid-cols-3 gap-1 mb-2">
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5">(km²)</div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5"></div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5">(km²)</div>
</div>
</div>
<div className="space-y-1 text-[9px] font-korean">
{summary && (
<div className="flex justify-between">
<span className="text-text-3"></span>
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
</div>
)}
{summary && (
<div className="flex justify-between">
<span className="text-text-3"></span>
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
</div>
)}
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}</span>
</div>
</div>
<div className="flex gap-1.5 mt-2">
<button
onClick={onClear}
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
>
</button>
{onRerun && (
<button
onClick={onRerun}
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
>
</button>
)}
</div>
</div>
)
}

파일 보기

@ -0,0 +1,110 @@
interface SimulationErrorModalProps {
message: string;
onClose: () => void;
}
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(10, 14, 26, 0.75)',
backdropFilter: 'blur(4px)',
}}
>
<div
style={{
width: 360,
background: 'var(--bg1)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rM)',
padding: '28px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
fill="rgb(239, 68, 68)"
opacity="0.9"
/>
</svg>
</div>
<div>
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</div>
{/* 에러 메시지 */}
<div
style={{
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 'var(--rS)',
padding: '10px 14px',
color: 'rgb(252, 165, 165)',
fontSize: 13,
lineHeight: 1.6,
wordBreak: 'break-word',
}}
>
{message}
</div>
{/* 확인 버튼 */}
<button
onClick={onClose}
style={{
marginTop: 4,
padding: '8px 0',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rS)',
color: 'rgb(252, 165, 165)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
}}
>
</button>
</div>
</div>
);
};
export default SimulationErrorModal;

파일 보기

@ -18,6 +18,7 @@ export interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
export interface PredictionDetail {

파일 보기

@ -343,13 +343,26 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
</>
)}
{sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}</span>
</p>
)}
{sec.id === 'oil-coastal' && (() => {
const coastLength = oilPayload?.pollution.coastLength;
const hasNoCoastal = oilPayload && (
!coastLength || coastLength === '—' || coastLength.startsWith('0.00')
);
if (hasNoCoastal) {
return (
<p className="text-[12px] text-text-2 font-korean">
<span className="font-semibold text-text-1"> </span>.
</p>
);
}
return (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{coastLength || sampleOilData.coastal.coastLength}</span>
</p>
);
})()}
{sec.id === 'oil-defense' && (
<div className="text-[12px] text-text-3 font-korean">
<p className="mb-2"> .</p>

파일 보기

@ -66,7 +66,7 @@ function ScatLeftPanel({
<select
value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full"
className="wing-select w-full"
>
<option> ()</option>
<option></option>
@ -81,7 +81,7 @@ function ScatLeftPanel({
<select
value={areaFilter}
onChange={(e) => onAreaChange(e.target.value)}
className="prd-i w-full"
className="wing-select w-full"
>
<option></option>
{zones.map((z) => (
@ -99,7 +99,7 @@ function ScatLeftPanel({
<select
value={phaseFilter}
onChange={(e) => onPhaseChange(e.target.value)}
className="prd-i w-full"
className="wing-select w-full"
>
<option>Pre-SCAT ()</option>
<option>SCAT ( )</option>
@ -113,12 +113,12 @@ function ScatLeftPanel({
placeholder="🔍 구간 검색..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="prd-i flex-1"
className="wing-input flex-1"
/>
<select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
className="prd-i w-[70px]"
className="wing-select w-[70px]"
>
<option></option>
<option></option>

파일 보기

@ -0,0 +1,207 @@
import { useState } from 'react';
import BrandLogo from './sections/BrandLogo';
import BrandColor from './sections/BrandColor';
import Typography from './sections/Typography';
import Icons from './sections/Icons';
import Spacing from './sections/Spacing';
import Border from './sections/Border';
import BadgeSection from './sections/BadgeSection';
import ButtonSection from './sections/ButtonSection';
import InputSection from './sections/InputSection';
import CardSection from './sections/CardSection';
import DataTableSection from './sections/DataTableSection';
import PaginationSection from './sections/PaginationSection';
import ModalSection from './sections/ModalSection';
import SidePanelSection from './sections/SidePanelSection';
import ComboBoxSection from './sections/ComboBoxSection';
/* ── 라이트 테마 CSS 변수 오버라이드 ── */
const LIGHT_THEME_VARS: React.CSSProperties = {
'--bg0': '#ffffff',
'--bg1': '#f8f9fb',
'--bg2': '#f0f2f5',
'--bg3': '#e8ebf0',
'--bgH': '#dde1e8',
'--bd': '#d0d5dd',
'--bdL': '#e0e4ea',
'--t1': '#1a1d26',
'--t2': '#4a5568',
'--t3': '#8690a6',
'--cyan': '#0891b2',
'--blue': '#2563eb',
'--red': '#dc2626',
'--orange': '#ea580c',
'--yellow': '#ca8a04',
'--green': '#16a34a',
'--purple': '#9333ea',
} as React.CSSProperties;
/* ── 사이드바 메뉴 구조 ── */
type SectionId =
| 'logo'
| 'brand-color' | 'typography' | 'icons' | 'spacing' | 'border'
| 'badge' | 'button' | 'input' | 'card' | 'data-table' | 'pagination' | 'modal' | 'side-panel' | 'combo-box';
interface MenuItem {
id: SectionId;
label: string;
}
interface MenuGroup {
title: string;
items: MenuItem[];
}
const MENU: MenuGroup[] = [
{
title: 'Brand',
items: [
{ id: 'logo', label: '로고 (Logo)' },
],
},
{
title: 'Foundations',
items: [
{ id: 'brand-color', label: '브랜드 컬러' },
{ id: 'typography', label: '타이포그래피' },
{ id: 'icons', label: '아이콘' },
{ id: 'spacing', label: '간격' },
{ id: 'border', label: '테두리' },
],
},
{
title: 'Components',
items: [
{ id: 'badge', label: 'Badge' },
{ id: 'button', label: 'Button' },
{ id: 'input', label: 'Input' },
{ id: 'card', label: 'Card' },
{ id: 'data-table', label: 'DataTable' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'modal', label: 'Modal' },
{ id: 'side-panel', label: 'SidePanel' },
{ id: 'combo-box', label: 'ComboBox' },
],
},
];
const SECTION_MAP: Record<SectionId, () => React.ReactNode> = {
'logo': () => <BrandLogo />,
'brand-color': () => <BrandColor />,
'typography': () => <Typography />,
'icons': () => <Icons />,
'spacing': () => <Spacing />,
'border': () => <Border />,
'badge': () => <BadgeSection />,
'button': () => <ButtonSection />,
'input': () => <InputSection />,
'card': () => <CardSection />,
'data-table': () => <DataTableSection />,
'pagination': () => <PaginationSection />,
'modal': () => <ModalSection />,
'side-panel': () => <SidePanelSection />,
'combo-box': () => <ComboBoxSection />,
};
/* ── 메인 쇼케이스 ── */
const ShowcaseView = () => {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeSection, setActiveSection] = useState<SectionId>('logo');
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Brand: true,
Foundations: true,
Components: true,
});
const isLight = theme === 'light';
const toggleGroup = (title: string) => {
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
};
const renderSection = SECTION_MAP[activeSection];
return (
<div
className="flex h-full overflow-hidden w-full"
style={isLight ? LIGHT_THEME_VARS : undefined}
>
{/* 사이드바 */}
<div
className="w-[240px] shrink-0 h-full overflow-y-auto border-r flex flex-col"
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}
>
{/* 사이드바 헤더 + 테마 토글 */}
<div className="p-4 border-b" style={{ borderColor: 'var(--bd)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-wing-heading font-bold" style={{ color: 'var(--t1)' }}>
Design System
</span>
<button
onClick={() => setTheme(isLight ? 'dark' : 'light')}
className="flex items-center gap-1.5 px-2 py-1 rounded-sm border transition-all text-[10px] font-semibold"
style={{
background: 'var(--bg3)',
borderColor: 'var(--bd)',
color: 'var(--t1)',
}}
>
<span>{isLight ? '☀️' : '🌙'}</span>
{isLight ? 'Light' : 'Dark'}
</button>
</div>
<p className="wing-meta" style={{ color: 'var(--t3)' }}>WING UI </p>
</div>
{/* 메뉴 그룹 */}
<nav className="flex-1 py-2">
{MENU.map((group) => (
<div key={group.title} className="mb-1">
{/* 그룹 헤더 */}
<button
onClick={() => toggleGroup(group.title)}
className="w-full flex items-center justify-between px-4 py-2 text-[11px] font-bold tracking-wide transition-colors hover:opacity-80"
style={{ color: 'var(--t3)' }}
>
<span>{group.title}</span>
<span className="text-[10px]">{openGroups[group.title] ? '▾' : '▸'}</span>
</button>
{/* 하위 메뉴 */}
{openGroups[group.title] && (
<div>
{group.items.map((item) => {
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className="w-full text-left px-4 py-1.5 pl-7 text-[11px] transition-all"
style={{
color: isActive ? 'var(--cyan)' : 'var(--t2)',
background: isActive ? 'rgba(6,182,212,0.08)' : 'transparent',
fontWeight: isActive ? 600 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
))}
</nav>
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-y-auto p-6" style={{ background: 'var(--bg0)', color: 'var(--t1)' }}>
<div className="max-w-[960px] mx-auto">
{renderSection()}
</div>
</div>
</div>
);
};
export default ShowcaseView;

파일 보기

@ -0,0 +1 @@
export { default as ShowcaseView } from './ShowcaseView';

파일 보기

@ -0,0 +1,23 @@
import Badge from '@common/components/ui/Badge';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BADGE_COLORS = ['neutral', 'red', 'blue', 'green', 'yellow', 'purple', 'cyan'] as const;
const BadgeSection = () => (
<Section title="Badge" desc="wing-badge 컬러 7종">
<div className="flex gap-2 flex-wrap">
{BADGE_COLORS.map((color) => (
<Badge key={color} color={color}>{color}</Badge>
))}
</div>
</Section>
);
export default BadgeSection;

파일 보기

@ -0,0 +1,48 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Border = () => (
<Section title="테두리 (Border)" desc="테두리 색상 + Border Radius 4단계">
<div className="space-y-4">
<div>
<p className="wing-label mb-2"> </p>
<div className="flex gap-2">
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bd)', background: 'var(--bg1)' }} />
<span className="wing-meta">bd</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bdL)', background: 'var(--bg1)' }} />
<span className="wing-meta">bdL</span>
</div>
</div>
</div>
<div>
<p className="wing-label mb-2">Border Radius</p>
<div className="flex gap-2">
{[
{ r: 4, label: 'xs (4px)' },
{ r: 6, label: 'sm (6px)' },
{ r: 10, label: 'md (10px)' },
{ r: 12, label: 'lg (12px)' },
].map((item) => (
<div key={item.r} className="flex flex-col items-center gap-1">
<div
className="w-12 h-12 border-2 border-primary-cyan bg-bg-2"
style={{ borderRadius: item.r }}
/>
<span className="wing-meta text-text-3">{item.label}</span>
</div>
))}
</div>
</div>
</div>
</Section>
);
export default Border;

파일 보기

@ -0,0 +1,47 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BrandColor = () => (
<Section title="브랜드 컬러 (Brand Color)" desc="배경, 텍스트, Accent Color">
{/* 배경 */}
<p className="wing-label mb-2"> (Background)</p>
<div className="flex gap-2 mb-4">
{['--bg0', '--bg1', '--bg2', '--bg3', '--bgH'].map((v) => (
<div key={v} className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: `var(${v})`, border: '1px solid var(--bd)' }} />
<span className="wing-meta">{v.replace('--', '')}</span>
</div>
))}
</div>
{/* 텍스트 */}
<p className="wing-label mb-2"> (Text)</p>
<div className="flex gap-4 mb-4 p-3 rounded-sm" style={{ background: 'var(--bg1)' }}>
<span style={{ color: 'var(--t1)' }} className="text-wing-body">Text-1 </span>
<span style={{ color: 'var(--t2)' }} className="text-wing-body">Text-2 </span>
<span style={{ color: 'var(--t3)' }} className="text-wing-body">Text-3 </span>
</div>
{/* Accent */}
<p className="wing-label mb-2">Accent Color</p>
<div className="flex gap-2 mb-2">
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--cyan)' }} />
<span className="wing-meta">Cyan</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--blue)' }} />
<span className="wing-meta">Blue</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }} />
<span className="wing-meta">Gradient</span>
</div>
</div>
</Section>
);
export default BrandColor;

파일 보기

@ -0,0 +1,31 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BrandLogo = () => (
<Section title="로고 (Logo)" desc="WING 로고 규격 및 여백">
<div className="p-6 rounded-sm flex flex-col gap-6" style={{ background: 'var(--bg1)' }}>
<div>
<p className="wing-label mb-3"> </p>
<div className="flex gap-8 items-center">
<div className="p-4 rounded-sm" style={{ background: 'var(--bg0)' }}>
<img src="/wing_logo_white.svg" alt="WING Logo" className="h-5" />
</div>
<div className="p-4 rounded-sm" style={{ background: '#ffffff' }}>
<img src="/wing_logo_text_white.svg" alt="WING Logo Text" className="h-5" style={{ filter: 'invert(1)' }} />
</div>
</div>
</div>
<div>
<p className="wing-label mb-2"> </p>
<p className="wing-meta" style={{ color: 'var(--t3)' }}> 여백: 로고 50% </p>
</div>
</div>
</Section>
);
export default BrandLogo;

파일 보기

@ -0,0 +1,21 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const ButtonSection = () => (
<Section title="Button" desc="wing-btn 변형">
<div className="flex gap-2 flex-wrap items-center">
<button className="wing-btn wing-btn-primary">Primary</button>
<button className="wing-btn wing-btn-secondary">Secondary</button>
<button className="wing-btn wing-btn-outline">Outline</button>
<button className="wing-btn wing-btn-danger">Danger</button>
<button className="wing-btn wing-btn-primary" disabled>Disabled</button>
</div>
</Section>
);
export default ButtonSection;

파일 보기

@ -0,0 +1,30 @@
import Card from '@common/components/ui/Card';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const CardSection = () => (
<Section title="Card" desc="wing-card 기본 + hover">
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
<Card>
<p className="wing-label mb-1"> </p>
<p className="wing-meta text-text-3">hover: true ()</p>
</Card>
<Card hover={false}>
<p className="wing-label mb-1">Hover </p>
<p className="wing-meta text-text-3">hover: false</p>
</Card>
<Card onClick={() => alert('클릭!')}>
<p className="wing-label mb-1"> </p>
<p className="wing-meta text-text-3">onClick </p>
</Card>
</div>
</Section>
);
export default CardSection;

파일 보기

@ -0,0 +1,32 @@
import { useState } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const OPTIONS = [
{ value: 'prediction', label: '확산 예측' },
{ value: 'hns', label: 'HNS 분석' },
{ value: 'aerial', label: '항공 방제' },
{ value: 'weather', label: '해양 기상' },
{ value: 'scat', label: 'SCAT 조사' },
];
const ComboBoxSection = () => {
const [value, setValue] = useState('prediction');
return (
<Section title="ComboBox" desc="검색 가능한 드롭다운 선택">
<div className="max-w-[240px]">
<ComboBox value={value} onChange={setValue} options={OPTIONS} placeholder="기능 선택..." />
</div>
<p className="wing-meta text-text-3 mt-2">: {value}</p>
</Section>
);
};
export default ComboBoxSection;

파일 보기

@ -0,0 +1,39 @@
import Badge from '@common/components/ui/Badge';
import DataTable from '@common/components/ui/DataTable';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const SAMPLE_DATA = [
{ id: 1, title: '유류 확산 예측 보고서', author: '이동녘', category: '공지사항', date: '2026-03-10', views: 142 },
{ id: 2, title: 'HNS 물질 분석 자료', author: '김해양', category: '자료실', date: '2026-03-09', views: 87 },
{ id: 3, title: '항공 방제 드론 운영 매뉴얼', author: '박방제', category: '매뉴얼', date: '2026-03-08', views: 256 },
{ id: 4, title: 'SCAT 해안조사 결과', author: '최조사', category: '보고서', date: '2026-03-07', views: 64 },
{ id: 5, title: '기상 데이터 연동 가이드', author: '정기상', category: 'Q&A', date: '2026-03-06', views: 33 },
];
const TABLE_COLUMNS = [
{ key: 'id' as const, label: '번호', width: '60px', align: 'center' as const },
{ key: 'title' as const, label: '제목' },
{ key: 'author' as const, label: '작성자', width: '100px' },
{ key: 'category' as const, label: '분류', width: '100px', render: (val: unknown) => <Badge>{val as string}</Badge> },
{ key: 'date' as const, label: '작성일', width: '120px' },
{ key: 'views' as const, label: '조회', width: '60px', align: 'center' as const },
];
const DataTableSection = () => (
<Section title="DataTable" desc="wing-table 기반 제네릭 테이블">
<DataTable
columns={TABLE_COLUMNS}
data={SAMPLE_DATA}
onRowClick={(row) => alert(`선택: ${row.title}`)}
/>
</Section>
);
export default DataTableSection;

파일 보기

@ -0,0 +1,31 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Icons = () => (
<Section title="아이콘 (Icons)" desc="lucide-react 기반, 16/18/20px 3단계">
<div className="p-4 rounded-sm space-y-4" style={{ background: 'var(--bg1)' }}>
<p className="wing-meta" style={{ color: 'var(--t3)' }}>
lucide-react .
</p>
<div className="flex gap-6 items-end">
{[16, 18, 20].map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded-sm flex items-center justify-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
<span className="wing-meta" style={{ color: 'var(--t3)' }}>{size}px</span>
</div>
))}
</div>
</div>
</Section>
);
export default Icons;

파일 보기

@ -0,0 +1,53 @@
import { useState } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const COMBO_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'notice', label: '공지사항' },
{ value: 'data', label: '자료실' },
{ value: 'manual', label: '매뉴얼' },
];
const InputSection = () => {
const [comboValue, setComboValue] = useState('all');
return (
<Section title="Input / Select / ComboBox" desc="wing-input, wing-select, ComboBox">
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
<div>
<label className="wing-label mb-1 block">Input</label>
<input className="wing-input" placeholder="텍스트 입력..." />
</div>
<div>
<label className="wing-label mb-1 block">Search</label>
<input className="wing-input-search" placeholder="검색..." />
</div>
<div>
<label className="wing-label mb-1 block">Select</label>
<select className="wing-select">
<option> 1</option>
<option> 2</option>
<option> 3</option>
</select>
</div>
<div>
<label className="wing-label mb-1 block">Textarea</label>
<textarea className="wing-textarea" rows={2} placeholder="여러 줄 입력..." />
</div>
<div>
<label className="wing-label mb-1 block">ComboBox</label>
<ComboBox value={comboValue} onChange={setComboValue} options={COMBO_OPTIONS} />
</div>
</div>
</Section>
);
};
export default InputSection;

파일 보기

@ -0,0 +1,46 @@
import { useState } from 'react';
import Modal from '@common/components/ui/Modal';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const ModalSection = () => {
const [modalOpen, setModalOpen] = useState(false);
const [modalSize, setModalSize] = useState<'sm' | 'md' | 'lg'>('md');
return (
<Section title="Modal" desc="wing-modal 3가지 사이즈">
<div className="flex gap-2">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
className="wing-btn wing-btn-secondary"
onClick={() => { setModalSize(size); setModalOpen(true); }}
>
{size.toUpperCase()}
</button>
))}
</div>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={`모달 (${modalSize})`}
size={modalSize}
footer={
<div className="flex gap-2 justify-end">
<button className="wing-btn wing-btn-secondary" onClick={() => setModalOpen(false)}></button>
<button className="wing-btn wing-btn-primary" onClick={() => setModalOpen(false)}></button>
</div>
}
>
<p className="text-text-2 text-wing-body"> . : {modalSize}</p>
</Modal>
</Section>
);
};
export default ModalSection;

파일 보기

@ -0,0 +1,30 @@
import { useState } from 'react';
import Pagination from '@common/components/ui/Pagination';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const PaginationSection = () => {
const [page, setPage] = useState(1);
return (
<Section title="Pagination" desc="wing-btn 기반 페이지네이션">
<div className="flex gap-6 items-center">
<div>
<p className="wing-meta text-text-3 mb-2">7 {page}</p>
<Pagination currentPage={page} totalPages={7} onPageChange={setPage} />
</div>
<div>
<p className="wing-meta text-text-3 mb-2">3 (showFirstLast: false)</p>
<Pagination currentPage={1} totalPages={3} onPageChange={() => {}} showFirstLast={false} />
</div>
</div>
</Section>
);
};
export default PaginationSection;

파일 보기

@ -0,0 +1,67 @@
import { useState } from 'react';
import Badge from '@common/components/ui/Badge';
import SidePanel from '@common/components/ui/SidePanel';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const SidePanelSection = () => {
const [visible, setVisible] = useState(false);
return (
<>
<Section title="SidePanel" desc="wing-panel-right/left 사이드패널">
<button
className="wing-btn wing-btn-secondary"
onClick={() => setVisible(!visible)}
>
{visible ? '사이드패널 닫기' : '사이드패널 열기'}
</button>
</Section>
{visible && (
<SidePanel
position="right"
width="default"
header={
<div className="flex items-center justify-between w-full">
<span className="wing-title"> </span>
<button className="wing-btn wing-btn-secondary" onClick={() => setVisible(false)}></button>
</div>
}
footer={
<button className="wing-btn wing-btn-primary w-full"></button>
}
>
<div className="p-3 space-y-3">
<div className="wing-info-row">
<span className="wing-info-label"></span>
<Badge color="green"></Badge>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<Badge></Badge>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value"></span>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value">2026-03-10</span>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value">142</span>
</div>
</div>
</SidePanel>
)}
</>
);
};
export default SidePanelSection;

파일 보기

@ -0,0 +1,25 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Spacing = () => (
<Section title="간격 (Spacing)" desc="4px 그리드 기반 6단계">
<div>
<p className="wing-label mb-2">Spacing (4px grid)</p>
<div className="flex items-end gap-2">
{[4, 6, 8, 12, 16, 20].map((s) => (
<div key={s} className="flex flex-col items-center gap-1">
<div className="bg-primary-cyan/30 border border-primary-cyan/50" style={{ width: s, height: 40 }} />
<span className="wing-meta text-text-3">{s}px</span>
</div>
))}
</div>
</div>
</Section>
);
export default Spacing;

파일 보기

@ -0,0 +1,43 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Typography = () => (
<Section title="타이포그래피 (Typography)" desc="폰트 패밀리 + 5단계 스케일">
<div className="p-4 bg-bg-1 rounded-sm space-y-3">
<div className="flex items-baseline gap-4">
<span className="text-wing-title font-bold text-text-1">Title 15px</span>
<span className="wing-meta text-text-3">font-bold, line-height 1.3</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-heading font-bold text-text-1">Heading 13px</span>
<span className="wing-meta text-text-3">font-bold, line-height 1.4</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-body text-text-1">Body 11px </span>
<span className="wing-meta text-text-3">line-height 1.5</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-caption text-text-2">Caption 10px , </span>
<span className="wing-meta text-text-3">line-height 1.4</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-meta text-text-3">Meta 9px , </span>
<span className="wing-meta text-text-3">line-height 1.4</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<p className="wing-meta text-text-3 mb-2"> </p>
<div className="flex gap-6">
<span className="text-wing-body font-sans text-text-2">Sans: Outfit + Noto Sans KR</span>
<span className="text-wing-body font-mono text-text-2">Mono: JetBrains Mono</span>
</div>
</div>
</div>
</Section>
);
export default Typography;

파일 보기

@ -44,6 +44,13 @@ export default {
mono: ['JetBrains Mono', 'monospace'],
korean: ['Noto Sans KR', 'sans-serif'],
},
fontSize: {
'wing-meta': ['9px', { lineHeight: '1.4' }],
'wing-caption': ['10px', { lineHeight: '1.4' }],
'wing-body': ['11px', { lineHeight: '1.5' }],
'wing-heading': ['13px', { lineHeight: '1.4' }],
'wing-title': ['15px', { lineHeight: '1.3' }],
},
borderRadius: {
sm: '6px',
md: '10px',