Compare commits
176 커밋
feature/20
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b669b25f6e | |||
| 3b24e68547 | |||
| 04a205c9ec | |||
| c7c7bcdc45 | |||
| 846c63eae9 | |||
| 7de0b008c4 | |||
| d07bd3e0f1 | |||
| 9a85cb545c | |||
| 4a730d1582 | |||
| 1980463904 | |||
| 26b86a5a4b | |||
| 2ee4df5afb | |||
| 784b36e69b | |||
| c5dc5c60c5 | |||
| 9f4a578af3 | |||
| 1a31795970 | |||
| e31cb9b764 | |||
| c46bf50348 | |||
| 13bda6d15b | |||
| 650bb2b035 | |||
| 749453b9d1 | |||
| 0a9a5f433e | |||
| 3525e22590 | |||
| ee90b2efdb | |||
| bdb9f95b96 | |||
| 5987dcb991 | |||
| 798002580f | |||
| 0c61041974 | |||
| ea208cbf52 | |||
| c219adf2d9 | |||
| 2f5d2fdb1b | |||
| cd4717f303 | |||
| 7a5028226b | |||
| a6b0e92a8e | |||
| 765d3fb9d2 | |||
| 38d931db65 | |||
| 28544d5c8f | |||
| 10510a0410 | |||
| 1da2553694 | |||
| 7fa3fa6a2e | |||
| 1f66723060 | |||
| 2d6827c0a9 | |||
| 2082e9a79b | |||
| 988cc47e9f | |||
| 0daae3c807 | |||
| fa5c7f518f | |||
| 72ead1140f | |||
| 938665e323 | |||
| 29c5293ce7 | |||
| ae0a17990b | |||
| 6b19d34e5b | |||
| 679649ab8c | |||
| 279dcbc0e1 | |||
| 2fe9deeabe | |||
| 388116aa88 | |||
| 3eb66e2e54 | |||
| 15ca946a00 | |||
| 20d5c08bc7 | |||
| 8a0e5daf60 | |||
| c12e747b14 | |||
| 69b01fca9e | |||
| fef7583eb5 | |||
| f47aeef3ce | |||
| 8093727efe | |||
| 547e83e617 | |||
| af4ab9dd80 | |||
| 28931d9a5e | |||
| ad24445101 | |||
| 4f5260ae12 | |||
| 9630b1daac | |||
| bf0de764c6 | |||
| 965b238b08 | |||
| 2640d882da | |||
|
|
387e2a2e40 | ||
|
|
1142e0cc46 | ||
| 5de10662a7 | |||
| 972e6319cc | |||
| 0d53f850b2 | |||
| 7e0da5ea76 | |||
| cc3e0c5596 | |||
| 1ef0f5bce9 | |||
| d8d236c624 | |||
| a33dd09485 | |||
| f375ecc3ab | |||
| 9e51651fc7 | |||
| 4065ec76ef | |||
| 109c0d2480 | |||
| 4d71ca3a01 | |||
| 04f89ad24c | |||
| fbdf0e9122 | |||
| 646fa38f39 | |||
| 6620f00ee1 | |||
| e4b9c3e5dd | |||
| 7921bfef96 | |||
| 77d36ec8d0 | |||
| c4b9b85b24 | |||
| 5ea904fc3a | |||
| 7cdbc8664f | |||
| 5b36ea3991 | |||
| afa1d16b6b | |||
| f4630429f7 | |||
| 1bb0154153 | |||
| 76ab75f561 | |||
| f93aceeef0 | |||
| c7b0b7a3c2 | |||
| 5489bb0db5 | |||
| a511c0280e | |||
| 6803fc156c | |||
| 08bcfbf24d | |||
| a0be19d060 | |||
| 42d749426e | |||
| c77ac4e7a8 | |||
| 38d11df363 | |||
| dafd6cc1ac | |||
| 7a8e2ddea1 | |||
| a50b149dda | |||
| 5ae838c3a9 | |||
| a474cf6d1d | |||
| 625b15e395 | |||
| 440e6fd9fd | |||
| a719130f20 | |||
| f960660f3b | |||
| 7d2a889e11 | |||
| 0da3adb793 | |||
| c40711cae1 | |||
| 7d3b5ed419 | |||
| 0bf7587a1b | |||
| d931219169 | |||
| 2a99ffbbe1 | |||
| a86188f473 | |||
| 5a792bb53c | |||
| 2c0f43962b | |||
| 4361fdbf2d | |||
| 8eda3f27a0 | |||
| 1458f4b1de | |||
| 0e6d63f1f0 | |||
| 7890651e90 | |||
| d8a5acc1e6 | |||
| 5e2076647c | |||
| 71cdc634c6 | |||
| 6d5fb70020 | |||
| d10b27db87 | |||
| 837832b000 | |||
| f3cfc86921 | |||
| e2254cc960 | |||
| d71c43ae5a | |||
| 4869bbbed4 | |||
| c92014f3a3 | |||
| 9bae76f1d4 | |||
| 2cdd9cf52b | |||
| 3a224ea649 | |||
| 29477e4e2a | |||
| 8fb3c67307 | |||
| af1a7f80b9 | |||
| 944995bc50 | |||
| 6c9b212632 | |||
| 9d630a9466 | |||
| 94e0837072 | |||
| 2472be673b | |||
| 960d035700 | |||
| ff202c6e05 | |||
| ebe76176e3 | |||
| bc7e966cb1 | |||
| 6c68d04fc3 | |||
| a55d3c18c2 | |||
| 84fa49189c | |||
| 5f622c7520 | |||
| 7cef385c3a | |||
| 36829b9ff4 | |||
| 16db2e1925 | |||
| 6b9ed4e06e | |||
| 99c2e8d6ae | |||
| 3ad24a6e1a | |||
| 714bac9f24 | |||
| ecca827098 | |||
| dc4be29cfc |
@ -5,6 +5,7 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
@ -84,5 +85,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"allow": []
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-26",
|
||||
"applied_date": "2026-04-17",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
# commit-msg hook
|
||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||
#==============================================================================
|
||||
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||
|
||||
$raw = [Console]::In.ReadToEnd()
|
||||
|
||||
if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 }
|
||||
$toolName = $Matches[1]
|
||||
|
||||
if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 }
|
||||
|
||||
if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 }
|
||||
$sessionId = $Matches[1]
|
||||
|
||||
$hooksDir = '.github\java-upgrade\hooks'
|
||||
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null }
|
||||
|
||||
$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n"
|
||||
[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false))
|
||||
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_NAME="${INPUT#*\"tool_name\":\"}"
|
||||
TOOL_NAME="${TOOL_NAME%%\"*}"
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
run_in_terminal|appmod-*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
case "$INPUT" in
|
||||
*'"session_id":"'*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
SESSION_ID="${INPUT#*\"session_id\":\"}"
|
||||
SESSION_ID="${SESSION_ID%%\"*}"
|
||||
[ -z "$SESSION_ID" ] && exit 0
|
||||
|
||||
HOOKS_DIR=".github/java-upgrade/hooks"
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
|
||||
LINE=$(printf '%s' "$INPUT" | tr -d '\r\n')
|
||||
printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -79,6 +79,9 @@ prediction/image/**/*.pth
|
||||
frontend/public/hns-manual/pages/
|
||||
frontend/public/hns-manual/images/
|
||||
|
||||
# HNS import pipeline outputs (local, 1회성 생성물)
|
||||
backend/scripts/hns-import/out/
|
||||
|
||||
# Claude Code (team workflow tracked, override global gitignore)
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
@ -99,3 +102,10 @@ frontend/public/hns-manual/images/
|
||||
# Lock files (keep for reproducible builds)
|
||||
!frontend/package-lock.json
|
||||
!backend/package-lock.json
|
||||
|
||||
|
||||
# mcp
|
||||
.mcp.json
|
||||
|
||||
# python
|
||||
.venv
|
||||
@ -54,7 +54,7 @@ wing/
|
||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||
│ └── components/ 탭 단위 패키지 (@components/ alias)
|
||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
@ -96,7 +96,7 @@ wing/
|
||||
### Path Alias
|
||||
|
||||
- `@common/*` -> `src/common/*` (공통 모듈)
|
||||
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
|
||||
- `@components/*` -> `src/components/*` (탭 패키지)
|
||||
|
||||
## 팀 컨벤션
|
||||
|
||||
@ -107,6 +107,8 @@ wing/
|
||||
- `naming.md` -- 네이밍 규칙
|
||||
- `testing.md` -- 테스트 규칙
|
||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
||||
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
||||
|
||||
## 개발 문서 (docs/)
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
|
||||
Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*`
|
||||
|
||||
```
|
||||
wing/
|
||||
@ -95,7 +95,7 @@ wing/
|
||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
|
||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
|
||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
@ -34,6 +35,37 @@
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.89.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
|
||||
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@ -1689,6 +1721,20 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@ -2618,6 +2664,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
140
backend/scripts/hns-import/README.md
Normal file
140
backend/scripts/hns-import/README.md
Normal file
@ -0,0 +1,140 @@
|
||||
# HNS 물질 Import 파이프라인
|
||||
|
||||
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
|
||||
|
||||
## 파이프라인 구조
|
||||
|
||||
```
|
||||
[Excel xlsm] [PDF 물질정보집 (193종)]
|
||||
├─ (1) extract-excel.py (2b) extract-pdf.py
|
||||
│ → out/base.json → out/pdf-data.json
|
||||
└─ (2a) extract-images.py ──────────────────────────┐
|
||||
→ out/images/*.png │
|
||||
↓ │
|
||||
(3) ocr-images.ts │
|
||||
→ out/ocr.json │
|
||||
↓ ↓
|
||||
(4) merge-data.ts ←──────────────┘
|
||||
→ frontend/src/data/hnsSubstanceData.json
|
||||
↓
|
||||
(5) tsx src/db/seedHns.ts
|
||||
→ HNS_SUBSTANCE 테이블
|
||||
```
|
||||
|
||||
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
|
||||
|
||||
## 전제 조건
|
||||
|
||||
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
|
||||
- Node.js 20
|
||||
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
|
||||
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### 1) Excel 메타 시트 파싱
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/hns-import/extract-excel.py
|
||||
```
|
||||
|
||||
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
|
||||
- 출력: `scripts/hns-import/out/base.json`
|
||||
|
||||
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
|
||||
|
||||
```bash
|
||||
python scripts/hns-import/extract-images.py
|
||||
```
|
||||
|
||||
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
|
||||
|
||||
### 2b) PDF 물질정보집 파싱 ★ 권장
|
||||
|
||||
```bash
|
||||
python scripts/hns-import/extract-pdf.py
|
||||
```
|
||||
|
||||
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
|
||||
- 출력: `scripts/hns-import/out/pdf-data.json`
|
||||
|
||||
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
cd backend
|
||||
npx tsx scripts/hns-import/ocr-images.ts
|
||||
```
|
||||
|
||||
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
|
||||
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
|
||||
|
||||
### 4) 최종 JSON 병합
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx scripts/hns-import/merge-data.ts
|
||||
```
|
||||
|
||||
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
|
||||
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
|
||||
|
||||
### 5) DB 재시드
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/db/seedHns.ts
|
||||
```
|
||||
|
||||
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
|
||||
|
||||
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/hns-import/extract-pdf.py && \
|
||||
npx tsx scripts/hns-import/merge-data.ts && \
|
||||
npx tsx src/db/seedHns.ts
|
||||
```
|
||||
|
||||
## 현재 데이터 현황 (2024-04 기준)
|
||||
|
||||
| 항목 | 이전 (OCR) | 현재 (PDF) |
|
||||
|------|-----------|-----------|
|
||||
| 총 물질 수 | 514종 | 514종 |
|
||||
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
|
||||
| NFPA 코드 있음 | ~150종 | 201종 |
|
||||
| CAS 번호 있음 | ~380종 | 504종 |
|
||||
| 해양거동 있음 | ~0종 | 206종 |
|
||||
| 한국어 유사명 있음 | ~200종 | 449종 |
|
||||
|
||||
## 재실행 안내
|
||||
|
||||
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
|
||||
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
|
||||
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
|
||||
|
||||
## 알려진 이슈
|
||||
|
||||
### 1) PDF 매칭 실패 35종
|
||||
|
||||
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
|
||||
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
|
||||
|
||||
**원인:**
|
||||
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin` ↔ `디이알 383`)
|
||||
- 동일 CAS 충돌 (예: `컨덴세이트`와 `나프타`가 같은 CAS)
|
||||
- 표기 차이 (예: `아이소파-G` ↔ `아이소파 G`)
|
||||
|
||||
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
|
||||
|
||||
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
|
||||
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
|
||||
|
||||
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
|
||||
|
||||
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.
|
||||
236
backend/scripts/hns-import/extract-excel.py
Normal file
236
backend/scripts/hns-import/extract-excel.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Excel 메타 시트 → base.json 변환.
|
||||
|
||||
처리 시트:
|
||||
- 화물적부도 화물코드: 1,345개 기본 레코드
|
||||
- 동의어: 215개 한/영 유사명
|
||||
- IBC CODE: IMO IBC 분류
|
||||
|
||||
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts)에 맞춘 JSON 배열.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
|
||||
def norm_key(s: str | None) -> str:
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'\s+', '', str(s)).strip().lower()
|
||||
|
||||
|
||||
def split_synonyms(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ''
|
||||
# 원본은 "·" 또는 "/" 구분, 개행 포함
|
||||
parts = re.split(r'[·/\n]+', str(raw))
|
||||
cleaned = [p.strip() for p in parts if p and p.strip()]
|
||||
return ' / '.join(cleaned)
|
||||
|
||||
|
||||
def clean_text(v) -> str:
|
||||
if v is None:
|
||||
return ''
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
|
||||
|
||||
# ────────── 화물적부도 화물코드 ──────────
|
||||
ws = wb['화물적부도 화물코드']
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
|
||||
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
|
||||
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}개')
|
||||
|
||||
# ────────── 동의어 시트 ──────────
|
||||
ws_syn = wb['동의어']
|
||||
syn_rows = list(ws_syn.iter_rows(values_only=True))
|
||||
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
|
||||
syn_map: dict[str, dict] = {}
|
||||
for r in syn_rows[2:]:
|
||||
if not r or r[0] is None:
|
||||
continue
|
||||
name_kr = clean_text(r[1])
|
||||
cas = clean_text(r[3])
|
||||
if not name_kr and not cas:
|
||||
continue
|
||||
key = norm_key(name_kr) or norm_key(cas)
|
||||
syn_map[key] = {
|
||||
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
|
||||
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
|
||||
}
|
||||
print(f'[동의어] {len(syn_map)}개')
|
||||
|
||||
# ────────── IBC CODE 시트 ──────────
|
||||
ws_ibc = wb['IBC CODE']
|
||||
ibc_map: dict[str, dict] = {}
|
||||
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
|
||||
if i < 2:
|
||||
continue # header 2 rows
|
||||
if not r or not r[0]:
|
||||
continue
|
||||
name_en = clean_text(r[0])
|
||||
key = norm_key(name_en)
|
||||
if not key:
|
||||
continue
|
||||
ibc_map[key] = {
|
||||
'ibcHazard': clean_text(r[2]), # 위험성 S/P
|
||||
'ibcShipType': clean_text(r[3]), # 선박형식
|
||||
'ibcTankType': clean_text(r[4]), # 탱크형식
|
||||
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
|
||||
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
|
||||
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
|
||||
}
|
||||
print(f'[IBC CODE] {len(ibc_map)}개')
|
||||
|
||||
wb.close()
|
||||
|
||||
# ────────── 통합 레코드 생성 ──────────
|
||||
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
|
||||
groups: dict[str, list] = defaultdict(list)
|
||||
for r in cargo_rows:
|
||||
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
|
||||
# 그룹 키: CAS 우선, 없으면 국문명
|
||||
cas_s = clean_text(cas)
|
||||
group_key = cas_s if cas_s else norm_key(name_kr)
|
||||
groups[group_key].append({
|
||||
'abbreviation': clean_text(abbr),
|
||||
'nameKr': clean_text(name_kr),
|
||||
'nameEn': clean_text(name_en),
|
||||
'synonymsKr': split_synonyms(syn_kr),
|
||||
'synonymsEn': split_synonyms(syn_en),
|
||||
'usage': clean_text(usage),
|
||||
'unNumber': clean_text(un),
|
||||
'casNumber': cas_s,
|
||||
})
|
||||
|
||||
records: list[dict] = []
|
||||
next_id = 1
|
||||
for group_key, entries in groups.items():
|
||||
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
|
||||
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
|
||||
|
||||
name_kr_key = norm_key(primary['nameKr'])
|
||||
name_en_key = norm_key(primary['nameEn'])
|
||||
|
||||
# 동의어 병합
|
||||
syn_extra = syn_map.get(name_kr_key, {})
|
||||
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
|
||||
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
|
||||
|
||||
# IBC 병합 (영문명 기준)
|
||||
ibc = ibc_map.get(name_en_key, {})
|
||||
|
||||
# cargoCodes 집계
|
||||
cargo_codes = [
|
||||
{
|
||||
'code': e['abbreviation'],
|
||||
'name': e['nameEn'] or e['nameKr'],
|
||||
'company': '국제공통',
|
||||
'source': '적부도',
|
||||
}
|
||||
for e in entries
|
||||
if e['abbreviation']
|
||||
]
|
||||
|
||||
record = {
|
||||
'id': next_id,
|
||||
'abbreviation': primary['abbreviation'],
|
||||
'nameKr': primary['nameKr'],
|
||||
'nameEn': primary['nameEn'],
|
||||
'synonymsKr': synonyms_kr,
|
||||
'synonymsEn': synonyms_en,
|
||||
'unNumber': primary['unNumber'],
|
||||
'casNumber': primary['casNumber'],
|
||||
'transportMethod': '',
|
||||
'sebc': '',
|
||||
# 물리·화학 (OCR 단계에서 채움)
|
||||
'usage': primary['usage'],
|
||||
'state': '',
|
||||
'color': '',
|
||||
'odor': '',
|
||||
'flashPoint': '',
|
||||
'autoIgnition': '',
|
||||
'boilingPoint': '',
|
||||
'density': '',
|
||||
'solubility': '',
|
||||
'vaporPressure': '',
|
||||
'vaporDensity': '',
|
||||
'explosionRange': '',
|
||||
# 위험도
|
||||
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
|
||||
'hazardClass': '',
|
||||
'ergNumber': '',
|
||||
'idlh': '',
|
||||
'aegl2': '',
|
||||
'erpg2': '',
|
||||
# 방제
|
||||
'responseDistanceFire': '',
|
||||
'responseDistanceSpillDay': '',
|
||||
'responseDistanceSpillNight': '',
|
||||
'marineResponse': '',
|
||||
'ppeClose': '',
|
||||
'ppeFar': '',
|
||||
# MSDS
|
||||
'msds': {
|
||||
'hazard': '',
|
||||
'firstAid': '',
|
||||
'fireFighting': '',
|
||||
'spillResponse': '',
|
||||
'exposure': '',
|
||||
'regulation': '',
|
||||
},
|
||||
# IBC
|
||||
'ibcHazard': ibc.get('ibcHazard', ''),
|
||||
'ibcShipType': ibc.get('ibcShipType', ''),
|
||||
'ibcTankType': ibc.get('ibcTankType', ''),
|
||||
'ibcDetection': ibc.get('ibcDetection', ''),
|
||||
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
|
||||
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
|
||||
# EmS (OCR에서 채움)
|
||||
'emsCode': '',
|
||||
'emsFire': '',
|
||||
'emsSpill': '',
|
||||
'emsFirstAid': '',
|
||||
# cargoCodes / portFrequency
|
||||
'cargoCodes': cargo_codes,
|
||||
'portFrequency': [],
|
||||
}
|
||||
records.append(record)
|
||||
next_id += 1
|
||||
|
||||
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
|
||||
|
||||
# 저장
|
||||
out_path = OUT_DIR / 'base.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
170
backend/scripts/hns-import/extract-images.py
Normal file
170
backend/scripts/hns-import/extract-images.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
|
||||
|
||||
엑셀 워크시트 → drawing → image 관계 체인을 추적해
|
||||
각 물질 시트의 핵심 이미지만 out/images/{nameKr}.png 로 저장.
|
||||
|
||||
동시에 out/image-map.json 생성 (파일명 ↔ 시트명/국문명 매핑).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
NS = {
|
||||
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||
}
|
||||
|
||||
# 메타 시트(데이터 시트)는 스킵
|
||||
SKIP_SHEETS = {
|
||||
'화물적부도 화물코드',
|
||||
'항구별 코드',
|
||||
'동의어',
|
||||
'IBC CODE',
|
||||
'경계선',
|
||||
}
|
||||
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
|
||||
SKIP_PATTERN = re.compile(r'^\d{3}$')
|
||||
|
||||
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
|
||||
MIN_IMAGE_SIZE = 50_000 # 50 KB
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
name = name.strip().rstrip(',').strip()
|
||||
name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||
return name
|
||||
|
||||
|
||||
def norm_path(p: str) -> str:
|
||||
return os.path.normpath(p).replace(os.sep, '/')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
image_map: dict[str, dict] = {}
|
||||
saved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
|
||||
with zipfile.ZipFile(SOURCE_XLSX) as z:
|
||||
# 1) workbook.xml → sheet 목록
|
||||
with z.open('xl/workbook.xml') as f:
|
||||
wb_root = ET.parse(f).getroot()
|
||||
sheets = []
|
||||
for s in wb_root.findall('m:sheets/m:sheet', NS):
|
||||
sheets.append({
|
||||
'name': s.get('name'),
|
||||
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
|
||||
})
|
||||
with z.open('xl/_rels/workbook.xml.rels') as f:
|
||||
rels_root = ET.parse(f).getroot()
|
||||
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
|
||||
for s in sheets:
|
||||
s['target'] = rid_target.get(s['rid'])
|
||||
|
||||
print(f'[시트] 총 {len(sheets)}개')
|
||||
|
||||
for s in sheets:
|
||||
name = s['name']
|
||||
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sheet_file = 'xl/' + s['target']
|
||||
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
|
||||
try:
|
||||
with z.open(rels_file) as f:
|
||||
srels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 시트 → drawing
|
||||
drawing_rel = None
|
||||
for r in srels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
|
||||
drawing_rel = t
|
||||
break
|
||||
if not drawing_rel:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
|
||||
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
|
||||
try:
|
||||
with z.open(drawing_rels_path) as f:
|
||||
drels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# drawing → images
|
||||
image_paths: list[str] = []
|
||||
for r in drels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'image' in t.lower():
|
||||
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
|
||||
image_paths.append(img_path)
|
||||
if not image_paths:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
|
||||
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
|
||||
sized.sort(reverse=True)
|
||||
largest_size, largest_path = sized[0]
|
||||
if largest_size < MIN_IMAGE_SIZE:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 저장
|
||||
safe = safe_filename(name)
|
||||
ext = os.path.splitext(largest_path)[1].lower() or '.png'
|
||||
out_name = f'{safe}{ext}'
|
||||
out_path = IMG_DIR / out_name
|
||||
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
|
||||
fout.write(fin.read())
|
||||
image_map[out_name] = {
|
||||
'sheetName': name,
|
||||
'nameKr': safe,
|
||||
'source': largest_path,
|
||||
'sizeBytes': largest_size,
|
||||
}
|
||||
saved += 1
|
||||
if saved % 25 == 0:
|
||||
print(f' {saved}개 저장 완료')
|
||||
|
||||
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
|
||||
|
||||
map_path = OUT_DIR / 'image-map.json'
|
||||
with open(map_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(image_map, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] 매핑 파일: {map_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
707
backend/scripts/hns-import/extract-pdf.py
Normal file
707
backend/scripts/hns-import/extract-pdf.py
Normal file
@ -0,0 +1,707 @@
|
||||
"""PDF 물질정보집 → pdf-data.json 변환.
|
||||
|
||||
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
|
||||
해양경찰청 발행 193종 물질 정보
|
||||
|
||||
PDF 구조:
|
||||
- 페이지 1-21: 표지/머리말/목차
|
||||
- 페이지 22-407: 193종 × 2페이지 물질 카드
|
||||
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동 등
|
||||
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
|
||||
- 물질 NO(1-193) → 0-인덱스 시작 페이지: 21 + (NO-1) * 2
|
||||
|
||||
출력: out/pdf-data.json
|
||||
{ [nameKr]: OcrResult } — merge-data.ts 와 동일한 키 구조
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
PDF_PATH = Path(os.environ.get(
|
||||
'HNS_PDF_PATH',
|
||||
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
|
||||
))
|
||||
|
||||
# 전각 문자 → 반각 변환 테이블
|
||||
_FULLWIDTH = str.maketrans(
|
||||
'()tC°℃ ,',
|
||||
'()tC℃℃ ,',
|
||||
)
|
||||
|
||||
|
||||
def clean(s: str) -> str:
|
||||
"""텍스트 정리."""
|
||||
if not s:
|
||||
return ''
|
||||
s = s.translate(_FULLWIDTH)
|
||||
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
|
||||
s = re.sub(r'(?<=[0-9])\s*[仁七부사 ](?=\s|$|이|이하)', '℃', s)
|
||||
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '℃', s)
|
||||
s = re.sub(r'\s+', ' ', s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def norm_key(s: str) -> str:
|
||||
"""정규화 키: 공백/특수문자 제거 + 소문자."""
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
|
||||
|
||||
|
||||
def normalize_cas(raw: str) -> str:
|
||||
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
|
||||
if not raw:
|
||||
return ''
|
||||
# 혼합물
|
||||
if '혼합물' in raw:
|
||||
return ''
|
||||
# 특수 대시 → -
|
||||
s = raw.replace('—', '-').replace('-', '-').replace('–', '-')
|
||||
# OCR 오인식: 이,이, 공백 등 → 0
|
||||
s = re.sub(r'[이oO0]', '0', s)
|
||||
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
|
||||
# CAS 포맷 검증 후 반환
|
||||
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
|
||||
if m:
|
||||
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
|
||||
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
|
||||
s2 = re.sub(r'[^0-9\-]', '', s)
|
||||
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
|
||||
if m2:
|
||||
return m2.group(1).lstrip('0') or '0'
|
||||
return ''
|
||||
|
||||
|
||||
def find_cas_in_text(text: str) -> str:
|
||||
"""텍스트에서 CAS 번호 패턴 검색."""
|
||||
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
|
||||
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
|
||||
for c in candidates:
|
||||
cas = normalize_cas(c)
|
||||
if cas and len(cas) >= 5:
|
||||
return cas
|
||||
return ''
|
||||
|
||||
|
||||
def parse_nfpa(text: str) -> dict | None:
|
||||
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
|
||||
m = re.search(r'건강\s*[::]\s*(\d)\s*화재\s*[::]\s*(\d)\s*반응\s*[::]\s*(\d)', text)
|
||||
if m:
|
||||
return {
|
||||
'health': int(m.group(1)),
|
||||
'fire': int(m.group(2)),
|
||||
'reactivity': int(m.group(3)),
|
||||
'special': '',
|
||||
}
|
||||
# 대안 패턴: 줄바꿈 포함
|
||||
m2 = re.search(r'건강\s*[::]\s*(\d).*?화재\s*[::]\s*(\d).*?반응\s*[::]\s*(\d)', text, re.DOTALL)
|
||||
if m2:
|
||||
return {
|
||||
'health': int(m2.group(1)),
|
||||
'fire': int(m2.group(2)),
|
||||
'reactivity': int(m2.group(3)),
|
||||
'special': '',
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
|
||||
"""레이블 직후 값 추출 (단순 패턴)."""
|
||||
idx = text.find(label)
|
||||
if idx < 0:
|
||||
return ''
|
||||
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
|
||||
# 첫 비공백 줄 추출
|
||||
lines = snippet.split('\n')
|
||||
for line in lines:
|
||||
v = clean(line)
|
||||
if v and v not in (':', ':', ''):
|
||||
return v[:max_chars]
|
||||
return ''
|
||||
|
||||
|
||||
def parse_summary_card(text: str, index_entry: dict) -> dict:
|
||||
"""요약 카드(첫 번째 페이지) 파싱."""
|
||||
result: dict = {}
|
||||
|
||||
# 인화점
|
||||
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and '위험' not in val and len(val) < 40:
|
||||
result['flashPoint'] = val
|
||||
|
||||
# 발화점
|
||||
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['autoIgnition'] = val
|
||||
|
||||
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
|
||||
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
# 파편화된 텍스트 제거
|
||||
if val and re.search(r'\d', val) and len(val) < 60:
|
||||
result['vaporPressure'] = val
|
||||
|
||||
# 증기밀도 숫자값
|
||||
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 20:
|
||||
result['vaporDensity'] = val
|
||||
|
||||
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
|
||||
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and '%' in val and len(val) < 30:
|
||||
result['explosionRange'] = val
|
||||
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
|
||||
if not result.get('explosionRange'):
|
||||
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
|
||||
if m:
|
||||
result['explosionRange'] = clean(m.group(1))
|
||||
|
||||
# 화재시 대피거리
|
||||
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val:
|
||||
result['responseDistanceFire'] = val
|
||||
|
||||
# 해양거동
|
||||
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
|
||||
if not m:
|
||||
m = re.search(r'해양거동\s+([^\n]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 80:
|
||||
result['marineResponse'] = val
|
||||
|
||||
# 상온상태
|
||||
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['state'] = val
|
||||
|
||||
# 냄새
|
||||
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['odor'] = val
|
||||
|
||||
# 비중
|
||||
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
|
||||
if not m:
|
||||
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['density'] = val
|
||||
|
||||
# 색상
|
||||
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['color'] = val
|
||||
|
||||
# 이격거리 / 방호거리 거리 숫자 추출
|
||||
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
|
||||
if m_hot:
|
||||
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
|
||||
|
||||
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
|
||||
if m_warm:
|
||||
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_detail_card(text: str) -> dict:
|
||||
"""상세 카드(두 번째 페이지) 파싱."""
|
||||
result: dict = {}
|
||||
|
||||
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
|
||||
# 형식: "001 과산화수소" or "0이 과산화수소"
|
||||
first_lines = text.strip().split('\n')[:4]
|
||||
for line in first_lines:
|
||||
line = line.strip()
|
||||
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
|
||||
m = re.match(r'^[0-9이이아오0-9]{2,3}\s+([\w\s\-,./()()]+)$', line)
|
||||
if m:
|
||||
candidate = clean(m.group(1).strip())
|
||||
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
|
||||
result['nameKr'] = candidate
|
||||
break
|
||||
|
||||
# ── 분류 ──────────────────────────────────────────────────────────
|
||||
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
|
||||
if m:
|
||||
result['hazardClass'] = clean(m.group(0))
|
||||
|
||||
# ── 물질요약 ───────────────────────────────────────────────────────
|
||||
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
|
||||
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
|
||||
if not m:
|
||||
# 분류값 이후 ~ 유사명 전
|
||||
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
|
||||
if m:
|
||||
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
if summary and len(summary) > 15:
|
||||
result['materialSummary'] = summary[:500]
|
||||
|
||||
# ── 유사명 ─────────────────────────────────────────────────────────
|
||||
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
|
||||
if m:
|
||||
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
# CAS 번호 형태면 제외
|
||||
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
|
||||
result['synonymsKr'] = synonyms_raw
|
||||
|
||||
# ── CAS 번호 ────────────────────────────────────────────────────────
|
||||
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
|
||||
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
|
||||
if not m:
|
||||
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
|
||||
if m:
|
||||
cas = normalize_cas(m.group(1).strip().split()[0])
|
||||
if cas:
|
||||
result['casNumber'] = cas
|
||||
|
||||
# 2순위: 텍스트 전체에서 CAS 패턴 검색
|
||||
if not result.get('casNumber'):
|
||||
cas = find_cas_in_text(text)
|
||||
if cas:
|
||||
result['casNumber'] = cas
|
||||
|
||||
# ── UN 번호 ─────────────────────────────────────────────────────────
|
||||
# NFPA 코드 이후 줄에 있는 4자리 숫자
|
||||
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
|
||||
if not m:
|
||||
# NFPA 다음 4자리 숫자
|
||||
m = re.search(r'반응\s*[::]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
|
||||
if m:
|
||||
result['unNumber'] = m.group(1).strip()
|
||||
|
||||
# ── NFPA 코드 ───────────────────────────────────────────────────────
|
||||
nfpa = parse_nfpa(text)
|
||||
if nfpa:
|
||||
result['nfpa'] = nfpa
|
||||
|
||||
# ── 용도 ────────────────────────────────────────────────────────────
|
||||
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
|
||||
if m:
|
||||
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
# GHS 마크(특수문자 블록) 제거
|
||||
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
|
||||
if usage and len(usage) < 200:
|
||||
result['usage'] = usage
|
||||
|
||||
# ── 물질특성 블록 ───────────────────────────────────────────────────
|
||||
props_start = text.find('물질특성')
|
||||
props_end = text.find('인체 유해성')
|
||||
if props_end < 0:
|
||||
props_end = text.find('인체유해성')
|
||||
if props_end < 0:
|
||||
props_end = text.find('흡입노출')
|
||||
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
|
||||
|
||||
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
|
||||
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||
result['flashPoint'] = val
|
||||
|
||||
# 발화점 (상세) — 숫자 포함 값만 허용
|
||||
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||
result['autoIgnition'] = val
|
||||
|
||||
# 끓는점
|
||||
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['boilingPoint'] = val
|
||||
|
||||
# 수용해도
|
||||
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 50:
|
||||
result['solubility'] = val
|
||||
|
||||
# 상온상태 (상세)
|
||||
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1)).strip('()')
|
||||
if val and len(val) < 60:
|
||||
result['state'] = val
|
||||
|
||||
# 색상 (상세)
|
||||
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['color'] = val
|
||||
|
||||
# 냄새 (상세)
|
||||
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['odor'] = val
|
||||
|
||||
# 비중 (상세)
|
||||
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['density'] = val
|
||||
|
||||
# 증기압 (상세)
|
||||
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['vaporPressure'] = val
|
||||
|
||||
# 증기밀도 (상세)
|
||||
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 20:
|
||||
result['vaporDensity'] = val
|
||||
|
||||
# 점도
|
||||
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['viscosity'] = val
|
||||
|
||||
# ── 인체유해성 블록 ─────────────────────────────────────────────────
|
||||
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
|
||||
if hazard_start < 0:
|
||||
hazard_start = text.find('급성독성')
|
||||
response_start = text.find('초동대응')
|
||||
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
|
||||
|
||||
# IDLH
|
||||
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['idlh'] = val
|
||||
|
||||
# TWA
|
||||
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['twa'] = val
|
||||
|
||||
# ── 응급조치 ─────────────────────────────────────────────────────────
|
||||
fa_start = text.find('흡입노출')
|
||||
fa_end = text.find('초동대응')
|
||||
if fa_start >= 0:
|
||||
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
|
||||
fa = re.sub(r'\s+', ' ', fa_text).strip()
|
||||
result['msds'] = {
|
||||
'firstAid': fa[:600],
|
||||
'spillResponse': '',
|
||||
'hazard': '',
|
||||
'fireFighting': '',
|
||||
'exposure': '',
|
||||
'regulation': '',
|
||||
}
|
||||
|
||||
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
|
||||
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceSpillDay'] = m.group(1) + 'm'
|
||||
|
||||
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceSpillNight'] = m.group(1) + 'm'
|
||||
|
||||
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceFire'] = m.group(1) + 'm'
|
||||
|
||||
# ── GHS 분류 ─────────────────────────────────────────────────────────
|
||||
ghs_items = re.findall(
|
||||
r'(?:인화성[^\n((]{2,40}|급성독성[^\n((]{2,40}|피부부식[^\n((]{2,40}|'
|
||||
r'눈\s*손상[^\n((]{2,40}|발암성[^\n((]{2,40}|생식독성[^\n((]{2,40}|'
|
||||
r'수생환경[^\n((]{2,40}|특정표적[^\n((]{2,40}|흡인유해[^\n((]{2,40})',
|
||||
text,
|
||||
)
|
||||
if ghs_items:
|
||||
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
|
||||
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
|
||||
index: dict[int, dict] = {}
|
||||
|
||||
for page_idx in range(3, 21):
|
||||
page = pdf[page_idx]
|
||||
text = page.get_text()
|
||||
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if not re.match(r'^\d{1,3}$', line):
|
||||
continue
|
||||
no = int(line)
|
||||
if not (1 <= no <= 193):
|
||||
continue
|
||||
if no in index:
|
||||
continue
|
||||
|
||||
# 탐색 창: NO 앞 1~4줄
|
||||
cas, name_en, name_kr = '', '', ''
|
||||
if i >= 1:
|
||||
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
|
||||
raw_cas = lines[i - 1]
|
||||
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
|
||||
if not cas and '혼합물' in raw_cas:
|
||||
cas = '혼합물'
|
||||
|
||||
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
|
||||
if i >= 2:
|
||||
name_en = lines[i - 2]
|
||||
if i >= 3:
|
||||
name_kr = lines[i - 3]
|
||||
elif i >= 2:
|
||||
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
|
||||
raw_cas2 = lines[i - 2] if i >= 2 else ''
|
||||
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
|
||||
if cas or '혼합물' in raw_cas2:
|
||||
name_en = lines[i - 1] if i >= 1 else ''
|
||||
# name_kr는 찾기 어려움
|
||||
|
||||
if not name_kr and i >= 3:
|
||||
# 이름이 공백/짧으면 더 위 줄에서 찾기
|
||||
for j in range(3, min(6, i + 1)):
|
||||
cand = lines[i - j]
|
||||
if re.search(r'[가-힣]', cand) and len(cand) > 1:
|
||||
name_kr = cand
|
||||
break
|
||||
|
||||
index[no] = {
|
||||
'no': no,
|
||||
'nameKr': name_kr,
|
||||
'nameEn': name_en,
|
||||
'casNumber': cas if cas != '혼합물' else '',
|
||||
}
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def extract_name_from_summary(text: str) -> tuple[str, str]:
|
||||
"""요약 카드에서 nameKr, nameEn 추출."""
|
||||
name_kr, name_en = '', ''
|
||||
lines = text.strip().split('\n')
|
||||
|
||||
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
|
||||
found_header = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 제목 줄 건너뜀
|
||||
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
|
||||
found_header = True
|
||||
continue
|
||||
# 3자리 번호 줄 건너뜀
|
||||
if re.match(r'^\d{1,3}$', line):
|
||||
continue
|
||||
# 한글이 있으면 nameKr 후보
|
||||
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
|
||||
if not name_kr:
|
||||
name_kr = clean(line)
|
||||
|
||||
# 영문명: (영문명) 형태
|
||||
m_en = re.search(r'[((]([A-Za-z][^))]{3,60})[))]', line)
|
||||
if m_en and not name_en:
|
||||
name_en = clean(m_en.group(1))
|
||||
|
||||
if name_kr and name_en:
|
||||
break
|
||||
|
||||
return name_kr, name_en
|
||||
|
||||
|
||||
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
|
||||
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
|
||||
start_idx = 21 + (no - 1) * 2
|
||||
if start_idx + 1 >= pdf.page_count:
|
||||
return None
|
||||
|
||||
summary_text = pdf[start_idx].get_text()
|
||||
detail_text = pdf[start_idx + 1].get_text()
|
||||
|
||||
summary = parse_summary_card(summary_text, index_entry)
|
||||
detail = parse_detail_card(detail_text)
|
||||
|
||||
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
|
||||
name_kr = index_entry.get('nameKr', '')
|
||||
if not name_kr:
|
||||
name_kr = detail.get('nameKr', '')
|
||||
if not name_kr:
|
||||
name_kr, _ = extract_name_from_summary(summary_text)
|
||||
|
||||
# nameEn
|
||||
name_en = index_entry.get('nameEn', '')
|
||||
|
||||
# 통합: detail 우선, 없으면 summary
|
||||
merged: dict = {
|
||||
'nameKr': name_kr,
|
||||
'nameEn': name_en,
|
||||
}
|
||||
|
||||
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
|
||||
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
|
||||
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
|
||||
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
|
||||
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
|
||||
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
|
||||
detail_val = detail.get(key)
|
||||
summary_val = summary.get(key)
|
||||
if detail_val:
|
||||
merged[key] = detail_val
|
||||
elif summary_val:
|
||||
merged[key] = summary_val
|
||||
|
||||
# CAS: 인덱스 우선
|
||||
if index_entry.get('casNumber') and not merged.get('casNumber'):
|
||||
merged['casNumber'] = index_entry['casNumber']
|
||||
|
||||
# NFPA: detail 우선
|
||||
if 'nfpa' in detail:
|
||||
merged['nfpa'] = detail['nfpa']
|
||||
|
||||
if 'msds' not in merged:
|
||||
merged['msds'] = {
|
||||
'firstAid': '', 'spillResponse': '', 'hazard': '',
|
||||
'fireFighting': '', 'exposure': '', 'regulation': '',
|
||||
}
|
||||
|
||||
merged['_no'] = no
|
||||
merged['_pageIdx'] = start_idx
|
||||
return merged
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not PDF_PATH.exists():
|
||||
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
|
||||
|
||||
print(f'[읽기] {PDF_PATH}')
|
||||
pdf = fitz.open(str(PDF_PATH))
|
||||
print(f'[PDF] 총 {pdf.page_count}페이지')
|
||||
|
||||
# 1. 인덱스 파싱
|
||||
print('[인덱스] 목차 페이지 파싱 중...')
|
||||
index = parse_index_pages(pdf)
|
||||
print(f'[인덱스] {len(index)}개 항목 발견')
|
||||
|
||||
# 2. 물질 카드 파싱
|
||||
results: dict[str, dict] = {}
|
||||
failed: list[int] = []
|
||||
|
||||
for no in range(1, 194):
|
||||
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
|
||||
try:
|
||||
rec = parse_substance(pdf, no, entry)
|
||||
if rec:
|
||||
name_kr = rec.get('nameKr', '')
|
||||
if name_kr:
|
||||
key = name_kr
|
||||
if key in results:
|
||||
key = f'{name_kr}_{no}'
|
||||
results[key] = rec
|
||||
else:
|
||||
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
|
||||
failed.append(no)
|
||||
except Exception as e:
|
||||
print(f' [오류] NO={no}: {e}')
|
||||
failed.append(no)
|
||||
|
||||
pdf.close()
|
||||
|
||||
# 3. 저장
|
||||
out_path = OUT_DIR / 'pdf-data.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
size_kb = out_path.stat().st_size / 1024
|
||||
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
|
||||
|
||||
if failed:
|
||||
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
|
||||
|
||||
# 4. 통계
|
||||
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
|
||||
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
|
||||
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
|
||||
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
|
||||
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}종')
|
||||
|
||||
# 5. 샘플 출력
|
||||
print('\n[샘플] 주요 항목:')
|
||||
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
|
||||
for k in sample_keys:
|
||||
if k in results:
|
||||
v = results[k]
|
||||
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
23
backend/scripts/hns-import/merge-batch.py
Normal file
23
backend/scripts/hns-import/merge-batch.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""배치 JSON을 ocr.json에 병합."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OCR_PATH = OUT_DIR / 'ocr.json'
|
||||
BATCH_PATH = OUT_DIR / 'batch.json'
|
||||
|
||||
with open(OCR_PATH, encoding='utf-8') as f:
|
||||
ocr = json.load(f)
|
||||
with open(BATCH_PATH, encoding='utf-8') as f:
|
||||
batch = json.load(f)
|
||||
|
||||
added = [k for k in batch if k not in ocr]
|
||||
updated = [k for k in batch if k in ocr]
|
||||
ocr.update(batch)
|
||||
|
||||
with open(OCR_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')
|
||||
362
backend/scripts/hns-import/merge-data.ts
Normal file
362
backend/scripts/hns-import/merge-data.ts
Normal file
@ -0,0 +1,362 @@
|
||||
/**
|
||||
* base.json + pdf-data.json + ocr.json → frontend/src/data/hnsSubstanceData.json
|
||||
*
|
||||
* 우선순위: pdf-data (PDF 텍스트 추출, 최고 정확도) > base.json > ocr.json (이미지 OCR, 낮은 정확도)
|
||||
* 매칭 키 순서:
|
||||
* 1. CAS 번호 (가장 신뢰할 수 있는 식별자)
|
||||
* 2. 국문명(nameKr) 정규화 비교
|
||||
* 3. 동의어(synonymsKr) 정규화 비교
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const BASE_PATH = resolve(OUT_DIR, 'base.json');
|
||||
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
|
||||
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
|
||||
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
|
||||
|
||||
function normalizeName(s: string | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[,.·/\-_()[\]()]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCas(s: string | undefined): string {
|
||||
if (!s) return '';
|
||||
// 앞자리 0 제거 후 정규화
|
||||
return s
|
||||
.replace(/[^0-9\-]/g, '')
|
||||
.replace(/^0+/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface NfpaBlock {
|
||||
health: number;
|
||||
fire: number;
|
||||
reactivity: number;
|
||||
special: string;
|
||||
}
|
||||
|
||||
interface MsdsBlock {
|
||||
hazard: string;
|
||||
firstAid: string;
|
||||
fireFighting: string;
|
||||
spillResponse: string;
|
||||
exposure: string;
|
||||
regulation: string;
|
||||
}
|
||||
|
||||
interface BaseRecord {
|
||||
id: number;
|
||||
abbreviation: string;
|
||||
nameKr: string;
|
||||
nameEn: string;
|
||||
synonymsEn: string;
|
||||
synonymsKr: string;
|
||||
unNumber: string;
|
||||
casNumber: string;
|
||||
transportMethod: string;
|
||||
sebc: string;
|
||||
usage: string;
|
||||
state: string;
|
||||
color: string;
|
||||
odor: string;
|
||||
flashPoint: string;
|
||||
autoIgnition: string;
|
||||
boilingPoint: string;
|
||||
density: string;
|
||||
solubility: string;
|
||||
vaporPressure: string;
|
||||
vaporDensity: string;
|
||||
explosionRange: string;
|
||||
nfpa: NfpaBlock;
|
||||
hazardClass: string;
|
||||
ergNumber: string;
|
||||
idlh: string;
|
||||
aegl2: string;
|
||||
erpg2: string;
|
||||
responseDistanceFire: string;
|
||||
responseDistanceSpillDay: string;
|
||||
responseDistanceSpillNight: string;
|
||||
marineResponse: string;
|
||||
ppeClose: string;
|
||||
ppeFar: string;
|
||||
msds: MsdsBlock;
|
||||
ibcHazard: string;
|
||||
ibcShipType: string;
|
||||
ibcTankType: string;
|
||||
ibcDetection: string;
|
||||
ibcFireFighting: string;
|
||||
ibcMinRequirement: string;
|
||||
emsCode: string;
|
||||
emsFire: string;
|
||||
emsSpill: string;
|
||||
emsFirstAid: string;
|
||||
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
||||
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
||||
}
|
||||
|
||||
interface PdfResult {
|
||||
[key: string]: unknown;
|
||||
casNumber?: string;
|
||||
nameKr?: string;
|
||||
nfpa?: Partial<NfpaBlock>;
|
||||
msds?: Partial<MsdsBlock>;
|
||||
}
|
||||
|
||||
interface OcrResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function firstString(...values: Array<unknown>): string {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
|
||||
const n = source.nfpa as Partial<NfpaBlock> | undefined;
|
||||
if (!n || typeof n !== 'object') return null;
|
||||
const h = Number(n.health);
|
||||
const f = Number(n.fire);
|
||||
const r = Number(n.reactivity);
|
||||
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
|
||||
return {
|
||||
health: h,
|
||||
fire: f,
|
||||
reactivity: r,
|
||||
special: typeof n.special === 'string' ? n.special : '',
|
||||
};
|
||||
}
|
||||
|
||||
function pickMsds(
|
||||
pdf: PdfResult | undefined,
|
||||
ocr: OcrResult | undefined,
|
||||
base: MsdsBlock,
|
||||
): MsdsBlock {
|
||||
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
|
||||
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
|
||||
return {
|
||||
hazard: firstString(base.hazard, p.hazard, o.hazard),
|
||||
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
|
||||
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
|
||||
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
|
||||
exposure: firstString(base.exposure, p.exposure, o.exposure),
|
||||
regulation: firstString(base.regulation, p.regulation, o.regulation),
|
||||
};
|
||||
}
|
||||
|
||||
function merge(
|
||||
base: BaseRecord,
|
||||
pdf: PdfResult | undefined,
|
||||
ocr: OcrResult | undefined,
|
||||
): BaseRecord {
|
||||
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
|
||||
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
|
||||
// pdf NFPA 우선, 없으면 ocr, 없으면 base
|
||||
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
|
||||
|
||||
return {
|
||||
...base,
|
||||
// pdf > base > ocr 우선순위
|
||||
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
|
||||
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
|
||||
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
|
||||
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
|
||||
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
|
||||
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
|
||||
state: firstString(pdf?.state, base.state, ocr?.state),
|
||||
color: firstString(pdf?.color, base.color, ocr?.color),
|
||||
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
|
||||
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
|
||||
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
|
||||
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
|
||||
density: firstString(pdf?.density, base.density, ocr?.density),
|
||||
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
|
||||
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
|
||||
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
|
||||
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
|
||||
nfpa,
|
||||
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
|
||||
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
|
||||
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
|
||||
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
|
||||
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
|
||||
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
|
||||
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
|
||||
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
|
||||
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
|
||||
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
|
||||
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
|
||||
msds: pickMsds(pdf, ocr, base.msds),
|
||||
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
|
||||
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
|
||||
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
|
||||
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(BASE_PATH)) {
|
||||
console.error(`base.json 없음: ${BASE_PATH}`);
|
||||
console.error('→ extract-excel.py 를 먼저 실행하세요.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
|
||||
|
||||
// PDF 데이터 로드
|
||||
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
|
||||
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
// OCR 데이터 로드
|
||||
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
|
||||
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
console.log(
|
||||
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}종`,
|
||||
);
|
||||
|
||||
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
|
||||
// 1) nameKr 정규화 인덱스
|
||||
const pdfByName = new Map<string, PdfResult>();
|
||||
// 2) CAS 번호 인덱스
|
||||
const pdfByCas = new Map<string, PdfResult>();
|
||||
|
||||
for (const [key, value] of Object.entries(pdfRaw)) {
|
||||
const normKey = normalizeName(key);
|
||||
if (normKey) pdfByName.set(normKey, value);
|
||||
|
||||
const cas = normalizeCas(value.casNumber);
|
||||
if (cas) {
|
||||
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
|
||||
const ocrByName = new Map<string, OcrResult>();
|
||||
const ocrNormToOrig = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(ocr)) {
|
||||
const normKey = normalizeName(key);
|
||||
if (normKey) {
|
||||
ocrByName.set(normKey, value);
|
||||
ocrNormToOrig.set(normKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 병합 ──────────────────────────────────────────────────────────
|
||||
let pdfMatchedByName = 0;
|
||||
let pdfMatchedByCas = 0;
|
||||
let pdfMatchedBySynonym = 0;
|
||||
let ocrMatched = 0;
|
||||
const pdfUnmatched = new Set(Object.keys(pdfRaw));
|
||||
const ocrUnmatched = new Set(ocrByName.keys());
|
||||
|
||||
const merged = base.map((record) => {
|
||||
let pdfResult: PdfResult | undefined;
|
||||
let ocrResult: OcrResult | undefined;
|
||||
|
||||
// ── PDF 매칭 ────────────────────────────────────────────────────
|
||||
// 1. CAS 번호 매칭 (가장 정확)
|
||||
const baseCas = normalizeCas(record.casNumber);
|
||||
if (baseCas) {
|
||||
pdfResult = pdfByCas.get(baseCas);
|
||||
if (pdfResult) {
|
||||
pdfMatchedByCas++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. nameKr 정규화 매칭
|
||||
if (!pdfResult) {
|
||||
const normKr = normalizeName(record.nameKr);
|
||||
pdfResult = pdfByName.get(normKr);
|
||||
if (pdfResult) {
|
||||
pdfMatchedByName++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. synonymsKr 동의어 매칭
|
||||
if (!pdfResult && record.synonymsKr) {
|
||||
const synonyms = record.synonymsKr.split(' / ');
|
||||
for (const syn of synonyms) {
|
||||
const normSyn = normalizeName(syn);
|
||||
if (!normSyn) continue;
|
||||
pdfResult = pdfByName.get(normSyn);
|
||||
if (pdfResult) {
|
||||
pdfMatchedBySynonym++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
|
||||
const normKr = normalizeName(record.nameKr);
|
||||
const ocrByNameResult = ocrByName.get(normKr);
|
||||
if (ocrByNameResult) {
|
||||
ocrResult = ocrByNameResult;
|
||||
ocrMatched++;
|
||||
ocrUnmatched.delete(normKr);
|
||||
}
|
||||
|
||||
if (!ocrResult && record.synonymsKr) {
|
||||
const synonyms = record.synonymsKr.split(' / ');
|
||||
for (const syn of synonyms) {
|
||||
const normSyn = normalizeName(syn);
|
||||
if (!normSyn) continue;
|
||||
const synOcrResult = ocrByName.get(normSyn);
|
||||
if (synOcrResult) {
|
||||
ocrResult = synOcrResult;
|
||||
ocrMatched++;
|
||||
ocrUnmatched.delete(normSyn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merge(record, pdfResult, ocrResult);
|
||||
});
|
||||
|
||||
// ── 통계 출력 ──────────────────────────────────────────────────────
|
||||
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
|
||||
console.log(
|
||||
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
|
||||
);
|
||||
console.log(`[OCR 매칭] ${ocrMatched}종`);
|
||||
|
||||
if (pdfUnmatched.size > 0) {
|
||||
const unmatchedList = Array.from(pdfUnmatched).sort();
|
||||
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
|
||||
writeFileSync(
|
||||
unmatchedPath,
|
||||
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
console.warn(
|
||||
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
|
||||
);
|
||||
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
|
||||
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
|
||||
}
|
||||
|
||||
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
|
||||
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
|
||||
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}종`);
|
||||
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}종`);
|
||||
}
|
||||
|
||||
main();
|
||||
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환.
|
||||
*
|
||||
* 입력: out/images/*.png (222개)
|
||||
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
|
||||
*
|
||||
* 환경변수: ANTHROPIC_API_KEY
|
||||
* 모델: claude-sonnet-4-5 (Vision + 비용 효율)
|
||||
* 동시성: 5, 재시도 3회
|
||||
*
|
||||
* 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, basename, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
|
||||
? resolve(process.env.HNS_OCR_IMG_DIR)
|
||||
: resolve(OUT_DIR, 'images');
|
||||
const OCR_PATH = process.env.HNS_OCR_OUT
|
||||
? resolve(process.env.HNS_OCR_OUT)
|
||||
: resolve(OUT_DIR, 'ocr.json');
|
||||
const FAIL_PATH = process.env.HNS_OCR_FAIL
|
||||
? resolve(process.env.HNS_OCR_FAIL)
|
||||
: resolve(OUT_DIR, 'ocr-failures.json');
|
||||
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
|
||||
const OCR_ONLY = process.env.HNS_OCR_ONLY
|
||||
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const CONCURRENCY = 5;
|
||||
const MAX_RETRIES = 3;
|
||||
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
|
||||
|
||||
이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다:
|
||||
- 상단: 국문명, 영문명
|
||||
- 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호
|
||||
- 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리
|
||||
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치
|
||||
|
||||
아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null.
|
||||
숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)").
|
||||
NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시).
|
||||
|
||||
응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이).
|
||||
|
||||
스키마:
|
||||
{
|
||||
"transportMethod": "",
|
||||
"state": "",
|
||||
"color": "",
|
||||
"odor": "",
|
||||
"flashPoint": "",
|
||||
"autoIgnition": "",
|
||||
"boilingPoint": "",
|
||||
"density": "",
|
||||
"solubility": "",
|
||||
"vaporPressure": "",
|
||||
"vaporDensity": "",
|
||||
"explosionRange": "",
|
||||
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
|
||||
"hazardClass": "",
|
||||
"ergNumber": "",
|
||||
"idlh": "",
|
||||
"aegl2": "",
|
||||
"erpg2": "",
|
||||
"twa": "",
|
||||
"stel": "",
|
||||
"responseDistanceFire": "",
|
||||
"responseDistanceSpillDay": "",
|
||||
"responseDistanceSpillNight": "",
|
||||
"marineResponse": "",
|
||||
"ppeClose": "",
|
||||
"ppeFar": "",
|
||||
"msds": {
|
||||
"hazard": "",
|
||||
"firstAid": "",
|
||||
"fireFighting": "",
|
||||
"spillResponse": "",
|
||||
"exposure": "",
|
||||
"regulation": ""
|
||||
},
|
||||
"emsCode": "",
|
||||
"emsFire": "",
|
||||
"emsSpill": "",
|
||||
"emsFirstAid": "",
|
||||
"sebc": ""
|
||||
}`;
|
||||
|
||||
interface OcrResult {
|
||||
transportMethod?: string;
|
||||
state?: string;
|
||||
color?: string;
|
||||
odor?: string;
|
||||
flashPoint?: string;
|
||||
autoIgnition?: string;
|
||||
boilingPoint?: string;
|
||||
density?: string;
|
||||
solubility?: string;
|
||||
vaporPressure?: string;
|
||||
vaporDensity?: string;
|
||||
explosionRange?: string;
|
||||
nfpa?: { health: number; fire: number; reactivity: number; special: string };
|
||||
hazardClass?: string;
|
||||
ergNumber?: string;
|
||||
idlh?: string;
|
||||
aegl2?: string;
|
||||
erpg2?: string;
|
||||
twa?: string;
|
||||
stel?: string;
|
||||
responseDistanceFire?: string;
|
||||
responseDistanceSpillDay?: string;
|
||||
responseDistanceSpillNight?: string;
|
||||
marineResponse?: string;
|
||||
ppeClose?: string;
|
||||
ppeFar?: string;
|
||||
msds?: {
|
||||
hazard?: string;
|
||||
firstAid?: string;
|
||||
fireFighting?: string;
|
||||
spillResponse?: string;
|
||||
exposure?: string;
|
||||
regulation?: string;
|
||||
};
|
||||
emsCode?: string;
|
||||
emsFire?: string;
|
||||
emsSpill?: string;
|
||||
emsFirstAid?: string;
|
||||
sebc?: string;
|
||||
}
|
||||
|
||||
function loadExisting<T>(path: string, fallback: T): T {
|
||||
if (!existsSync(path)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJson(text: string): OcrResult | null {
|
||||
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
|
||||
const firstBrace = cleaned.indexOf('{');
|
||||
const lastBrace = cleaned.lastIndexOf('}');
|
||||
if (firstBrace < 0 || lastBrace < 0) return null;
|
||||
try {
|
||||
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
|
||||
const imageData = readFileSync(imagePath).toString('base64');
|
||||
const ext = extname(imagePath).slice(1).toLowerCase();
|
||||
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
|
||||
| 'image/png'
|
||||
| 'image/jpeg';
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: SYSTEM_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: mediaType, data: imageData },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const textBlock = response.content.find((b) => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') {
|
||||
throw new Error('응답에 텍스트 블록 없음');
|
||||
}
|
||||
const result = extractJson(textBlock.text);
|
||||
if (!result) {
|
||||
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function processWithRetry(
|
||||
client: Anthropic,
|
||||
imagePath: string,
|
||||
nameKr: string,
|
||||
): Promise<OcrResult> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await callVision(client, imagePath);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const wait = 1000 * Math.pow(2, attempt - 1);
|
||||
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const idx = cursor++;
|
||||
await worker(items[idx], idx);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new Anthropic();
|
||||
|
||||
if (!existsSync(IMG_DIR)) {
|
||||
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
|
||||
const images = OCR_ONLY
|
||||
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
|
||||
: allImages;
|
||||
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
|
||||
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
|
||||
|
||||
let pending = images.filter((f) => {
|
||||
const nameKr = basename(f, extname(f));
|
||||
return !(nameKr in existing);
|
||||
});
|
||||
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
|
||||
pending = pending.slice(0, OCR_LIMIT);
|
||||
}
|
||||
|
||||
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`);
|
||||
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
|
||||
console.log(`[출력] ${OCR_PATH}`);
|
||||
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
await runPool(pending, async (file, idx) => {
|
||||
const nameKr = basename(file, extname(file));
|
||||
const path = resolve(IMG_DIR, file);
|
||||
try {
|
||||
const result = await processWithRetry(client, path, nameKr);
|
||||
existing[nameKr] = result;
|
||||
delete failures[nameKr];
|
||||
done++;
|
||||
if (done % 10 === 0 || done === pending.length) {
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
failures[nameKr] = String(err).slice(0, 500);
|
||||
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
|
||||
|
||||
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
|
||||
console.log(` OCR 결과: ${OCR_PATH}`);
|
||||
if (Object.keys(failures).length > 0) {
|
||||
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
324
backend/scripts/hns-import/ocr-local.py
Normal file
324
backend/scripts/hns-import/ocr-local.py
Normal file
@ -0,0 +1,324 @@
|
||||
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
|
||||
|
||||
전용 venv(.venv)에 설치된 easyocr을 사용한다.
|
||||
|
||||
1. 이미지 → EasyOCR → (bbox, text, conf) 리스트
|
||||
2. y좌표로 행 그룹화 후 각 행 내 x좌표 정렬
|
||||
3. 레이블 키워드 기반 필드 매핑 (정규식)
|
||||
4. 결과를 out/ocr.json 에 누적 저장 (재실행 가능)
|
||||
|
||||
실행:
|
||||
cd backend/scripts/hns-import
|
||||
source .venv/Scripts/activate # Windows Git Bash
|
||||
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
|
||||
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
|
||||
|
||||
|
||||
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
|
||||
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
|
||||
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
|
||||
LABEL_CANDIDATES: dict[str, list[str]] = {
|
||||
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
|
||||
'unNumber': ['UN번호', 'UNNO', 'UN'],
|
||||
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
|
||||
'usage': ['용도'],
|
||||
'state': ['성상', '상태', '형태'],
|
||||
'color': ['색상', '색'],
|
||||
'odor': ['냄새'],
|
||||
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
|
||||
'autoIgnition': ['발화점', '발회점', '발하점'],
|
||||
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
|
||||
'density': ['비중'],
|
||||
'solubility': ['용해도', '용해'],
|
||||
'vaporPressure': ['증기압', '증기압력'],
|
||||
'vaporDensity': ['증기밀도'],
|
||||
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
|
||||
'idlh': ['IDLH'],
|
||||
'aegl2': ['AEGL-2', 'AEGL2'],
|
||||
'erpg2': ['ERPG-2', 'ERPG2'],
|
||||
'twa': ['TWA'],
|
||||
'stel': ['STEL'],
|
||||
'ergNumber': ['ERG번호', 'ERG'],
|
||||
'hazardClass': ['위험분류', '위험', '분류'],
|
||||
'synonymsKr': ['유사명'],
|
||||
'responseDistanceFire': ['대피거리', '머피거리'],
|
||||
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
|
||||
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
|
||||
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
|
||||
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
|
||||
'marineResponse': ['해상대응', '해상'],
|
||||
}
|
||||
|
||||
|
||||
def _norm_label(s: str) -> str:
|
||||
"""공백/특수문자 제거 후 비교용 정규화."""
|
||||
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
|
||||
|
||||
|
||||
LABEL_INDEX: dict[str, str] = {}
|
||||
for _field, _candidates in LABEL_CANDIDATES.items():
|
||||
for _cand in _candidates:
|
||||
LABEL_INDEX[_norm_label(_cand)] = _field
|
||||
|
||||
# NFPA 셀 값(한 자릿수 0~4) 추출용
|
||||
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
|
||||
|
||||
|
||||
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
|
||||
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
|
||||
if not items:
|
||||
return []
|
||||
heights = [it['y1'] - it['y0'] for it in items]
|
||||
median_h = sorted(heights)[len(heights) // 2]
|
||||
y_tol = max(8, median_h * y_tolerance_ratio)
|
||||
|
||||
sorted_items = sorted(items, key=lambda it: it['cy'])
|
||||
rows: list[list[dict]] = []
|
||||
for it in sorted_items:
|
||||
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
|
||||
rows[-1].append(it)
|
||||
else:
|
||||
rows.append([it])
|
||||
for row in rows:
|
||||
row.sort(key=lambda it: it['cx'])
|
||||
return rows
|
||||
|
||||
|
||||
def _match_label(text: str) -> str | None:
|
||||
key = _norm_label(text)
|
||||
if not key:
|
||||
return None
|
||||
# 정확 일치 우선
|
||||
if key in LABEL_INDEX:
|
||||
return LABEL_INDEX[key]
|
||||
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
|
||||
for cand_key, field in LABEL_INDEX.items():
|
||||
if len(cand_key) >= 2 and key.startswith(cand_key):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def parse_card(items: list[dict]) -> dict[str, Any]:
|
||||
"""OCR 결과 목록을 필드 dict로 변환."""
|
||||
rows = group_rows(items)
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# 1) 행 내 "레이블 → 값" 쌍 추출
|
||||
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
|
||||
for row in rows:
|
||||
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
|
||||
idx = 0
|
||||
while idx < len(row):
|
||||
field = _match_label(row[idx]['text'])
|
||||
if field:
|
||||
# 다음 non-label 조각을 값으로 취함
|
||||
value_parts: list[str] = []
|
||||
j = idx + 1
|
||||
while j < len(row):
|
||||
nxt = row[j]
|
||||
if _match_label(nxt['text']):
|
||||
break
|
||||
value_parts.append(nxt['text'])
|
||||
j += 1
|
||||
if value_parts and field not in result:
|
||||
value = ' '.join(value_parts).strip()
|
||||
if value and value not in ('-', '–', 'N/A'):
|
||||
result[field] = value
|
||||
idx = j
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
|
||||
nfpa_idx_row: int | None = None
|
||||
for ri, row in enumerate(rows):
|
||||
for cell in row:
|
||||
if re.search(r'NFPA', cell['text']):
|
||||
nfpa_idx_row = ri
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
|
||||
candidates: list[int] = []
|
||||
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
|
||||
for cell in rows[ri]:
|
||||
m = NFPA_VALUE_RE.match(cell['text'].strip())
|
||||
if m:
|
||||
candidates.append(int(cell['text'].strip()))
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
result['nfpa'] = {
|
||||
'health': candidates[0],
|
||||
'fire': candidates[1],
|
||||
'reactivity': candidates[2],
|
||||
'special': '',
|
||||
}
|
||||
|
||||
# 3) EmS 코드 (F-x / S-x 패턴)
|
||||
all_text = ' '.join(cell['text'] for row in rows for cell in row)
|
||||
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
|
||||
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
|
||||
if f_match or s_match:
|
||||
parts = []
|
||||
if f_match:
|
||||
parts.append(f'F-{f_match.group(1)}')
|
||||
if s_match:
|
||||
parts.append(f'S-{s_match.group(1)}')
|
||||
if parts:
|
||||
result['emsCode'] = ', '.join(parts)
|
||||
|
||||
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
|
||||
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
|
||||
if erg_match:
|
||||
result['ergNumber'] = erg_match.group(1)
|
||||
|
||||
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _preprocess_image(pil_img, upscale: float = 2.5):
|
||||
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
import numpy as np
|
||||
|
||||
if pil_img.mode != 'RGB':
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
# 1) 업스케일 (LANCZOS)
|
||||
w, h = pil_img.size
|
||||
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
|
||||
|
||||
# 2) 대비 향상
|
||||
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
|
||||
|
||||
# 3) 샤프닝
|
||||
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
|
||||
|
||||
return np.array(pil_img)
|
||||
|
||||
|
||||
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
|
||||
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
|
||||
from PIL import Image
|
||||
with Image.open(image_path) as pil:
|
||||
img = _preprocess_image(pil, upscale=upscale)
|
||||
raw = reader.readtext(img, detail=1, paragraph=False)
|
||||
items: list[dict] = []
|
||||
for bbox, text, conf in raw:
|
||||
if not text or not str(text).strip():
|
||||
continue
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
items.append({
|
||||
'text': str(text).strip(),
|
||||
'cx': sum(xs) / 4.0,
|
||||
'cy': sum(ys) / 4.0,
|
||||
'x0': min(xs),
|
||||
'x1': max(xs),
|
||||
'y0': min(ys),
|
||||
'y1': max(ys),
|
||||
'conf': float(conf),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def load_json(path: Path, fallback):
|
||||
if not path.exists():
|
||||
return fallback
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--limit', type=int, default=None)
|
||||
parser.add_argument('--only', type=str, default=None,
|
||||
help='파이프(|)로 구분된 물질명 리스트')
|
||||
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
|
||||
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
|
||||
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='파싱 중간 결과(row 단위) 함께 출력')
|
||||
args = parser.parse_args()
|
||||
|
||||
import easyocr # noqa: WPS433
|
||||
|
||||
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
|
||||
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
||||
print('[로딩] 완료')
|
||||
|
||||
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
|
||||
if args.only:
|
||||
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
|
||||
images = [p for p in images if p.stem in only_set]
|
||||
|
||||
existing: dict[str, Any] = load_json(args.out, {})
|
||||
failures: dict[str, str] = load_json(args.fail, {})
|
||||
|
||||
pending = [p for p in images if p.stem not in existing]
|
||||
if args.limit:
|
||||
pending = pending[: args.limit]
|
||||
|
||||
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}개')
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
for i, path in enumerate(pending, start=1):
|
||||
name = path.stem
|
||||
try:
|
||||
items = run_ocr(path, reader)
|
||||
parsed = parse_card(items)
|
||||
if args.debug:
|
||||
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
|
||||
for row in group_rows(items):
|
||||
print(' |', ' │ '.join(f'{c["text"]}' for c in row))
|
||||
print(f' → parsed: {parsed}')
|
||||
existing[name] = parsed
|
||||
if name in failures:
|
||||
del failures[name]
|
||||
ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
failures[name] = f'{type(e).__name__}: {e}'[:500]
|
||||
fail += 1
|
||||
print(f'[실패] {name}: {e}')
|
||||
|
||||
if i % 10 == 0 or i == len(pending):
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
|
||||
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
|
||||
print(f' 결과: {args.out}')
|
||||
if failures:
|
||||
print(f' 실패 목록: {args.fail}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
backend/scripts/hns-import/requirements.txt
Normal file
29
backend/scripts/hns-import/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
||||
# extract-excel.py 용
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
|
||||
easyocr==1.7.2
|
||||
filelock==3.19.1
|
||||
fsspec==2025.10.0
|
||||
ImageIO==2.37.2
|
||||
Jinja2==3.1.6
|
||||
lazy-loader==0.5
|
||||
MarkupSafe==3.0.3
|
||||
mpmath==1.3.0
|
||||
networkx==3.2.1
|
||||
ninja==1.13.0
|
||||
numpy==2.0.2
|
||||
opencv-python-headless==4.13.0.92
|
||||
packaging==26.1
|
||||
pillow==11.3.0
|
||||
pyclipper==1.3.0.post6
|
||||
python-bidi==0.6.7
|
||||
PyYAML==6.0.3
|
||||
scikit-image==0.24.0
|
||||
scipy==1.13.1
|
||||
shapely==2.0.7
|
||||
sympy==1.14.0
|
||||
tifffile==2024.8.30
|
||||
torch==2.8.0
|
||||
torchvision==0.23.0
|
||||
typing_extensions==4.15.0
|
||||
@ -1,6 +1,8 @@
|
||||
import express from 'express';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
listMedia,
|
||||
createMedia,
|
||||
@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
const router = express.Router();
|
||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
const mediaUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = path.resolve('uploads', 'aerial');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i;
|
||||
if (allowed.test(path.extname(file.originalname))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('허용되지 않는 파일 형식입니다.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AERIAL_MEDIA 라우트
|
||||
// ============================================================
|
||||
@ -73,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록
|
||||
router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: '파일이 필요합니다.' });
|
||||
return;
|
||||
}
|
||||
const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as {
|
||||
equipTpCd?: string;
|
||||
equipNm?: string;
|
||||
mediaTpCd?: string;
|
||||
acdntSn?: string;
|
||||
memo?: string;
|
||||
};
|
||||
|
||||
const isVideo = file.mimetype.startsWith('video/');
|
||||
const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진');
|
||||
const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
|
||||
const result = await createMedia({
|
||||
fileNm: file.filename,
|
||||
orgnlNm: file.originalname,
|
||||
filePath: file.path,
|
||||
equipTpCd: equipTpCd ?? 'drone',
|
||||
equipNm: equipNm ?? '기타',
|
||||
mediaTpCd: detectedMediaType,
|
||||
fileSz: fileSzMb,
|
||||
acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined,
|
||||
locDc: memo ?? undefined,
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('[aerial] 미디어 업로드 오류:', err);
|
||||
res.status(500).json({ error: '미디어 업로드 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시)
|
||||
router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params['sn'] as string, 10);
|
||||
if (!isValidNumber(sn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await getMediaBySn(sn);
|
||||
if (!media) {
|
||||
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 로컬 업로드 파일이면 직접 서빙
|
||||
if (media.filePath) {
|
||||
const absPath = path.resolve(media.filePath);
|
||||
if (existsSync(absPath)) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.tif': 'image/tiff', '.tiff': 'image/tiff',
|
||||
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||
};
|
||||
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.sendFile(absPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = media.fileNm.substring(0, 36);
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
|
||||
res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await fetchOriginalImage(media.equipNm, fileId);
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[aerial] 이미지 뷰어 오류:', err);
|
||||
res.status(502).json({ error: '이미지 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
||||
// OIL INFERENCE (GPU 서버 프록시)
|
||||
// ============================================================
|
||||
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
|
||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||
|
||||
|
||||
@ -24,8 +24,14 @@ async function seedHnsSubstances() {
|
||||
|
||||
let inserted = 0
|
||||
|
||||
// varchar 길이 제한에 맞춰 첫 번째 토큰만 검색 컬럼에 저장 (원본은 DATA JSONB에 보존)
|
||||
const firstToken = (v: unknown, max: number): string | null => {
|
||||
if (v == null) return null
|
||||
const s = String(v).split(/[\n,;/]/)[0].trim()
|
||||
return s ? s.slice(0, max) : null
|
||||
}
|
||||
|
||||
for (const s of HNS_SEARCH_DB) {
|
||||
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
||||
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||
|
||||
await client.query(
|
||||
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
|
||||
CAS_NO = EXCLUDED.CAS_NO,
|
||||
SEBC = EXCLUDED.SEBC,
|
||||
DATA = EXCLUDED.DATA`,
|
||||
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
|
||||
[
|
||||
s.id,
|
||||
firstToken(abbreviation, 50),
|
||||
firstToken(nameKr, 200) ?? '',
|
||||
firstToken(nameEn, 200),
|
||||
firstToken(unNumber, 10),
|
||||
firstToken(casNumber, 20),
|
||||
firstToken(sebc, 50),
|
||||
JSON.stringify(detailData),
|
||||
]
|
||||
)
|
||||
|
||||
inserted++
|
||||
|
||||
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../auth/authMiddleware.js';
|
||||
import { listGscAccidents } from './gscAccidentsService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============================================================
|
||||
// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건)
|
||||
// ============================================================
|
||||
router.get('/', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const accidents = await listGscAccidents(20);
|
||||
res.json(accidents);
|
||||
} catch (err) {
|
||||
console.error('[gsc] 사고 목록 조회 오류:', err);
|
||||
res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
44
backend/src/gsc/gscAccidentsService.ts
Normal file
44
backend/src/gsc/gscAccidentsService.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
export interface GscAccidentListItem {
|
||||
acdntSn: number;
|
||||
acdntMngNo: string;
|
||||
pollNm: string;
|
||||
pollDate: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}
|
||||
|
||||
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
ACDNT_SN AS "acdntSn",
|
||||
ACDNT_CD AS "acdntMngNo",
|
||||
ACDNT_NM AS "pollNm",
|
||||
to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
|
||||
LAT AS "lat",
|
||||
LNG AS "lon"
|
||||
FROM wing.ACDNT
|
||||
WHERE ACDNT_NM IS NOT NULL
|
||||
ORDER BY OCCRN_DTM DESC NULLS LAST
|
||||
LIMIT $1
|
||||
`;
|
||||
|
||||
const result = await wingPool.query<{
|
||||
acdntSn: number;
|
||||
acdntMngNo: string;
|
||||
pollNm: string;
|
||||
pollDate: string | null;
|
||||
lat: string | null;
|
||||
lon: string | null;
|
||||
}>(sql, [limit]);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
acdntSn: row.acdntSn,
|
||||
acdntMngNo: row.acdntMngNo,
|
||||
pollNm: row.pollNm,
|
||||
pollDate: row.pollDate,
|
||||
lat: row.lat != null ? Number(row.lat) : null,
|
||||
lon: row.lon != null ? Number(row.lon) : null,
|
||||
}));
|
||||
}
|
||||
@ -12,11 +12,13 @@ const router = express.Router()
|
||||
// GET /api/hns/analyses — 분석 목록
|
||||
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { status, substance, search } = req.query
|
||||
const { status, substance, search, acdntSn } = req.query
|
||||
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
||||
const items = await listAnalyses({
|
||||
status: status as string | undefined,
|
||||
substance: substance as string | undefined,
|
||||
search: search as string | undefined,
|
||||
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||
})
|
||||
res.json(items)
|
||||
} catch (err) {
|
||||
@ -48,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async
|
||||
// POST /api/hns/analyses — 분석 생성
|
||||
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
||||
const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
||||
if (!anlysNm) {
|
||||
res.status(400).json({ error: '분석명은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined
|
||||
const result = await createAnalysis({
|
||||
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
||||
anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||
acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
||||
})
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
|
||||
@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) {
|
||||
|
||||
interface HnsAnalysisItem {
|
||||
hnsAnlysSn: number
|
||||
acdntSn: number | null
|
||||
anlysNm: string
|
||||
acdntDtm: string | null
|
||||
locNm: string | null
|
||||
@ -118,11 +119,13 @@ interface ListAnalysesInput {
|
||||
status?: string
|
||||
substance?: string
|
||||
search?: string
|
||||
acdntSn?: number
|
||||
}
|
||||
|
||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||
return {
|
||||
hnsAnlysSn: r.hns_anlys_sn as number,
|
||||
acdntSn: (r.acdnt_sn as number) ?? null,
|
||||
anlysNm: r.anlys_nm as string,
|
||||
acdntDtm: r.acdnt_dtm as string | null,
|
||||
locNm: r.loc_nm as string | null,
|
||||
@ -146,7 +149,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||
|
||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||
const params: string[] = []
|
||||
const params: (string | number)[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.status) {
|
||||
@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
||||
params.push(input.search)
|
||||
idx++
|
||||
}
|
||||
if (input.acdntSn != null) {
|
||||
conditions.push(`ACDNT_SN = $${idx++}`)
|
||||
params.push(input.acdntSn)
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||
RSLT_DATA, REG_DTM
|
||||
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
||||
|
||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||
@ -194,6 +201,7 @@ export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||
|
||||
export async function createAnalysis(input: {
|
||||
anlysNm: string
|
||||
acdntSn?: number
|
||||
acdntDtm?: string
|
||||
locNm?: string
|
||||
lon?: number
|
||||
@ -213,21 +221,21 @@ export async function createAnalysis(input: {
|
||||
}): Promise<{ hnsAnlysSn: number }> {
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO HNS_ANALYSIS (
|
||||
ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
GEOM, LOC_DC,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
ANALYST_NM, EXEC_STTS_CD
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
||||
$6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16,
|
||||
$17, 'PENDING'
|
||||
$1, $2, $3, $4, $5::numeric, $6::numeric,
|
||||
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END,
|
||||
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END,
|
||||
$7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17,
|
||||
$18, 'PENDING'
|
||||
) RETURNING HNS_ANLYS_SN`,
|
||||
[
|
||||
input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
||||
input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
||||
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
||||
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
||||
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
getIncidentWeather,
|
||||
saveIncidentWeather,
|
||||
getIncidentMedia,
|
||||
getIncidentImageAnalysis,
|
||||
} from './incidentsService.js';
|
||||
|
||||
const router = Router();
|
||||
@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
|
||||
// ============================================================
|
||||
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10);
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const data = await getIncidentImageAnalysis(sn);
|
||||
if (!data) {
|
||||
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
|
||||
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -24,7 +24,11 @@ interface IncidentListItem {
|
||||
spilQty: number | null;
|
||||
spilUnitCd: string | null;
|
||||
fcstHr: number | null;
|
||||
hasPredCompleted: boolean;
|
||||
hasHnsCompleted: boolean;
|
||||
hasRescueCompleted: boolean;
|
||||
mediaCnt: number;
|
||||
hasImgAnalysis: boolean;
|
||||
}
|
||||
|
||||
interface PredExecItem {
|
||||
@ -111,11 +115,29 @@ export async function listIncidents(filters: {
|
||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.PRED_EXEC pe
|
||||
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||
) AS has_pred_completed,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||
AND h.USE_YN = 'Y'
|
||||
) AS has_hns_completed,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.RESCUE_OPS r
|
||||
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||
AND r.STTS_CD = 'RESOLVED'
|
||||
AND r.USE_YN = 'Y'
|
||||
) AS has_rescue_completed,
|
||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||
FROM wing.ACDNT a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||
FROM wing.SPIL_DATA
|
||||
WHERE ACDNT_SN = a.ACDNT_SN
|
||||
ORDER BY SPIL_DATA_SN
|
||||
@ -148,7 +170,11 @@ export async function listIncidents(filters: {
|
||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||
fcstHr: (r.fcst_hr as number) ?? null,
|
||||
hasPredCompleted: r.has_pred_completed as boolean,
|
||||
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||
mediaCnt: Number(r.media_cnt),
|
||||
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -162,11 +188,29 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.PRED_EXEC pe
|
||||
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||
) AS has_pred_completed,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||
AND h.USE_YN = 'Y'
|
||||
) AS has_hns_completed,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wing.RESCUE_OPS r
|
||||
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||
AND r.STTS_CD = 'RESOLVED'
|
||||
AND r.USE_YN = 'Y'
|
||||
) AS has_rescue_completed,
|
||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||
FROM wing.ACDNT a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||
FROM wing.SPIL_DATA
|
||||
WHERE ACDNT_SN = a.ACDNT_SN
|
||||
ORDER BY SPIL_DATA_SN
|
||||
@ -205,7 +249,11 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||
fcstHr: (r.fcst_hr as number) ?? null,
|
||||
hasPredCompleted: r.has_pred_completed as boolean,
|
||||
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||
mediaCnt: Number(r.media_cnt),
|
||||
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||
predictions,
|
||||
weather,
|
||||
media,
|
||||
@ -419,3 +467,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
|
||||
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 이미지 분석 데이터 조회
|
||||
// ============================================================
|
||||
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
|
||||
const sql = `
|
||||
SELECT IMG_RSLT_DATA
|
||||
FROM wing.SPIL_DATA
|
||||
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
|
||||
ORDER BY SPIL_DATA_SN
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { getBacktrack } from './predictionService.js';
|
||||
|
||||
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003';
|
||||
const VESSEL_TRACK_API_URL = process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||
const VESSEL_TRACK_COOKIE = process.env.VESSEL_TRACK_COOKIE ?? '';
|
||||
|
||||
// 유종 코드(DB) → OpenDrift 유종 코드 매핑
|
||||
const OIL_TYPE_MAP: Record<string, string> = {
|
||||
@ -13,8 +14,6 @@ const OIL_TYPE_MAP: Record<string, string> = {
|
||||
'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)',
|
||||
'GASOLINE': 'GENERIC GASOLINE',
|
||||
};
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const POLL_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
// AIS 선박유형 코드 → 위험도 점수 매핑
|
||||
// AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선
|
||||
@ -133,25 +132,6 @@ function toTimeLabel(d: Date): string {
|
||||
return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Python 결과 폴링 (DONE까지 대기)
|
||||
async function pollUntilDone(jobId: string): Promise<PythonTimeStep[]> {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
try {
|
||||
const res = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json() as { status: string; result?: PythonTimeStep[]; error?: string };
|
||||
if (data.status === 'DONE' && data.result) return data.result;
|
||||
if (data.status === 'ERROR') throw new Error(data.error ?? 'Python 분석 오류');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message !== 'fetch failed') throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('역추적 분석 시간 초과 (30분)');
|
||||
}
|
||||
|
||||
// 파티클 스텝별 탐색 영역 계산
|
||||
function computeParticleSteps(
|
||||
@ -286,16 +266,29 @@ async function fetchVesselTracks(
|
||||
polygons: [polygon],
|
||||
};
|
||||
|
||||
console.log(body);
|
||||
console.log('[backtrack] VESSEL_TRACK_API 호출', {
|
||||
url: `${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`,
|
||||
startTime: body.startTime,
|
||||
endTime: body.endTime,
|
||||
srchRadiusNm,
|
||||
polygons: body.polygons.length,
|
||||
hasCookie: !!VESSEL_TRACK_COOKIE,
|
||||
});
|
||||
|
||||
const res = await fetch(`${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(VESSEL_TRACK_COOKIE ? { 'Cookie': VESSEL_TRACK_COOKIE } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log('[backtrack] VESSEL_TRACK_API 응답', { status: res.status, ok: res.ok });
|
||||
if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`);
|
||||
return res.json() as Promise<VesselTrackApiResponse>;
|
||||
const data = await res.json() as VesselTrackApiResponse;
|
||||
console.log('[backtrack] VESSEL_TRACK_API 데이터', { totalVessels: data.summary?.totalVessels ?? 0, tracks: data.tracks?.length ?? 0 });
|
||||
return data;
|
||||
}
|
||||
|
||||
// 스코어링 + 순위 (선박 항적 API 응답 기반)
|
||||
@ -473,6 +466,7 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
||||
}
|
||||
|
||||
// Python 역방향 시뮬레이션 실행 (파티클 시각화용)
|
||||
console.log('[backtrack] Python 역방향 시뮬레이션 요청', { lat: bt.lat, lon: bt.lon, anlysHours, matVol, matTy, spillTime: spillTime.toISOString() });
|
||||
let rawResult: PythonTimeStep[];
|
||||
try {
|
||||
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, {
|
||||
@ -490,8 +484,9 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`);
|
||||
const { job_id } = await pythonRes.json() as { job_id: string };
|
||||
rawResult = await pollUntilDone(job_id);
|
||||
const pythonData = await pythonRes.json() as { success: boolean; result: PythonTimeStep[] };
|
||||
rawResult = pythonData.result ?? [];
|
||||
console.log('[backtrack] Python 역방향 시뮬레이션 완료 — steps:', rawResult.length);
|
||||
} catch (pyErr) {
|
||||
// Python 미연동 시 폴백: 빈 파티클 스텝 생성
|
||||
console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr);
|
||||
@ -515,16 +510,19 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
||||
|
||||
// 선박 항적 API 호출
|
||||
const srchRadius = bt.srchRadiusNm ?? 10;
|
||||
console.log('[backtrack] 선박 항적 API 호출 시작', { srchRadius, analysisStart: analysisStart.toISOString(), spillTime: spillTime.toISOString() });
|
||||
const apiResponse = await fetchVesselTracks(
|
||||
bt.lat, bt.lon, srchRadius, analysisStart, spillTime,
|
||||
);
|
||||
|
||||
const totalVessels = apiResponse.summary.totalVessels;
|
||||
console.log('[backtrack] 선박 점수 계산 시작 — totalVessels:', totalVessels);
|
||||
const ranked = scoreAndRankVesselsFromApi(
|
||||
apiResponse.tracks,
|
||||
apiResponse.hitDetails,
|
||||
bt.lat, bt.lon, srchRadius, anlysHours,
|
||||
);
|
||||
console.log('[backtrack] 선박 점수 계산 완료 — ranked:', ranked.length, '/', totalVessels, '| top:', ranked[0]?.name, ranked[0]?.probability);
|
||||
const replayShips = buildReplayShipsFromApi(ranked);
|
||||
const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime);
|
||||
|
||||
@ -536,7 +534,15 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
||||
// vessels에서 내부 필드 제거
|
||||
const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v);
|
||||
|
||||
const rsltData = { vessels, replayShips, collisionEvent, timeRange };
|
||||
// rawResult 샘플링 (최대 24 스텝, 파티클은 [lon, lat] 형식으로 저장)
|
||||
const MAX_BACKWARD_STEPS = 24;
|
||||
const sampleRate = Math.max(1, Math.ceil(rawResult.length / MAX_BACKWARD_STEPS));
|
||||
const backwardParticles = rawResult
|
||||
.filter((_, i) => i % sampleRate === 0)
|
||||
.slice(0, MAX_BACKWARD_STEPS)
|
||||
.map(step => step.particles.map(p => [p.lon, p.lat] as [number, number]));
|
||||
|
||||
const rsltData = { vessels, replayShips, collisionEvent, timeRange, backwardParticles };
|
||||
|
||||
await wingPool.query(
|
||||
`UPDATE wing.BACKTRACK
|
||||
|
||||
@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
|
||||
return { lat, lon, occurredAt };
|
||||
}
|
||||
|
||||
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
|
||||
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
|
||||
const fileId = crypto.randomUUID();
|
||||
|
||||
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
||||
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
||||
const volume = firstOil?.volume ?? 0;
|
||||
|
||||
// ACDNT INSERT
|
||||
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
||||
const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
||||
const acdntRes = await wingPool.query(
|
||||
`INSERT INTO wing.ACDNT
|
||||
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
||||
await wingPool.query(
|
||||
`INSERT INTO wing.SPIL_DATA
|
||||
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
||||
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
|
||||
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
|
||||
[
|
||||
acdntSn,
|
||||
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||
getOilSpillSummary,
|
||||
} from './predictionService.js';
|
||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||
import { isValidNumber } from '../middleware/security.js';
|
||||
@ -70,6 +71,27 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
|
||||
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||
return;
|
||||
}
|
||||
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
||||
const result = await getOilSpillSummary(acdntSn, predRunSn);
|
||||
if (!result) {
|
||||
res.json({ primary: null, byModel: {} });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[prediction] oil-summary 조회 오류:', err);
|
||||
res.status(500).json({ error: 'oil-summary 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
@ -230,7 +252,8 @@ router.post(
|
||||
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
||||
return;
|
||||
}
|
||||
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
|
||||
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
|
||||
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
|
||||
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
interface PredictionAnalysis {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
@ -812,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||
regDtm: String(r['reg_dtm'] ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 유출유 확산 요약 (통합조회 분할 패널용) ──────────────
|
||||
export interface OilSpillSummary {
|
||||
model: string;
|
||||
forecastDurationHr: number | null;
|
||||
maxSpreadDistanceKm: number | null;
|
||||
coastArrivalTimeHr: number | null;
|
||||
affectedCoastlineKm: number | null;
|
||||
weatheringRatePct: number | null;
|
||||
remainingVolumeKl: number | null;
|
||||
}
|
||||
|
||||
export interface OilSpillSummaryResponse {
|
||||
primary: OilSpillSummary;
|
||||
byModel: Record<string, OilSpillSummary>;
|
||||
}
|
||||
|
||||
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
|
||||
const baseSql = `
|
||||
SELECT pe.ALGO_CD, pe.RSLT_DATA,
|
||||
sd.FCST_HR,
|
||||
ST_Y(a.LOC_GEOM) AS spil_lat,
|
||||
ST_X(a.LOC_GEOM) AS spil_lon
|
||||
FROM wing.PRED_EXEC pe
|
||||
LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN
|
||||
LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN
|
||||
WHERE pe.ACDNT_SN = $1
|
||||
AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||
AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||
AND pe.RSLT_DATA IS NOT NULL
|
||||
`;
|
||||
const sql = predRunSn != null
|
||||
? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC'
|
||||
: baseSql + ' ORDER BY pe.CMPL_DTM DESC';
|
||||
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const byModel: Record<string, OilSpillSummary> = {};
|
||||
|
||||
// OpenDrift 우선, 없으면 POSEIDON
|
||||
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
||||
const primaryRow = opendriftRow ?? poseidonRow ?? null;
|
||||
|
||||
for (const row of rows as Array<Record<string, unknown>>) {
|
||||
const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null;
|
||||
if (!rsltData || rsltData.length === 0) continue;
|
||||
|
||||
const algoCd = String(row['algo_cd'] ?? '');
|
||||
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
|
||||
const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null;
|
||||
const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null;
|
||||
const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null;
|
||||
const totalSteps = rsltData.length;
|
||||
const lastStep = rsltData[totalSteps - 1];
|
||||
|
||||
// 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용
|
||||
let maxDist: number | null = null;
|
||||
const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null;
|
||||
const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null;
|
||||
if (originLat != null && originLon != null) {
|
||||
let maxVal = 0;
|
||||
for (const step of rsltData) {
|
||||
for (const p of step.particles) {
|
||||
const d = haversineKm(originLat, originLon, p.lat, p.lon);
|
||||
if (d > maxVal) maxVal = d;
|
||||
}
|
||||
}
|
||||
maxDist = maxVal;
|
||||
}
|
||||
|
||||
// 해안 도달 시간 (stranded===1 최초 등장 step)
|
||||
let coastArrivalHr: number | null = null;
|
||||
for (let i = 0; i < totalSteps; i++) {
|
||||
if (rsltData[i].particles.some((p) => p.stranded === 1)) {
|
||||
coastArrivalHr = fcstHr != null && totalSteps > 1
|
||||
? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1))
|
||||
: i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 풍화율
|
||||
const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3;
|
||||
const weatheringPct = totalVol > 0
|
||||
? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1))
|
||||
: null;
|
||||
|
||||
byModel[modelName] = {
|
||||
model: modelName,
|
||||
forecastDurationHr: fcstHr,
|
||||
maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null,
|
||||
coastArrivalTimeHr: coastArrivalHr,
|
||||
affectedCoastlineKm: lastStep.pollution_coast_length_m != null
|
||||
? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1))
|
||||
: null,
|
||||
weatheringRatePct: weatheringPct,
|
||||
remainingVolumeKl: lastStep.remaining_volume_m3 != null
|
||||
? parseFloat(lastStep.remaining_volume_m3.toFixed(1))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!primaryRow) return null;
|
||||
const primaryAlgo = String(primaryRow['algo_cd'] ?? '');
|
||||
const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo;
|
||||
|
||||
return {
|
||||
primary: byModel[primaryModel] ?? Object.values(byModel)[0],
|
||||
byModel,
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,11 +10,13 @@ const router = express.Router();
|
||||
// ============================================================
|
||||
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { sttsCd, acdntTpCd, search } = req.query;
|
||||
const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
|
||||
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
|
||||
const items = await listOps({
|
||||
sttsCd: sttsCd as string | undefined,
|
||||
acdntTpCd: acdntTpCd as string | undefined,
|
||||
search: search as string | undefined,
|
||||
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||
});
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
|
||||
@ -59,6 +59,7 @@ interface ListOpsInput {
|
||||
sttsCd?: string;
|
||||
acdntTpCd?: string;
|
||||
search?: string;
|
||||
acdntSn?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]
|
||||
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
||||
params.push(input.search);
|
||||
}
|
||||
if (input?.acdntSn != null) {
|
||||
conditions.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
|
||||
@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
||||
const OIL_TYPE_MAP: Record<string, string> = {
|
||||
'벙커C유': 'GENERIC BUNKER C',
|
||||
'경유': 'GENERIC DIESEL',
|
||||
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
|
||||
'원유': 'WEST TEXAS INTERMEDIATE',
|
||||
'중유': 'GENERIC HEAVY FUEL OIL',
|
||||
'등유': 'FUEL OIL NO.1 (KEROSENE)',
|
||||
'등유': 'FUEL OIL NO.1 (KEROSENE) ',
|
||||
'휘발유': 'GENERIC GASOLINE',
|
||||
}
|
||||
|
||||
|
||||
128
backend/src/routes/tiles.ts
Normal file
128
backend/src/routes/tiles.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const VWORLD_API_KEY = process.env.VWORLD_API_KEY || '';
|
||||
const ENC_UPSTREAM = 'https://tiles.gcnautical.com';
|
||||
|
||||
// ─── 공통 프록시 헬퍼 ───
|
||||
|
||||
async function proxyUpstream(upstreamUrl: string, res: import('express').Response, fallbackContentType = 'application/octet-stream') {
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || fallbackContentType;
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', cacheControl);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
res.end(Buffer.from(buffer));
|
||||
} catch {
|
||||
res.status(502).json({ error: '타일 서버 연결 실패' });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VWorld 위성타일 ───
|
||||
|
||||
// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회)
|
||||
// VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계
|
||||
router.get('/vworld/:z/:y/:x', async (req, res) => {
|
||||
const { z, y } = req.params;
|
||||
const x = req.params.x.replace(/\.jpeg$/i, '');
|
||||
|
||||
// z/y/x 정수 검증 (SSRF 방지)
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(y) || !/^\d+$/.test(x)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VWORLD_API_KEY) {
|
||||
res.status(503).json({ error: 'VWorld API 키가 설정되지 않았습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`;
|
||||
await proxyUpstream(tileUrl, res, 'image/jpeg');
|
||||
});
|
||||
|
||||
// ─── SR 민감자원 벡터타일 ───
|
||||
|
||||
// GET /api/tiles/sr/tilejson — SR TileJSON 프록시 (source-layer 메타데이터)
|
||||
router.get('/sr/tilejson', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sr`, res, 'application/json');
|
||||
});
|
||||
|
||||
// GET /api/tiles/sr/style — SR 스타일 JSON 프록시 (레이어별 type/paint/layout 정의)
|
||||
router.get('/sr/style', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/style/sr`, res, 'application/json');
|
||||
});
|
||||
|
||||
// GET /api/tiles/sr/:z/:x/:y — SR(민감자원) 벡터타일 프록시
|
||||
router.get('/sr/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sr/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// ─── S-57 전자해도 (ENC) ───
|
||||
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||
|
||||
// GET /api/tiles/enc/style — 공식 style.json 프록시
|
||||
router.get('/enc/style', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json');
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/sprite/:file — sprite JSON/PNG 프록시 (sprite.json, sprite.png, sprite@2x.json, sprite@2x.png)
|
||||
router.get('/enc/sprite/:file', async (req, res) => {
|
||||
const { file } = req.params;
|
||||
if (!/^sprite(@2x)?\.(json|png)$/.test(file)) {
|
||||
res.status(400).json({ error: '잘못된 sprite 파일명' });
|
||||
return;
|
||||
}
|
||||
const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json';
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt);
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시
|
||||
router.get('/enc/font/:fontstack/:range', async (req, res) => {
|
||||
const { fontstack, range } = req.params;
|
||||
if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) {
|
||||
res.status(400).json({ error: '잘못된 폰트 요청' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/font/${fontstack}/${range}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/globe/:z/:x/:y — globe 벡터타일 프록시 (저줌 레벨용)
|
||||
router.get('/enc/globe/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/globe/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/:z/:x/:y — ENC 벡터타일 프록시 (표준 XYZ 순서)
|
||||
router.get('/enc/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/enc/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser'
|
||||
import { testWingDbConnection } from './db/wingDb.js'
|
||||
import layersRouter from './routes/layers.js'
|
||||
import simulationRouter from './routes/simulation.js'
|
||||
import tilesRouter from './routes/tiles.js'
|
||||
import authRouter from './auth/authRouter.js'
|
||||
import userRouter from './users/userRouter.js'
|
||||
import roleRouter from './roles/roleRouter.js'
|
||||
@ -18,12 +19,15 @@ import hnsRouter from './hns/hnsRouter.js'
|
||||
import reportsRouter from './reports/reportsRouter.js'
|
||||
import assetsRouter from './assets/assetsRouter.js'
|
||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||
import scatRouter from './scat/scatRouter.js'
|
||||
import predictionRouter from './prediction/predictionRouter.js'
|
||||
import aerialRouter from './aerial/aerialRouter.js'
|
||||
import rescueRouter from './rescue/rescueRouter.js'
|
||||
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||
import monitorRouter from './monitor/monitorRouter.js'
|
||||
import vesselRouter from './vessels/vesselRouter.js'
|
||||
import { startVesselScheduler } from './vessels/vesselScheduler.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -105,7 +109,8 @@ const generalLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
skip: (req) => {
|
||||
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
||||
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
|
||||
return req.path.startsWith('/api/aerial/cctv/stream-proxy') ||
|
||||
req.path.startsWith('/api/tiles/');
|
||||
},
|
||||
message: {
|
||||
error: '요청 횟수 초과',
|
||||
@ -166,12 +171,15 @@ app.use('/api/hns', hnsRouter)
|
||||
app.use('/api/reports', reportsRouter)
|
||||
app.use('/api/assets', assetsRouter)
|
||||
app.use('/api/incidents', incidentsRouter)
|
||||
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||
app.use('/api/scat', scatRouter)
|
||||
app.use('/api/prediction', predictionRouter)
|
||||
app.use('/api/aerial', aerialRouter)
|
||||
app.use('/api/rescue', rescueRouter)
|
||||
app.use('/api/map-base', mapBaseRouter)
|
||||
app.use('/api/monitor', monitorRouter)
|
||||
app.use('/api/tiles', tilesRouter)
|
||||
app.use('/api/vessels', vesselRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
@ -207,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||
|
||||
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
|
||||
startVesselScheduler()
|
||||
|
||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||
const connected = await testWingDbConnection()
|
||||
if (connected) {
|
||||
|
||||
33
backend/src/vessels/vesselRouter.ts
Normal file
33
backend/src/vessels/vesselRouter.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Router } from 'express';
|
||||
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
|
||||
import type { BoundingBox } from './vesselTypes.js';
|
||||
|
||||
const vesselRouter = Router();
|
||||
|
||||
// POST /api/vessels/in-area
|
||||
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
|
||||
vesselRouter.post('/in-area', (req, res) => {
|
||||
const { bounds } = req.body as { bounds?: BoundingBox };
|
||||
|
||||
if (
|
||||
!bounds ||
|
||||
typeof bounds.minLon !== 'number' ||
|
||||
typeof bounds.minLat !== 'number' ||
|
||||
typeof bounds.maxLon !== 'number' ||
|
||||
typeof bounds.maxLat !== 'number'
|
||||
) {
|
||||
res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const vessels = getVesselsInBounds(bounds);
|
||||
res.json(vessels);
|
||||
});
|
||||
|
||||
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
|
||||
vesselRouter.get('/status', (_req, res) => {
|
||||
const status = getCacheStatus();
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
export default vesselRouter;
|
||||
96
backend/src/vessels/vesselScheduler.ts
Normal file
96
backend/src/vessels/vesselScheduler.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { updateVesselCache } from './vesselService.js';
|
||||
import type { VesselPosition } from './vesselTypes.js';
|
||||
|
||||
const VESSEL_TRACK_API_URL =
|
||||
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
|
||||
function buildVesselCookie(): string {
|
||||
const entries: [string, string | undefined][] = [
|
||||
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
|
||||
['g_state', process.env.VESSEL_COOKIE_G_STATE],
|
||||
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
|
||||
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
|
||||
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
|
||||
];
|
||||
const parts = entries
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k}=${v}`);
|
||||
|
||||
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
|
||||
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
|
||||
return process.env.VESSEL_TRACK_COOKIE;
|
||||
}
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
|
||||
const KOREA_WATERS_POLYGON = [
|
||||
[120, 31],
|
||||
[132, 31],
|
||||
[132, 41],
|
||||
[120, 41],
|
||||
[120, 31],
|
||||
];
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function pollVesselSignals(): Promise<void> {
|
||||
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
|
||||
const body = {
|
||||
minutes: 5,
|
||||
coordinates: KOREA_WATERS_POLYGON,
|
||||
polygonFilter: true,
|
||||
};
|
||||
|
||||
const cookie = buildVesselCookie();
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as VesselPosition[];
|
||||
updateVesselCache(data);
|
||||
} catch (err) {
|
||||
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function startVesselScheduler(): void {
|
||||
if (intervalId !== null) return;
|
||||
|
||||
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
|
||||
pollVesselSignals();
|
||||
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
|
||||
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
|
||||
}
|
||||
|
||||
export function stopVesselScheduler(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
|
||||
}
|
||||
}
|
||||
55
backend/src/vessels/vesselService.ts
Normal file
55
backend/src/vessels/vesselService.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
|
||||
|
||||
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
|
||||
|
||||
const cachedVessels = new Map<string, VesselPosition>();
|
||||
let lastUpdated: Date | null = null;
|
||||
|
||||
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
|
||||
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
|
||||
function evictStale(): void {
|
||||
const now = Date.now();
|
||||
for (const [mmsi, vessel] of cachedVessels) {
|
||||
const ts = Date.parse(vessel.lastUpdate);
|
||||
if (Number.isNaN(ts)) continue;
|
||||
if (now - ts > VESSEL_TTL_MS) {
|
||||
cachedVessels.delete(mmsi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateVesselCache(vessels: VesselPosition[]): void {
|
||||
for (const vessel of vessels) {
|
||||
if (!vessel.mmsi) continue;
|
||||
cachedVessels.set(vessel.mmsi, vessel);
|
||||
}
|
||||
evictStale();
|
||||
lastUpdated = new Date();
|
||||
}
|
||||
|
||||
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
|
||||
const result: VesselPosition[] = [];
|
||||
for (const v of cachedVessels.values()) {
|
||||
if (
|
||||
v.lon >= bounds.minLon &&
|
||||
v.lon <= bounds.maxLon &&
|
||||
v.lat >= bounds.minLat &&
|
||||
v.lat <= bounds.maxLat
|
||||
) {
|
||||
result.push(v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getCacheStatus(): {
|
||||
count: number;
|
||||
bangjeCount: number;
|
||||
lastUpdated: Date | null;
|
||||
} {
|
||||
let bangjeCount = 0;
|
||||
for (const v of cachedVessels.values()) {
|
||||
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
|
||||
}
|
||||
return { count: cachedVessels.size, bangjeCount, lastUpdated };
|
||||
}
|
||||
26
backend/src/vessels/vesselTypes.ts
Normal file
26
backend/src/vessels/vesselTypes.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export interface VesselPosition {
|
||||
mmsi: string;
|
||||
imo?: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
sog?: number;
|
||||
cog?: number;
|
||||
heading?: number;
|
||||
shipNm?: string;
|
||||
shipTy?: string;
|
||||
shipKindCode?: string;
|
||||
nationalCode?: string;
|
||||
lastUpdate: string;
|
||||
status?: string;
|
||||
destination?: string;
|
||||
length?: number;
|
||||
width?: number;
|
||||
draught?: number;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
minLon: number;
|
||||
minLat: number;
|
||||
maxLon: number;
|
||||
maxLat: number;
|
||||
}
|
||||
@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
|
||||
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
||||
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
||||
SPIL_QTY NUMERIC(12,2), -- 유출량
|
||||
SPIL_QTY NUMERIC(14,10), -- 유출량
|
||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
||||
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
||||
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
||||
|
||||
@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
|
||||
SPIL_DATA_SN SERIAL NOT NULL,
|
||||
ACDNT_SN INTEGER NOT NULL,
|
||||
OIL_TP_CD VARCHAR(50) NOT NULL,
|
||||
SPIL_QTY NUMERIC(12,2),
|
||||
SPIL_QTY NUMERIC(14,10),
|
||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||
SPIL_TP_CD VARCHAR(20),
|
||||
FCST_HR INTEGER,
|
||||
|
||||
@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
|
||||
SBST_NM VARCHAR(100),
|
||||
UN_NO VARCHAR(10),
|
||||
CAS_NO VARCHAR(20),
|
||||
SPIL_QTY NUMERIC(10,2),
|
||||
SPIL_QTY NUMERIC(14,10),
|
||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||
SPIL_TP_CD VARCHAR(20),
|
||||
FCST_HR INTEGER,
|
||||
|
||||
@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
|
||||
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
|
||||
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
|
||||
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
|
||||
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
|
||||
-- - 유출 모델링: 파공부 유출률 변화
|
||||
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
|
||||
-- ============================================================
|
||||
INSERT INTO RESCUE_SCENARIO (
|
||||
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
||||
) VALUES
|
||||
-- S-01: 사고 발생 (Initial Impact)
|
||||
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
|
||||
(
|
||||
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]',
|
||||
1
|
||||
),
|
||||
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
|
||||
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
|
||||
(
|
||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
|
||||
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
|
||||
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
|
||||
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
|
||||
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
|
||||
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
|
||||
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||
2
|
||||
),
|
||||
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
|
||||
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
|
||||
(
|
||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
|
||||
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
|
||||
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
|
||||
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
|
||||
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]',
|
||||
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
|
||||
3
|
||||
),
|
||||
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
|
||||
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
|
||||
(
|
||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
|
||||
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
|
||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
|
||||
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
|
||||
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]',
|
||||
'[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]',
|
||||
4
|
||||
),
|
||||
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
|
||||
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
|
||||
(
|
||||
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
|
||||
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
|
||||
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]',
|
||||
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
|
||||
5
|
||||
),
|
||||
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
|
||||
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
|
||||
(
|
||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
|
||||
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
|
||||
'[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]',
|
||||
6
|
||||
),
|
||||
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
|
||||
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
|
||||
(
|
||||
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
|
||||
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
|
||||
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]',
|
||||
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
|
||||
7
|
||||
),
|
||||
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
|
||||
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
|
||||
(
|
||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
|
||||
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]',
|
||||
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
|
||||
8
|
||||
),
|
||||
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
|
||||
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
|
||||
(
|
||||
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
|
||||
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
|
||||
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]',
|
||||
'[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]',
|
||||
9
|
||||
),
|
||||
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
|
||||
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
|
||||
(
|
||||
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
||||
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
||||
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
|
||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
|
||||
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
|
||||
5
|
||||
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
|
||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
|
||||
'[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]',
|
||||
10
|
||||
);
|
||||
|
||||
7
database/migration/031_spil_qty_precision.sql
Normal file
7
database/migration/031_spil_qty_precision.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
|
||||
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
|
||||
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
|
||||
-- 정수부 최대 4자리, 소수부 10자리
|
||||
|
||||
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
@ -0,0 +1,118 @@
|
||||
-- ============================================================
|
||||
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
|
||||
-- ------------------------------------------------------------
|
||||
-- 목적
|
||||
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
|
||||
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
|
||||
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
|
||||
--
|
||||
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
|
||||
-- - acdnt_asort_code IN (12개 코드)
|
||||
-- - acdnt_title IS NOT NULL
|
||||
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
|
||||
-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건)
|
||||
--
|
||||
-- ACDNT_CD 생성 규칙
|
||||
-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리)
|
||||
-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을
|
||||
-- 구해 이어서 증가시킨다.
|
||||
--
|
||||
-- 중복 방지
|
||||
-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외.
|
||||
-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용.
|
||||
--
|
||||
-- ACDNT_TP_CD
|
||||
-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code)
|
||||
-- 매핑 누락 시 원본 코드값으로 폴백.
|
||||
--
|
||||
-- 사전 확인 쿼리 (실행 전 참고)
|
||||
-- SELECT COUNT(DISTINCT a.acdnt_mng_no)
|
||||
-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no)
|
||||
-- WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||
-- '055001001','055001002','055001003','055001004','055001005','055001006',
|
||||
-- '055003001','055003002','055003003','055003004','055003005','055004003'
|
||||
-- ]::varchar[])
|
||||
-- AND a.acdnt_title IS NOT NULL
|
||||
-- AND a.rcept_dt >= '2026-04-10';
|
||||
-- ============================================================
|
||||
|
||||
WITH src AS (
|
||||
SELECT DISTINCT ON (a.acdnt_mng_no)
|
||||
a.acdnt_mng_no,
|
||||
a.acdnt_title,
|
||||
a.acdnt_asort_code,
|
||||
a.rcept_dt,
|
||||
b.la,
|
||||
b.lo
|
||||
FROM gsc.tgs_acdnt_info AS a
|
||||
JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no
|
||||
WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||
'055001001','055001002','055001003','055001004','055001005','055001006',
|
||||
'055003001','055003002','055003003','055003004','055003005','055004003'
|
||||
]::varchar[])
|
||||
AND a.acdnt_title IS NOT NULL
|
||||
AND a.rcept_dt >= '2026-04-10'::timestamptz
|
||||
AND b.la IS NOT NULL AND b.lo IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM wing.ACDNT w
|
||||
WHERE w.ACDNT_NM = a.acdnt_title
|
||||
AND w.OCCRN_DTM = a.rcept_dt
|
||||
)
|
||||
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
src.*,
|
||||
EXTRACT(YEAR FROM src.rcept_dt)::int AS yr,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY EXTRACT(YEAR FROM src.rcept_dt)
|
||||
ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC
|
||||
) AS rn_in_year
|
||||
FROM src
|
||||
),
|
||||
year_max AS (
|
||||
SELECT
|
||||
(split_part(ACDNT_CD, '-', 2))::int AS yr,
|
||||
MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq
|
||||
FROM wing.ACDNT
|
||||
WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$'
|
||||
GROUP BY split_part(ACDNT_CD, '-', 2)
|
||||
)
|
||||
INSERT INTO wing.ACDNT (
|
||||
ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD,
|
||||
LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM
|
||||
)
|
||||
SELECT
|
||||
'INC-' || lpad(n.yr::text, 4, '0') || '-' ||
|
||||
lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD,
|
||||
n.acdnt_title AS ACDNT_NM,
|
||||
COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD,
|
||||
'ACTIVE' AS ACDNT_STTS_CD,
|
||||
n.la::numeric AS LAT,
|
||||
n.lo::numeric AS LNG,
|
||||
ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM,
|
||||
NULL AS LOC_DC,
|
||||
n.rcept_dt AS OCCRN_DTM,
|
||||
NOW(), NOW()
|
||||
FROM numbered n
|
||||
LEFT JOIN year_max ym ON ym.yr = n.yr
|
||||
LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code
|
||||
ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC;
|
||||
|
||||
-- ============================================================
|
||||
-- 사후 검증 (필요 시 주석 해제 실행)
|
||||
-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10';
|
||||
--
|
||||
-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM
|
||||
-- FROM wing.ACDNT
|
||||
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||
-- ORDER BY ACDNT_CD DESC
|
||||
-- LIMIT 20;
|
||||
--
|
||||
-- SELECT ACDNT_TP_CD, COUNT(*)
|
||||
-- FROM wing.ACDNT
|
||||
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||
-- GROUP BY 1
|
||||
-- ORDER BY 2 DESC;
|
||||
-- ============================================================
|
||||
@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
||||
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
||||
|
||||
```typescript
|
||||
// frontend/src/tabs/board/services/boardApi.ts
|
||||
// frontend/src/components/board/services/boardApi.ts
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// 인터페이스 정의
|
||||
@ -490,7 +490,7 @@ interface MenuConfigItem {
|
||||
|
||||
```typescript
|
||||
// frontend/src/common/store/newStore.ts (공통) 또는
|
||||
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
|
||||
// frontend/src/components/{탭}/store/newStore.ts (탭 전용)
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface MyState {
|
||||
@ -514,7 +514,7 @@ export const useMyStore = create<MyState>((set) => ({
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';
|
||||
import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi';
|
||||
|
||||
// 조회 (캐싱 + 자동 리페치)
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
||||
### 파일 위치
|
||||
|
||||
```
|
||||
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
||||
frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||
```
|
||||
|
||||
### 작성 패턴
|
||||
|
||||
```typescript
|
||||
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
||||
// frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// ============================================================
|
||||
|
||||
@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
|
||||
### 파일 위치
|
||||
|
||||
```
|
||||
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
||||
frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||
```
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```ts
|
||||
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
||||
// frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
@ -1376,7 +1376,7 @@ export default router;
|
||||
### 4단계: 프론트엔드 API 서비스
|
||||
|
||||
```ts
|
||||
// frontend/src/tabs/assets/services/equipmentApi.ts
|
||||
// frontend/src/components/assets/services/equipmentApi.ts
|
||||
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
|
||||
@ -3,24 +3,216 @@
|
||||
## 개요
|
||||
|
||||
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
|
||||
Google Stitch MCP로 생성된 스크린을 기반으로 일관된 UI 구현을 유도한다.
|
||||
시맨틱 토큰 기반 다크/라이트 테마 전환을 지원한다.
|
||||
|
||||
## Stitch 프로젝트
|
||||
---
|
||||
|
||||
- **프로젝트명**: WING-OPS Design System v1
|
||||
- **프로젝트 ID**: `5453076280291618640`
|
||||
## 버전 히스토리
|
||||
|
||||
## 스크린 목록
|
||||
### v1.1 — 시맨틱 토큰 & 테마 시스템 (2026-03-28~)
|
||||
|
||||
| # | 스크린 | Screen ID | 용도 |
|
||||
|---|--------|-----------|------|
|
||||
| 1 | Design Tokens | `ce520225d85c4c38b2024e93ec6a4fb2` | 색상, 타이포그래피, 간격, 라운딩 토큰 |
|
||||
| 2 | Component Catalog (Buttons/Badges) | `42fa9cf1a3d341a7972a1bc10ba00a8c` | 버튼 variant, 뱃지, 아이콘 버튼 |
|
||||
| 3 | Form Components | `7331ad8a598f4cc59f62a14226c1d023` | 입력, 선택, 날짜, 토글, 폼 레이아웃 |
|
||||
| 4 | Table & List Patterns | `5967382c70f9422ba3a0f4da79922ecf` | 데이터 테이블, 사이드바 리스트, 페이지네이션 |
|
||||
| 5 | Modal Catalog | `440be91f8db7423cbb5cc89e6dd6f9ca` | 모달 3사이즈, 확인 다이얼로그, 폼 모달 |
|
||||
| 6 | Operational Shell (Layout) | `86fd57c9f3c749d288f6270838a9387d` | TopBar, SubMenu, 3컬럼 레이아웃 |
|
||||
| 7 | Container & Navigation | `201c2c0c47b74fcfb3427d029319fa9d` | 카드, 섹션, 탭바, KV 행, 헤더바 |
|
||||
> 브랜치: `feature/predict-develop`, `feature/design-system-font`
|
||||
|
||||
v1.0의 축약형 토큰 시스템을 시맨틱 네이밍으로 전면 전환하고, 다크/라이트 테마 전환 기능을 도입한 구조적 리팩토링.
|
||||
|
||||
#### 변경된 점
|
||||
|
||||
| 항목 | v1.0 | v1.1 |
|
||||
|------|------|------|
|
||||
| 토큰 네이밍 | 축약형 (`--bg0`, `--t1`, `--cyan`) | 시맨틱 (`--bg-base`, `--fg-default`, `--color-accent`) |
|
||||
| 폰트 | Outfit + Noto Sans KR + JetBrains Mono (3종) | PretendardGOV 단일 폰트 (4웨이트) |
|
||||
| 테마 | 다크 모드 전용 (하드코딩) | 다크/라이트 전환 지원 (`data-theme` 속성) |
|
||||
| 프리미티브 팔레트 | 7그룹 11단계 (Navy/Cyan/Blue/Red/Green/Orange/Yellow, 00~100) | 6그룹 10단계 (Gray/Blue/Green/Yellow/Red/Purple, 100~1000) |
|
||||
| 텍스트 대비 | `--t2: #b0b8cc`, `--t3: #8690a6` | `--fg-sub: #c0c8dc`, `--fg-disabled: #9ba3b8` (대비 향상) |
|
||||
| 버튼 스타일 | `.prd-btn.pri` 그라데이션 (cyan→blue) | 아웃라인/고스트 스타일 |
|
||||
| Tailwind 컬러 키 | 하드코딩 hex (`bg.0`, `text.1`, `primary.cyan`) | CSS 변수 참조 (`bg.base`, `fg.DEFAULT`, `color.accent`) |
|
||||
| 폰트 유틸리티 | 하드코딩 (`font-family: 'JetBrains Mono'`) | CSS 변수 경유 (`font-family: var(--font-mono)`) |
|
||||
|
||||
#### 토큰 마이그레이션 매핑
|
||||
|
||||
| v1.0 | v1.1 | 설명 |
|
||||
|------|------|------|
|
||||
| `--bg0` | `--bg-base` | 페이지 배경 |
|
||||
| `--bg1` | `--bg-surface` | 사이드바, 패널 |
|
||||
| `--bg2` | `--bg-elevated` | 테이블 헤더, 상위 요소 |
|
||||
| `--bg3` | `--bg-card` | 카드 배경 |
|
||||
| `--bgH` | `--bg-surface-hover` | 호버 상태 |
|
||||
| `--bd` | `--stroke-default` | 기본 구분선 |
|
||||
| `--bdL` | `--stroke-light` | 연한 구분선 |
|
||||
| `--t1` | `--fg-default` | 기본 텍스트 |
|
||||
| `--t2` | `--fg-sub` | 보조 텍스트 |
|
||||
| `--t3` | `--fg-disabled` | 비활성 텍스트 |
|
||||
| `--cyan` | `--color-accent` | 주요 강조 |
|
||||
| `--blue` | `--color-info` | 정보, 링크 |
|
||||
| `--red` | `--color-danger` | 위험, 삭제 |
|
||||
| `--orange` | `--color-warning` | 주의 |
|
||||
| `--yellow` | `--color-caution` | 경고 |
|
||||
| `--green` | `--color-success` | 성공, 정상 |
|
||||
| `--purple` | `--color-tertiary` | 3차 강조 |
|
||||
| `--boom` | `--color-boom` | 오일붐 전용 |
|
||||
| `--fK` | `--font-korean` | 한국어 폰트 스택 |
|
||||
| `--fM` | `--font-mono` | 모노 폰트 스택 |
|
||||
| `--rS` | `--radius-sm` | 소형 border radius |
|
||||
| `--rM` | `--radius-md` | 중형 border radius |
|
||||
|
||||
#### 추가된 기능
|
||||
|
||||
- **다크/라이트 테마 전환**: `themeStore.ts` (Zustand) + `data-theme` DOM 속성 + FOUC 방지 인라인 스크립트
|
||||
- **시맨틱 오버레이 토큰**: `--hover-overlay`, `--dropdown-bg` (테마별 불투명도 차별화)
|
||||
- **Navy 액센트 토큰**: `--color-navy`, `--color-navy-hover`, `--color-accent-muted`
|
||||
- **정적 컬러 토큰**: `--static-black`, `--static-white` (테마 무관 고정값)
|
||||
- **타이포그래피 스케일 (17개 토큰)**: Display 3종, Heading 3종, Title 6종, Body 2종, Label 2종, Caption 1종
|
||||
- **Letter-spacing 토큰 5종**: `--letter-spacing-display` (0.06em) ~ `--letter-spacing-label` (0.04em)
|
||||
- **Font weight 토큰 4종**: thin(300) / regular(400) / medium(500) / bold(700)
|
||||
- **Line height 토큰 4종**: tight(1.3) / snug(1.4) / normal(1.5) / relaxed(1.6)
|
||||
- **Tailwind tracking 유틸리티**: `tracking-display`, `tracking-heading`, `tracking-body`, `tracking-navigation`, `tracking-label`
|
||||
- **라이트 테마 컴포넌트 오버라이드**: CCTV 팝업, 날짜피커, 타임라인, 콤보박스, 좌표 표시 등
|
||||
- **폰트 렌더링 최적화**: `-webkit-font-smoothing: antialiased`, `text-rendering: optimizeLegibility`
|
||||
|
||||
#### 업데이트된 부분
|
||||
|
||||
- 92개+ 컴포넌트 파일의 토큰 마이그레이션 (축약형 → 시맨틱)
|
||||
- Stitch MCP 프로젝트 참조 제거 (독립 토큰 시스템으로 전환)
|
||||
- DESIGN-SYSTEM.md 문서 전면 재작성
|
||||
|
||||
---
|
||||
|
||||
### v1.0 — 초기 디자인 시스템 (2026-03-24~25)
|
||||
|
||||
> 브랜치: `feature/stitch-mcp` | 도구: Google Stitch MCP
|
||||
|
||||
WING-OPS 전용 디자인 시스템의 첫 구축. CSS 변수 기반 토큰 시스템, `.wing-*` 컴포넌트 클래스, 라이브 카탈로그 뷰어를 도입.
|
||||
|
||||
#### 컬러 시스템
|
||||
|
||||
- **프리미티브 팔레트**: 7개 그룹 (Navy/Cyan/Blue/Red/Green/Orange/Yellow), 각 11단계 스케일 (00~100)
|
||||
- **시맨틱 컬러**: 축약형 CSS 변수 — Background(`--bg0`~`--bgH`), Text(`--t1`~`--t3`), Border(`--bd`, `--bdL`)
|
||||
- **액센트 컬러**: `--cyan`(#06b6d4), `--blue`(#3b82f6), `--red`, `--green`, `--orange`, `--yellow`, `--purple`
|
||||
- **특수 컬러**: `--boom`(#f59e0b) — 오일붐 전용 Amber
|
||||
|
||||
#### 타이포그래피
|
||||
|
||||
- **3종 폰트 패밀리**: Outfit (영문 본문), Noto Sans KR (한국어), JetBrains Mono (좌표/수치)
|
||||
- **10개 `.wing-*` 타이포 클래스**: `.wing-title`(15px) ~ `.wing-badge`(9px)
|
||||
|
||||
#### 컴포넌트 클래스
|
||||
|
||||
| 카테고리 | 클래스 |
|
||||
|----------|--------|
|
||||
| 레이아웃 셸 | `.wing-panel`, `.wing-panel-scroll`, `.wing-header-bar`, `.wing-sidebar` |
|
||||
| 컨테이너 | `.wing-card`, `.wing-card-sm`, `.wing-section` |
|
||||
| 버튼 | `.wing-btn`, `.wing-btn-primary` (cyan→blue 그라데이션), `-secondary`, `-outline`, `-pdf`, `-danger` |
|
||||
| 입력 | `.wing-input` (cyan 포커스 링) |
|
||||
| 테이블 | `.wing-table`, `.wing-th`, `.wing-td`, `.wing-tr-hover` |
|
||||
| 탭 | `.wing-tab-bar`, `.wing-tab` (cyan 틴트 + 보더 활성) |
|
||||
| 모달 | `.wing-overlay` (블러 백드롭), `.wing-modal`, `.wing-modal-header` |
|
||||
| 유틸리티 | `.wing-divider`, `.wing-kv-row`, `.wing-kv-label`, `.wing-kv-value` |
|
||||
| 뱃지/아이콘 | `.wing-badge`, `.wing-icon-badge`, `.wing-icon-badge-sm` |
|
||||
|
||||
#### 특수 컴포넌트
|
||||
|
||||
| 영역 | 클래스 접두사 | 설명 |
|
||||
|------|--------------|------|
|
||||
| 예측 패널 | `.prd-*` | 폼 입력, 버튼, 맵 버튼 |
|
||||
| 콤보박스 | `.combo-*` | 커스텀 드롭다운 (검색 + 리스트) |
|
||||
| 타임라인 | `.tlb`, `.tlt`, `.tlr`, `.tlth` | 지도 하단 재생 컨트롤 |
|
||||
| 레이어 트리 | `.lyr-*` | 3단계 접이식 레이어 (색상 스와치 + 투명도) |
|
||||
| 오일붐 | `.boom-*` | 오일붐 드로잉 인디케이터 |
|
||||
| 역추적 | `.bt-*` | 역추적 리플레이 마커 + 속도 버튼 |
|
||||
| HNS | `.hns-scn-card` | HNS 시나리오 선택 카드 |
|
||||
| 모델 칩 | `.prd-mc` | 모델 선택 칩 (활성 `✓` 인디케이터) |
|
||||
|
||||
#### 레이아웃
|
||||
|
||||
- **데스크톱 전용** (≥ 1280px), Tablet/Mobile 미지원
|
||||
- **7단계 z-index 레이어**: Base(0) ~ Tooltip(60)
|
||||
- **Spacing**: Tailwind 기본 스케일 사용 (`gap-1`~`gap-8`)
|
||||
|
||||
#### Border Radius
|
||||
|
||||
- `rounded-sm`: **6px** (커스텀 오버라이드)
|
||||
- `rounded-md`: **10px** (커스텀 오버라이드)
|
||||
- 나머지 Tailwind 기본값 유지
|
||||
|
||||
#### 애니메이션
|
||||
|
||||
`fadeIn`, `fadeSlideDown`, `pulse-dot`, `pulse-border`, `comboIn`, `lyrPopIn`, `bt-collision-pulse`
|
||||
|
||||
#### 디자인 카탈로그
|
||||
|
||||
`/design` 라우트에 라이브 카탈로그 뷰어 배포:
|
||||
- **Foundations 탭**: Color Palette, Typography, Radius, Layout, Overview
|
||||
- **Components 탭**: Button, TextField, Overview (Button Catalog, Card, Icon Badge 섹션)
|
||||
- **다크/라이트 모드 토글** 내장
|
||||
- **테마 엔진**: `designTheme.ts` — 타입 안전한 `DesignTheme` 인터페이스
|
||||
|
||||
#### SVG 아이콘 에셋
|
||||
|
||||
23개 커스텀 아이콘 (`wing-` 접두사): `wing-anchor`, `wing-cargo`, `wing-chart-bar`, `wing-color-palette`, `wing-documentation`, `wing-elevation`, `wing-foundations`, `wing-layout-grid`, `wing-notification`, `wing-pdf-file`, `wing-settings`, `wing-typography`, `wing-wave-graph` 등
|
||||
|
||||
#### Stitch MCP 연동
|
||||
|
||||
Google Stitch 프로젝트 7개 스크린 참조:
|
||||
- Design Tokens, Component Catalog (Buttons/Badges), Form Components
|
||||
- Table & List Patterns, Modal Catalog, Operational Shell (Layout)
|
||||
- Container & Navigation
|
||||
|
||||
---
|
||||
|
||||
## 테마 (Theme)
|
||||
|
||||
### 테마 전환 메커니즘
|
||||
|
||||
다크(기본)/라이트 2벌 테마를 CSS 변수 오버라이드 방식으로 지원한다.
|
||||
|
||||
```
|
||||
[플래시 방지] index.html 인라인 스크립트
|
||||
↓ localStorage → <html data-theme="dark|light">
|
||||
[상태 관리] themeStore.ts (Zustand)
|
||||
↓ toggleTheme() / setTheme()
|
||||
[CSS 적용] base.css :root (dark) / [data-theme="light"] (light)
|
||||
↓ CSS 변수 오버라이드
|
||||
[UI 반영] 모든 컴포넌트가 var(--*) 참조 → 즉시 전환
|
||||
```
|
||||
|
||||
#### 1단계 — FOUC 방지 (`index.html`)
|
||||
|
||||
```html
|
||||
<script>
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
localStorage.getItem('wing-theme') || 'dark'
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
HTML 파싱 즉시 `data-theme` 속성을 설정하여 테마 깜빡임을 방지한다.
|
||||
|
||||
#### 2단계 — Zustand 스토어 (`themeStore.ts`)
|
||||
|
||||
```ts
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: ThemeMode;
|
||||
toggleTheme: () => void; // dark ↔ light 토글
|
||||
setTheme: (mode: ThemeMode) => void; // 직접 지정
|
||||
}
|
||||
```
|
||||
|
||||
- 초기값: `localStorage.getItem('wing-theme') || 'dark'`
|
||||
- `toggleTheme()`: localStorage 갱신 → DOM 속성 변경 → Zustand 상태 갱신
|
||||
|
||||
#### 3단계 — CSS 변수 오버라이드 (`base.css`)
|
||||
|
||||
- `:root` — 다크 테마 (기본값)
|
||||
- `[data-theme="light"]` — 라이트 테마 오버라이드
|
||||
|
||||
시맨틱 토큰(`--bg-*`, `--fg-*`, `--stroke-*`)만 테마별 오버라이드하고, 프리미티브 토큰(`--gray-*`, `--blue-*` 등)과 액센트 컬러(`--color-*`)는 테마 간 동일 값을 유지한다.
|
||||
|
||||
#### UI 진입점
|
||||
|
||||
TopBar 퀵메뉴에서 `toggleTheme()` 호출로 전환.
|
||||
|
||||
---
|
||||
|
||||
@ -28,101 +220,117 @@ Google Stitch MCP로 생성된 스크린을 기반으로 일관된 UI 구현을
|
||||
|
||||
### 색상 (Color Palette)
|
||||
|
||||
#### 토큰 아키텍처
|
||||
|
||||
```
|
||||
Primitive Tokens (정적) Semantic Tokens (테마 반응형)
|
||||
────────────────────── ────────────────────────────
|
||||
--gray-100 ~ --gray-1000 --bg-base, --bg-surface, ...
|
||||
--blue-100 ~ --blue-1000 --fg-default, --fg-sub, ...
|
||||
--red-100 ~ --red-1000 --stroke-default, --stroke-light
|
||||
--green-100 ~ --green-1000 --hover-overlay, --dropdown-bg
|
||||
--yellow-100 ~ --yellow-1000
|
||||
--purple-100 ~ --purple-1000
|
||||
Accent Tokens (테마 불변)
|
||||
─────────────────────────
|
||||
--color-accent, --color-info, ...
|
||||
```
|
||||
|
||||
#### Semantic Colors — Background
|
||||
|
||||
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||
|----------|----------------|------|-------|------|
|
||||
| `--bg-base` | `bg-bg-base` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
|
||||
| `--bg-surface` | `bg-bg-surface` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
|
||||
| `--bg-elevated` | `bg-bg-elevated` | `#121929` | `#f1f5f9` | 테이블 헤더, 상위 요소 |
|
||||
| `--bg-card` | `bg-bg-card` | `#1a2236` | `#ffffff` | 카드 배경 |
|
||||
| `--bg-surface-hover` | `bg-bg-surface-hover` | `#1e2844` | `#e2e8f0` | 호버 상태 |
|
||||
|
||||
#### Semantic Colors — Foreground (Text)
|
||||
|
||||
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||
|----------|----------------|------|-------|------|
|
||||
| `--fg-default` | `text-fg` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 |
|
||||
| `--fg-sub` | `text-fg-sub` | `#c0c8dc` | `#475569` | 보조 텍스트 |
|
||||
| `--fg-disabled` | `text-fg-disabled` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||
|
||||
#### Semantic Colors — Border (Stroke)
|
||||
|
||||
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||
|----------|----------------|------|-------|------|
|
||||
| `--stroke-default` | `border-stroke` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
|
||||
| `--stroke-light` | `border-stroke-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
|
||||
|
||||
#### Semantic Colors — Overlay
|
||||
|
||||
| CSS 변수 | Dark | Light | 용도 |
|
||||
|----------|------|-------|------|
|
||||
| `--hover-overlay` | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.04)` | 호버 오버레이 |
|
||||
| `--dropdown-bg` | `rgba(18,25,41,0.97)` | `rgba(255,255,255,0.97)` | 드롭다운 배경 |
|
||||
|
||||
#### Accent Colors (테마 불변)
|
||||
|
||||
| CSS 변수 | Tailwind 클래스 | Hex | 용도 |
|
||||
|----------|----------------|-----|------|
|
||||
| `--color-accent` | `text-color-accent` | `#06b6d4` | 주요 강조 (Cyan) |
|
||||
| `--color-accent-muted` | `bg-color-accent-muted` | `#0e7490` / `#0891b2`(light) | 차분한 강조 (버튼 배경 등) |
|
||||
| `--color-info` | `text-color-info` | `#3b82f6` | 정보, 링크 (Blue) |
|
||||
| `--color-tertiary` | `text-color-tertiary` | `#a855f7` | 3차 강조 (Purple) |
|
||||
| `--color-danger` | `text-color-danger` | `#ef4444` | 위험, 삭제 (Red) |
|
||||
| `--color-warning` | `text-color-warning` | `#f97316` | 주의 (Orange) |
|
||||
| `--color-caution` | `text-color-caution` | `#eab308` | 경고 (Yellow) |
|
||||
| `--color-success` | `text-color-success` | `#22c55e` | 성공, 정상 (Green) |
|
||||
| `--color-boom` | `text-color-boom` | `#f59e0b` | 오일붐 전용 (Amber) |
|
||||
| `--color-boom-hover` | — | `#fbbf24` | 오일붐 호버 |
|
||||
| `--color-navy` | `bg-color-navy` | `#1e40af` / `#1d4ed8`(light) | Navy 버튼 배경 |
|
||||
| `--color-navy-hover` | `bg-color-navy-hover` | `#1d4ed8` / `#2563eb`(light) | Navy 호버 |
|
||||
|
||||
#### Static Colors
|
||||
|
||||
| CSS 변수 | Hex | 용도 |
|
||||
|----------|-----|------|
|
||||
| `--static-black` | `#131415` | 테마 무관 고정 검정 |
|
||||
| `--static-white` | `#ffffff` | 테마 무관 고정 흰색 |
|
||||
|
||||
#### Primitive Colors
|
||||
|
||||
UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단계, 나머지는 00~100의 11단계 스케일.
|
||||
UI 전반에서 직접 참조하거나 시맨틱 토큰의 원천으로 사용하는 기본 팔레트. 100~1000 (10단계).
|
||||
|
||||
**Navy** (배경 전용)
|
||||
**Gray**
|
||||
|
||||
| Step | Hex |
|
||||
|------|-----|
|
||||
| 0 | `#0a0e1a` |
|
||||
| 1 | `#0f1524` |
|
||||
| 2 | `#121929` |
|
||||
| 3 | `#1a2236` |
|
||||
| hover | `#1e2844` |
|
||||
|
||||
**Cyan**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#ecfeff` | `#cffafe` | `#a5f3fc` | `#67e8f9` | `#22d3ee` | `#06b6d4` | `#0891b2` | `#0e7490` | `#155e75` | `#164e63` | `#083344` |
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#f1f5f9` | `#e2e8f0` | `#cbd5e1` | `#94a3b8` | `#64748b` | `#475569` | `#334155` | `#1e293b` | `#0f172a` | `#020617` |
|
||||
|
||||
**Blue**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#eff6ff` | `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
|
||||
|
||||
**Red**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#fef2f2` | `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
|
||||
|
||||
**Green**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#f0fdf4` | `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
|
||||
|
||||
**Orange**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#fff7ed` | `#ffedd5` | `#fed7aa` | `#fdba74` | `#fb923c` | `#f97316` | `#ea580c` | `#c2410c` | `#9a3412` | `#7c2d12` | `#431407` |
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
|
||||
|
||||
**Yellow**
|
||||
|
||||
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||
|----|----|----|----|----|----|----|----|----|----|----|
|
||||
| `#fefce8` | `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
|
||||
|
||||
#### Semantic Colors
|
||||
**Red**
|
||||
|
||||
컨텍스트에 따라 의미를 부여한 토큰. Dark/Light 두 테마 값 병기.
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
|
||||
|
||||
**Text**
|
||||
**Purple**
|
||||
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `text-1` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 기본 |
|
||||
| `text-2` | `#b0b8cc` | `#475569` | 보조 텍스트 |
|
||||
| `text-3` | `#8690a6` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||
|
||||
**Background**
|
||||
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `bg-0` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
|
||||
| `bg-1` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
|
||||
| `bg-2` | `#121929` | `#f1f5f9` | 테이블 헤더 |
|
||||
| `bg-3` | `#1a2236` | `#e2e8f0` | 카드 배경 |
|
||||
| `bg-hover` | `#1e2844` | `#cbd5e1` | 호버 상태 |
|
||||
|
||||
**Border**
|
||||
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `border` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
|
||||
| `border-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
|
||||
|
||||
**Accent**
|
||||
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `primary-cyan` | `#06b6d4` | `#06b6d4` | 주요 강조, 활성 상태 |
|
||||
| `primary-blue` | `#3b82f6` | `#0891b2` | 보조 강조 |
|
||||
| `primary-purple` | `#a855f7` | `#6366f1` | 3차 강조 |
|
||||
|
||||
**Status**
|
||||
|
||||
| 토큰 | Dark | Light | 용도 |
|
||||
|------|------|-------|------|
|
||||
| `status-red` | `#ef4444` | `#dc2626` | 위험, 삭제 |
|
||||
| `status-orange` | `#f97316` | `#c2410c` | 주의 |
|
||||
| `status-yellow` | `#eab308` | `#b45309` | 경고 |
|
||||
| `status-green` | `#22c55e` | `#047857` | 정상, 성공 |
|
||||
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||
| `#f3e8ff` | `#e9d5ff` | `#d8b4fe` | `#c084fc` | `#a855f7` | `#9333ea` | `#7e22ce` | `#6b21a8` | `#581c87` | `#3b0764` |
|
||||
|
||||
---
|
||||
|
||||
@ -130,13 +338,104 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
|
||||
|
||||
#### Font Family
|
||||
|
||||
| 이름 | className | Font Stack | 용도 |
|
||||
|------|-----------|------------|------|
|
||||
| Noto Sans KR | `font-korean` | `'Noto Sans KR', sans-serif` | 기본 UI 텍스트, 한국어 콘텐츠 전반 |
|
||||
| JetBrains Mono | `font-mono` | `'JetBrains Mono', monospace` | 좌표, 수치, 코드, 토큰 이름 |
|
||||
| Outfit | `font-sans` | `'Outfit', 'Noto Sans KR', sans-serif` | 영문 헤딩, 브랜드 타이틀 |
|
||||
| CSS 변수 | Tailwind 클래스 | 용도 |
|
||||
|----------|----------------|------|
|
||||
| `--font-korean` | `font-korean` | 기본 UI 텍스트, 한국어/영문 콘텐츠 |
|
||||
| `--font-mono` | `font-mono` | 좌표, 수치, 데이터 값 |
|
||||
| — | `font-sans` | body 기본 (PretendardGOV) |
|
||||
|
||||
> Body 기본 스택: `font-family: 'Outfit', 'Noto Sans KR', sans-serif`
|
||||
> 모든 폰트 패밀리가 `PretendardGOV` 우선 스택으로 통일.
|
||||
> `@font-face`: Regular(400), Medium(500), SemiBold(600), Bold(700) — `/fonts/PretendardGOV-*.otf`
|
||||
|
||||
#### Typography Categories
|
||||
|
||||
5가지 용도 카테고리로 타이포그래피를 구성합니다.
|
||||
|
||||
| 카테고리 | 토큰 | 설명 |
|
||||
|----------|------|------|
|
||||
| **Display** | Display 1, Display 2, Display 3 | 배너, 마케팅 등 최대 크기 텍스트 |
|
||||
| **Heading** | Heading 1, Heading 2, Heading 3 | 페이지/모듈 단위 제목, 계층 설정 |
|
||||
| **Body** | Body 1, Body 2, Caption | 본문/콘텐츠 텍스트 |
|
||||
| **Navigation** | Title 1~6 | 사이트 내 이정표 역할 (패널 제목, 탭 버튼, 메뉴 항목 등) |
|
||||
| **Label** | Label 1, Label 2 | 컴포넌트 label, placeholder, 버튼 텍스트 |
|
||||
|
||||
> Navigation 카테고리의 토큰은 CSS 변수명 `--font-size-title-*` / Tailwind `text-title-*`을 유지합니다.
|
||||
|
||||
#### Font Size Tokens
|
||||
|
||||
CSS 변수와 Tailwind 유틸리티가 1:1 매핑된 타이포그래피 스케일. `text-*` 클래스 사용 시 font-size, line-height, letter-spacing이 함께 적용됩니다.
|
||||
|
||||
**Display** — 배너, 마케팅, 랜딩 영역
|
||||
|
||||
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||
|----------|---------|-----|--------|--------|---------|
|
||||
| `--font-size-display-1` | `text-display-1` | 60px | 700 | 1.3 | 0.06em |
|
||||
| `--font-size-display-2` | `text-display-2` | 40px | 700 | 1.3 | 0.06em |
|
||||
| `--font-size-display-3` | `text-display-3` | 36px | 500 | 1.4 | 0.06em |
|
||||
|
||||
**Heading** — 페이지/모듈 제목
|
||||
|
||||
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||
|----------|---------|-----|--------|--------|---------|
|
||||
| `--font-size-heading-1` | `text-heading-1` | 32px | 700 | 1.4 | 0.02em |
|
||||
| `--font-size-heading-2` | `text-heading-2` | 24px | 700 | 1.4 | 0.02em |
|
||||
| `--font-size-heading-3` | `text-heading-3` | 22px | 500 | 1.4 | 0.02em |
|
||||
|
||||
**Body** — 본문/콘텐츠
|
||||
|
||||
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||
|----------|---------|-----|--------|--------|---------|
|
||||
| `--font-size-body-1` | `text-body-1` | 14px | 400 | 1.6 | 0em |
|
||||
| `--font-size-body-2` | `text-body-2` | 13px | 400 | 1.6 | 0em |
|
||||
| `--font-size-caption` | `text-caption` | 11px | 400 | 1.5 | 0em |
|
||||
|
||||
**Navigation** — 패널 제목, 탭 버튼, 메뉴 항목, 소형 네비게이션
|
||||
|
||||
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||
|----------|---------|-----|--------|--------|---------|
|
||||
| `--font-size-title-1` | `text-title-1` | 18px | 700 | 1.5 | 0.02em |
|
||||
| `--font-size-title-2` | `text-title-2` | 16px | 500 | 1.5 | 0.02em |
|
||||
| `--font-size-title-3` | `text-title-3` | 14px | 500 | 1.5 | 0.02em |
|
||||
| `--font-size-title-4` | `text-title-4` | 13px | 500 | 1.5 | 0.02em |
|
||||
| `--font-size-title-5` | `text-title-5` | 12px | 500 | 1.5 | 0.02em |
|
||||
| `--font-size-title-6` | `text-title-6` | 11px | 500 | 1.5 | 0.02em |
|
||||
|
||||
**Label** — 레이블, 플레이스홀더, 버튼
|
||||
|
||||
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||
|----------|---------|-----|--------|--------|---------|
|
||||
| `--font-size-label-1` | `text-label-1` | 12px | 500 | 1.5 | 0.04em |
|
||||
| `--font-size-label-2` | `text-label-2` | 11px | 500 | 1.5 | 0.04em |
|
||||
|
||||
#### Font Weight Tokens
|
||||
|
||||
| CSS 변수 | 값 | 용도 |
|
||||
|----------|-----|------|
|
||||
| `--font-weight-thin` | 300 | 얇은 텍스트 |
|
||||
| `--font-weight-regular` | 400 | 본문 기본 |
|
||||
| `--font-weight-medium` | 500 | 중간 강조 |
|
||||
| `--font-weight-bold` | 700 | 제목, 강조 |
|
||||
|
||||
#### Line Height Tokens
|
||||
|
||||
| CSS 변수 | 값 | 용도 |
|
||||
|----------|-----|------|
|
||||
| `--line-height-tight` | 1.3 | Display |
|
||||
| `--line-height-snug` | 1.4 | Heading |
|
||||
| `--line-height-normal` | 1.5 | Navigation, Label, Caption |
|
||||
| `--line-height-relaxed` | 1.6 | Body |
|
||||
|
||||
#### Letter Spacing Tokens
|
||||
|
||||
카테고리별 자간 토큰. `text-*` 클래스에 자동 포함되며, `tracking-*` 클래스로 개별 사용도 가능합니다.
|
||||
|
||||
| CSS 변수 | Tailwind | 값 | 카테고리 |
|
||||
|----------|---------|-----|----------|
|
||||
| `--letter-spacing-display` | `tracking-display` | 0.06em | Display |
|
||||
| `--letter-spacing-heading` | `tracking-heading` | 0.02em | Heading |
|
||||
| `--letter-spacing-body` | `tracking-body` | 0em | Body |
|
||||
| `--letter-spacing-navigation` | `tracking-navigation` | 0.02em | Navigation |
|
||||
| `--letter-spacing-label` | `tracking-label` | 0.04em | Label |
|
||||
|
||||
#### Typography Tokens (`.wing-*` 클래스)
|
||||
|
||||
@ -237,3 +536,30 @@ UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단
|
||||
| `.wing-panel-scroll` | 패널 내 스크롤 영역 | `flex-1 overflow-y-auto` |
|
||||
| `.wing-header-bar` | 패널 헤더 | `flex items-center justify-between shrink-0 px-5 border-b` |
|
||||
| `.wing-sidebar` | 사이드바 | `flex flex-col border-r border-border` |
|
||||
|
||||
---
|
||||
|
||||
## CSS 레이어 아키텍처
|
||||
|
||||
```
|
||||
index.css
|
||||
├── @import base.css → @layer base (CSS 변수, reset, body, @font-face)
|
||||
├── @import components.css → @layer components (MapLibre, scrollbar, prd-*, combo-*)
|
||||
├── @import wing.css → @layer components (wing-* 디자인 시스템 클래스)
|
||||
├── @tailwind base
|
||||
├── @tailwind components
|
||||
└── @tailwind utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tailwind 시맨틱 토큰 매핑 요약
|
||||
|
||||
| 카테고리 | CSS 변수 | Tailwind 클래스 예시 |
|
||||
|---------|----------|---------------------|
|
||||
| Background | `--bg-base` ~ `--bg-surface-hover` | `bg-bg-base`, `bg-bg-surface`, ... |
|
||||
| Foreground | `--fg-default`, `--fg-sub`, `--fg-disabled` | `text-fg`, `text-fg-sub`, `text-fg-disabled` |
|
||||
| Border | `--stroke-default`, `--stroke-light` | `border-stroke`, `border-stroke-light` |
|
||||
| Accent | `--color-accent` ~ `--color-success` | `text-color-accent`, `bg-color-info`, ... |
|
||||
| Font Size | `--font-size-display-1` ~ `--font-size-caption` | `text-display-1`, `text-body-1`, ... |
|
||||
| Font Family | `--font-korean`, `--font-mono` | `font-korean`, `font-mono`, `font-sans` |
|
||||
|
||||
@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
|
||||
| Alias | 실제 경로 | 용도 |
|
||||
|-------|----------|------|
|
||||
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
||||
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
|
||||
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
|
||||
|
||||
```tsx
|
||||
import { useAuth } from '@common/hooks/useAuth';
|
||||
import OilSpillView from '@tabs/prediction/components/OilSpillView';
|
||||
import OilSpillView from '@components/prediction/components/OilSpillView';
|
||||
```
|
||||
|
||||
---
|
||||
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
|
||||
git status
|
||||
|
||||
# 스테이징 (파일 지정)
|
||||
git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
||||
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||
git add backend/src/incidents/incidentService.ts
|
||||
|
||||
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
||||
@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \
|
||||
- 변경 내용을 1~3줄로 요약
|
||||
|
||||
## 변경 파일
|
||||
- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규)
|
||||
- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규)
|
||||
- `backend/src/incidents/incidentService.ts` (수정)
|
||||
|
||||
## Test plan
|
||||
@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg
|
||||
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
||||
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
||||
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
||||
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
||||
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
||||
| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
||||
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
||||
|
||||
#### Step 2. 브랜치 생성
|
||||
|
||||
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
|
||||
**Frontend - API:**
|
||||
|
||||
```typescript
|
||||
// frontend/src/tabs/incidents/services/incidentsApi.ts
|
||||
// frontend/src/components/incidents/services/incidentsApi.ts
|
||||
export async function fetchIncidentById(id: number) {
|
||||
const { data } = await api.get(`/incidents/${id}`);
|
||||
return data;
|
||||
@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) {
|
||||
**Frontend - Component:**
|
||||
|
||||
```tsx
|
||||
// frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
||||
// frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['incident', incidentId],
|
||||
@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit
|
||||
#### Step 5. 커밋 & 푸시
|
||||
|
||||
```bash
|
||||
git add backend/src/incidents/ frontend/src/tabs/incidents/
|
||||
git add backend/src/incidents/ frontend/src/components/incidents/
|
||||
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
||||
# pre-commit: TypeScript OK, ESLint OK
|
||||
# commit-msg: Conventional Commits OK
|
||||
|
||||
@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
||||
|
||||
| 단계 | 파일 | 작업 |
|
||||
|------|------|------|
|
||||
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
||||
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
||||
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
|
||||
| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
||||
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
||||
| | `frontend/src/components/{탭명}/index.ts` | re-export |
|
||||
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
||||
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
||||
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
||||
@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
||||
### 1-1. 디렉토리 구조
|
||||
|
||||
```
|
||||
frontend/src/tabs/{탭명}/
|
||||
frontend/src/components/{탭명}/
|
||||
components/
|
||||
{TabName}View.tsx # 메인 뷰 컴포넌트
|
||||
services/
|
||||
@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/
|
||||
서브탭이 **없는** 간단한 탭:
|
||||
|
||||
```tsx
|
||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
||||
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||
|
||||
export function MonitoringView() {
|
||||
return (
|
||||
@ -91,7 +91,7 @@ export function MonitoringView() {
|
||||
서브탭이 **있는** 탭 (board 패턴):
|
||||
|
||||
```tsx
|
||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
||||
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||
|
||||
@ -122,7 +122,7 @@ export function MonitoringView() {
|
||||
### 1-3. API 서비스 (보일러플레이트)
|
||||
|
||||
```ts
|
||||
// frontend/src/tabs/monitoring/services/monitoringApi.ts
|
||||
// frontend/src/components/monitoring/services/monitoringApi.ts
|
||||
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{
|
||||
### 1-4. index.ts (re-export)
|
||||
|
||||
```ts
|
||||
// frontend/src/tabs/monitoring/index.ts
|
||||
// frontend/src/components/monitoring/index.ts
|
||||
|
||||
export { MonitoringView } from './components/MonitoringView';
|
||||
```
|
||||
@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad
|
||||
// frontend/src/App.tsx
|
||||
|
||||
// 1. import 추가
|
||||
import { MonitoringView } from '@tabs/monitoring';
|
||||
import { MonitoringView } from '@components/monitoring';
|
||||
|
||||
// 2. renderView switch에 case 추가
|
||||
const renderView = () => {
|
||||
@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
|
||||
### 1단계: 프론트엔드 파일 생성
|
||||
|
||||
```bash
|
||||
mkdir -p frontend/src/tabs/monitoring/components
|
||||
mkdir -p frontend/src/tabs/monitoring/services
|
||||
mkdir -p frontend/src/components/monitoring/components
|
||||
mkdir -p frontend/src/components/monitoring/services
|
||||
```
|
||||
|
||||
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
|
||||
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
|
||||
- `frontend/src/tabs/monitoring/index.ts` 생성
|
||||
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
|
||||
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
|
||||
- `frontend/src/components/monitoring/index.ts` 생성
|
||||
|
||||
### 2단계: 프론트엔드 기존 파일 수정
|
||||
|
||||
@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services
|
||||
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
||||
|
||||
--- frontend/src/App.tsx
|
||||
+ import { MonitoringView } from '@tabs/monitoring';
|
||||
+ import { MonitoringView } from '@components/monitoring';
|
||||
// renderView switch 내:
|
||||
+ case 'monitoring':
|
||||
+ return <MonitoringView />;
|
||||
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
|
||||
## 체크리스트
|
||||
|
||||
### 프론트엔드
|
||||
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
|
||||
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
|
||||
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
|
||||
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
|
||||
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
|
||||
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
|
||||
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
||||
- [ ] `App.tsx` import + renderView switch case 추가
|
||||
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
||||
|
||||
@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud
|
||||
```bash
|
||||
# 탭 디렉토리 내 mock 데이터 검색
|
||||
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
||||
frontend/src/tabs/{탭명}/
|
||||
frontend/src/components/{탭명}/
|
||||
|
||||
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
|
||||
|
||||
**1) API 서비스 파일 생성:**
|
||||
|
||||
파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts`
|
||||
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
|
||||
|
||||
```typescript
|
||||
import { api } from '@common/services/api';
|
||||
@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스
|
||||
|
||||
```bash
|
||||
# 해당 탭 디렉토리에서 mock 잔여 검색
|
||||
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
|
||||
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
|
||||
|
||||
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
||||
grep -rn "{탭명}" frontend/src/common/mock/
|
||||
@ -497,7 +497,7 @@ git status
|
||||
git add database/migration/017_{탭명}.sql
|
||||
git add backend/src/{탭명}/
|
||||
git add backend/src/server.ts
|
||||
git add frontend/src/tabs/{탭명}/
|
||||
git add frontend/src/components/{탭명}/
|
||||
|
||||
# 커밋 (Conventional Commits, 한국어)
|
||||
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
||||
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
|
||||
|
||||
```bash
|
||||
# 불충분 -- 탭 디렉토리만 검색
|
||||
grep -rn "mock" frontend/src/tabs/{탭명}/
|
||||
grep -rn "mock" frontend/src/components/{탭명}/
|
||||
|
||||
# 반드시 공통 디렉토리도 검색
|
||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||
@ -780,8 +780,8 @@ export async function fetchCategories(): Promise<Category[]> {
|
||||
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
||||
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
||||
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
||||
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
|
||||
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
|
||||
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외)
|
||||
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
|
||||
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
||||
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
||||
- [ ] 커밋 + 푸시 + MR 생성
|
||||
|
||||
@ -66,7 +66,7 @@ wing/
|
||||
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
||||
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
||||
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
||||
│ └── tabs/ @tabs/ alias (11개 탭)
|
||||
│ └── tabs/ @components/ alias (11개 탭)
|
||||
│ ├── prediction/ 유류 확산 예측
|
||||
│ ├── hns/ HNS 분석
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
@ -103,7 +103,7 @@ wing/
|
||||
| Alias | 경로 |
|
||||
|-------|------|
|
||||
| `@common/*` | `src/common/*` |
|
||||
| `@tabs/*` | `src/tabs/*` |
|
||||
| `@components/*` | `src/components/*` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -4,8 +4,118 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-17]
|
||||
|
||||
### 추가
|
||||
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
||||
|
||||
### 변경
|
||||
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
|
||||
|
||||
### 수정
|
||||
- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search)
|
||||
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
|
||||
|
||||
## [2026-04-16]
|
||||
|
||||
### 추가
|
||||
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
|
||||
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
|
||||
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
|
||||
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
|
||||
- HNS: 분석 생성 시 `acdntSn` 연결 지원
|
||||
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
|
||||
|
||||
### 변경
|
||||
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
|
||||
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
|
||||
|
||||
## [2026-04-15]
|
||||
|
||||
### 추가
|
||||
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
|
||||
|
||||
### 변경
|
||||
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||
- aerial 이미지 분석 API 기본 URL 변경
|
||||
|
||||
## [2026-04-14]
|
||||
|
||||
### 추가
|
||||
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
|
||||
- 관리자: 비식별화조치 메뉴 및 패널 추가
|
||||
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
|
||||
|
||||
### 변경
|
||||
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
### 추가
|
||||
- 사고별 이미지 분석 데이터 조회 API 추가
|
||||
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
|
||||
- 사고 마커 클릭 팝업 디자인 리뉴얼
|
||||
- 지도에 필터링된 사고만 표시되도록 개선
|
||||
|
||||
### 변경
|
||||
- 이미지 분석 시 사고명 파라미터 지원
|
||||
- 기본 예측시간 48시간 → 6시간으로 변경
|
||||
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
|
||||
- OpenDrift 유종 매핑 수정 (원유, 등유)
|
||||
- 소량 유출량 과학적 표기법으로 표시
|
||||
|
||||
## [2026-04-09]
|
||||
|
||||
### 추가
|
||||
- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일)
|
||||
- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계)
|
||||
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
|
||||
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가
|
||||
- 해양 오염물질 배출규정 구역 판별 기능 추가
|
||||
|
||||
### 변경
|
||||
- 지도: 항상 라이트 모드로 고정 (앱 다크 모드와 무관)
|
||||
- 지도: lightMode prop 제거, useThemeStore 기반 테마 전환 통합
|
||||
- 레이어 색상 상태를 OilSpillView로 끌어올림
|
||||
- 대한민국 해리 GeoJSON 데이터 갱신
|
||||
|
||||
## [2026-04-02]
|
||||
|
||||
### 변경
|
||||
- 디자인 시스템 폰트 및 시맨틱 토큰 전면 적용
|
||||
- HNS 탭: HNSTheoryView, HNSSubstanceView, HNSScenarioView, HNSLeftPanel, HNSRightPanel, HNSRecalcModal, HNSAnalysisListTable, HNSView
|
||||
- 예측 탭: OilSpillTheoryView, OilSpillView, BoomDeploymentTheoryView, AnalysisListTable
|
||||
- 구조 탭: RescueView
|
||||
- 하드코딩 색상(#hex, rgba) → CSS 변수 전환, 그라데이션 → 단색, fontFamily/fontSize → Tailwind 토큰
|
||||
|
||||
## [2026-04-01]
|
||||
|
||||
### 수정
|
||||
- 지도: S57 ENC 오버레이 스타일 로드 완료 대기 후 레이어 추가
|
||||
- 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합
|
||||
- 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가
|
||||
- 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응)
|
||||
|
||||
## [2026-03-31]
|
||||
|
||||
### 추가
|
||||
- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가
|
||||
- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도)
|
||||
- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가
|
||||
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
|
||||
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
|
||||
|
||||
### 변경
|
||||
- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off)
|
||||
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
|
||||
- PretendardGOV 폰트 적용
|
||||
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
||||
|
||||
## [2026-03-30]
|
||||
|
||||
### 추가
|
||||
- 지도: VWorld 위성타일 백엔드 프록시 추가 — API 키를 서버에서 관리하고 CORS 우회
|
||||
|
||||
## [2026-03-27]
|
||||
|
||||
@ -15,6 +125,13 @@
|
||||
- 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성
|
||||
- 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시
|
||||
- DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스)
|
||||
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
|
||||
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
|
||||
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
|
||||
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
|
||||
|
||||
### 수정
|
||||
- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search)
|
||||
|
||||
### 변경
|
||||
- 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요)
|
||||
@ -28,26 +145,20 @@
|
||||
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
|
||||
- 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선)
|
||||
|
||||
## [2026-03-25.2]
|
||||
|
||||
### 추가
|
||||
- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑)
|
||||
- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin)
|
||||
- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션)
|
||||
|
||||
### 변경
|
||||
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
|
||||
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
|
||||
|
||||
## [2026-03-25]
|
||||
|
||||
### 추가
|
||||
- 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회)
|
||||
- DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn)
|
||||
- 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE)
|
||||
- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑)
|
||||
- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin)
|
||||
- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션)
|
||||
|
||||
### 변경
|
||||
- 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등)
|
||||
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
|
||||
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
|
||||
|
||||
## [2026-03-24]
|
||||
|
||||
|
||||
@ -40,15 +40,15 @@ export default defineConfig([
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
import reactX from 'eslint-plugin-react-x';
|
||||
import reactDom from 'eslint-plugin-react-dom';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
@ -69,5 +69,5 @@ export default defineConfig([
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
```
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
@ -20,4 +20,4 @@ export default defineConfig([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<title>frontend</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>해양환경 위기대응 통합지원 시스템</title>
|
||||
<script>
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
localStorage.getItem('wing-theme') || 'dark',
|
||||
);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
328
frontend/package-lock.json
generated
328
frontend/package-lock.json
generated
@ -51,6 +51,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
@ -1944,9 +1945,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1958,9 +1959,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1972,9 +1973,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1986,9 +1987,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2000,9 +2001,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -2014,9 +2015,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2028,9 +2029,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -2042,9 +2043,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -2056,9 +2057,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -2070,9 +2071,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -2084,9 +2085,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -2098,9 +2099,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -2112,9 +2113,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -2126,9 +2127,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -2140,9 +2141,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -2154,9 +2155,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -2168,9 +2169,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -2182,9 +2183,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2196,9 +2197,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2210,9 +2211,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2224,9 +2225,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -2238,9 +2239,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -2252,9 +2253,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@ -2266,9 +2267,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2280,9 +2281,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2710,9 +2711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2720,13 +2721,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@ -2872,9 +2873,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2926,9 +2927,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -3014,14 +3015,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@ -3076,9 +3077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3945,9 +3946,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
||||
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
||||
"version": "4.5.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
||||
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -4054,16 +4055,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@ -4903,9 +4904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -4937,9 +4938,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@ -5168,9 +5169,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -5379,6 +5380,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@ -5392,10 +5409,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@ -5552,9 +5572,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -5616,9 +5636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -5632,31 +5652,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||
"@rollup/rollup-android-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@ -5784,9 +5804,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
@ -6268,9 +6288,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import tailwindcss from 'tailwindcss'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
}
|
||||
};
|
||||
|
||||
2040
frontend/public/data/대한민국.geojson
Normal file
2040
frontend/public/data/대한민국.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M4 12 Q16 0 28 12 Q22 15 16 13 Q10 15 4 12 Z" fill="#06b6d4"/>
|
||||
<path d="M4 19 Q10 15 16 19 T28 19 L28 22 Q22 26 16 22 T4 22 Z" fill="#06b6d4"/>
|
||||
<path d="M4 25 Q10 21 16 25 T28 25 L28 28 Q22 32 16 28 T4 28 Z" fill="#06b6d4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | 크기: 320 B |
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Bold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-ExtraBold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-ExtraBold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-ExtraLight.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-ExtraLight.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Light.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Light.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Medium.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Medium.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Regular.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Regular.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-SemiBold.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-SemiBold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOV-Thin.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Thin.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/PretendardGOVVariable.ttf
Normal file
BIN
frontend/public/fonts/PretendardGOVVariable.ttf
Normal file
Binary file not shown.
@ -7,15 +7,8 @@
|
||||
"p001_img05.jpg",
|
||||
"p001_img06.jpg"
|
||||
],
|
||||
"2": [
|
||||
"p002_img02.jpg",
|
||||
"p002_img03.jpg"
|
||||
],
|
||||
"5": [
|
||||
"p005_img01.jpg",
|
||||
"p005_img02.jpg",
|
||||
"p005_img03.jpg"
|
||||
],
|
||||
"2": ["p002_img02.jpg", "p002_img03.jpg"],
|
||||
"5": ["p005_img01.jpg", "p005_img02.jpg", "p005_img03.jpg"],
|
||||
"17": [
|
||||
"p017_img01.jpg",
|
||||
"p017_img02.jpg",
|
||||
@ -28,13 +21,8 @@
|
||||
"p017_img13.jpg",
|
||||
"p017_img14.jpg"
|
||||
],
|
||||
"19": [
|
||||
"p019_img01.jpg",
|
||||
"p019_img02.jpg"
|
||||
],
|
||||
"21": [
|
||||
"p021_img01.jpg"
|
||||
],
|
||||
"19": ["p019_img01.jpg", "p019_img02.jpg"],
|
||||
"21": ["p021_img01.jpg"],
|
||||
"22": [
|
||||
"p022_img02.jpg",
|
||||
"p022_img03.jpg",
|
||||
@ -48,12 +36,8 @@
|
||||
"p022_img16.jpg",
|
||||
"p022_img17.jpg"
|
||||
],
|
||||
"23": [
|
||||
"p023_img01.jpg"
|
||||
],
|
||||
"24": [
|
||||
"p024_img01.jpg"
|
||||
],
|
||||
"23": ["p023_img01.jpg"],
|
||||
"24": ["p024_img01.jpg"],
|
||||
"25": [
|
||||
"p025_img01.jpg",
|
||||
"p025_img04.jpg",
|
||||
@ -63,9 +47,7 @@
|
||||
"p025_img12.jpg",
|
||||
"p025_img14.jpg"
|
||||
],
|
||||
"26": [
|
||||
"p026_img01.jpg"
|
||||
],
|
||||
"26": ["p026_img01.jpg"],
|
||||
"27": [
|
||||
"p027_img01.jpg",
|
||||
"p027_img03.jpg",
|
||||
@ -82,20 +64,10 @@
|
||||
"p027_img18.jpg",
|
||||
"p027_img19.jpg"
|
||||
],
|
||||
"28": [
|
||||
"p028_img01.jpg"
|
||||
],
|
||||
"30": [
|
||||
"p030_img01.jpg",
|
||||
"p030_img02.jpg"
|
||||
],
|
||||
"32": [
|
||||
"p032_img01.jpg",
|
||||
"p032_img02.jpg"
|
||||
],
|
||||
"33": [
|
||||
"p033_img01.jpg"
|
||||
],
|
||||
"28": ["p028_img01.jpg"],
|
||||
"30": ["p030_img01.jpg", "p030_img02.jpg"],
|
||||
"32": ["p032_img01.jpg", "p032_img02.jpg"],
|
||||
"33": ["p033_img01.jpg"],
|
||||
"34": [
|
||||
"p034_img01.jpg",
|
||||
"p034_img02.jpg",
|
||||
@ -106,152 +78,50 @@
|
||||
"p034_img16.jpg",
|
||||
"p034_img17.jpg"
|
||||
],
|
||||
"35": [
|
||||
"p035_img02.jpg",
|
||||
"p035_img03.jpg",
|
||||
"p035_img04.jpg"
|
||||
],
|
||||
"36": [
|
||||
"p036_img03.jpg"
|
||||
],
|
||||
"37": [
|
||||
"p037_img01.jpg",
|
||||
"p037_img02.jpg"
|
||||
],
|
||||
"41": [
|
||||
"p041_img01.jpg"
|
||||
],
|
||||
"43": [
|
||||
"p043_img01.jpg"
|
||||
],
|
||||
"45": [
|
||||
"p045_img01.jpg",
|
||||
"p045_img02.jpg",
|
||||
"p045_img03.jpg"
|
||||
],
|
||||
"46": [
|
||||
"p046_img01.jpg"
|
||||
],
|
||||
"47": [
|
||||
"p047_img01.jpg"
|
||||
],
|
||||
"48": [
|
||||
"p048_img01.jpg"
|
||||
],
|
||||
"49": [
|
||||
"p049_img01.jpg"
|
||||
],
|
||||
"51": [
|
||||
"p051_img01.jpg"
|
||||
],
|
||||
"53": [
|
||||
"p053_img01.jpg"
|
||||
],
|
||||
"56": [
|
||||
"p056_img01.jpg"
|
||||
],
|
||||
"60": [
|
||||
"p060_img01.jpg"
|
||||
],
|
||||
"61": [
|
||||
"p061_img01.jpg"
|
||||
],
|
||||
"62": [
|
||||
"p062_img01.jpg"
|
||||
],
|
||||
"63": [
|
||||
"p063_img01.jpg"
|
||||
],
|
||||
"65": [
|
||||
"p065_img01.jpg"
|
||||
],
|
||||
"66": [
|
||||
"p066_img01.jpg",
|
||||
"p066_img02.jpg"
|
||||
],
|
||||
"69": [
|
||||
"p069_img01.jpg",
|
||||
"p069_img02.jpg"
|
||||
],
|
||||
"74": [
|
||||
"p074_img01.jpg",
|
||||
"p074_img02.jpg",
|
||||
"p074_img05.jpg"
|
||||
],
|
||||
"78": [
|
||||
"p078_img01.jpg"
|
||||
],
|
||||
"79": [
|
||||
"p079_img01.jpg"
|
||||
],
|
||||
"80": [
|
||||
"p080_img01.jpg"
|
||||
],
|
||||
"82": [
|
||||
"p082_img01.jpg",
|
||||
"p082_img02.jpg"
|
||||
],
|
||||
"84": [
|
||||
"p084_img01.jpg"
|
||||
],
|
||||
"85": [
|
||||
"p085_img01.jpg"
|
||||
],
|
||||
"88": [
|
||||
"p088_img01.jpg",
|
||||
"p088_img02.jpg"
|
||||
],
|
||||
"90": [
|
||||
"p090_img01.jpg"
|
||||
],
|
||||
"92": [
|
||||
"p092_img01.jpg"
|
||||
],
|
||||
"93": [
|
||||
"p093_img01.jpg"
|
||||
],
|
||||
"99": [
|
||||
"p099_img01.jpg"
|
||||
],
|
||||
"100": [
|
||||
"p100_img01.jpg"
|
||||
],
|
||||
"102": [
|
||||
"p102_img01.jpg"
|
||||
],
|
||||
"108": [
|
||||
"p108_img03.jpg",
|
||||
"p108_img07.jpg",
|
||||
"p108_img09.jpg"
|
||||
],
|
||||
"110": [
|
||||
"p110_img01.jpg"
|
||||
],
|
||||
"111": [
|
||||
"p111_img01.jpg"
|
||||
],
|
||||
"114": [
|
||||
"p114_img01.jpg"
|
||||
],
|
||||
"117": [
|
||||
"p117_img01.jpg"
|
||||
],
|
||||
"121": [
|
||||
"p121_img01.jpg",
|
||||
"p121_img02.jpg"
|
||||
],
|
||||
"122": [
|
||||
"p122_img01.jpg"
|
||||
],
|
||||
"127": [
|
||||
"p127_img01.jpg"
|
||||
],
|
||||
"129": [
|
||||
"p129_img01.jpg"
|
||||
],
|
||||
"130": [
|
||||
"p130_img01.jpg"
|
||||
],
|
||||
"35": ["p035_img02.jpg", "p035_img03.jpg", "p035_img04.jpg"],
|
||||
"36": ["p036_img03.jpg"],
|
||||
"37": ["p037_img01.jpg", "p037_img02.jpg"],
|
||||
"41": ["p041_img01.jpg"],
|
||||
"43": ["p043_img01.jpg"],
|
||||
"45": ["p045_img01.jpg", "p045_img02.jpg", "p045_img03.jpg"],
|
||||
"46": ["p046_img01.jpg"],
|
||||
"47": ["p047_img01.jpg"],
|
||||
"48": ["p048_img01.jpg"],
|
||||
"49": ["p049_img01.jpg"],
|
||||
"51": ["p051_img01.jpg"],
|
||||
"53": ["p053_img01.jpg"],
|
||||
"56": ["p056_img01.jpg"],
|
||||
"60": ["p060_img01.jpg"],
|
||||
"61": ["p061_img01.jpg"],
|
||||
"62": ["p062_img01.jpg"],
|
||||
"63": ["p063_img01.jpg"],
|
||||
"65": ["p065_img01.jpg"],
|
||||
"66": ["p066_img01.jpg", "p066_img02.jpg"],
|
||||
"69": ["p069_img01.jpg", "p069_img02.jpg"],
|
||||
"74": ["p074_img01.jpg", "p074_img02.jpg", "p074_img05.jpg"],
|
||||
"78": ["p078_img01.jpg"],
|
||||
"79": ["p079_img01.jpg"],
|
||||
"80": ["p080_img01.jpg"],
|
||||
"82": ["p082_img01.jpg", "p082_img02.jpg"],
|
||||
"84": ["p084_img01.jpg"],
|
||||
"85": ["p085_img01.jpg"],
|
||||
"88": ["p088_img01.jpg", "p088_img02.jpg"],
|
||||
"90": ["p090_img01.jpg"],
|
||||
"92": ["p092_img01.jpg"],
|
||||
"93": ["p093_img01.jpg"],
|
||||
"99": ["p099_img01.jpg"],
|
||||
"100": ["p100_img01.jpg"],
|
||||
"102": ["p102_img01.jpg"],
|
||||
"108": ["p108_img03.jpg", "p108_img07.jpg", "p108_img09.jpg"],
|
||||
"110": ["p110_img01.jpg"],
|
||||
"111": ["p111_img01.jpg"],
|
||||
"114": ["p114_img01.jpg"],
|
||||
"117": ["p117_img01.jpg"],
|
||||
"121": ["p121_img01.jpg", "p121_img02.jpg"],
|
||||
"122": ["p122_img01.jpg"],
|
||||
"127": ["p127_img01.jpg"],
|
||||
"129": ["p129_img01.jpg"],
|
||||
"130": ["p130_img01.jpg"],
|
||||
"133": [
|
||||
"p133_img01.jpg",
|
||||
"p133_img02.jpg",
|
||||
@ -268,157 +138,48 @@
|
||||
"p134_img05.jpg",
|
||||
"p134_img06.jpg"
|
||||
],
|
||||
"135": [
|
||||
"p135_img01.jpg"
|
||||
],
|
||||
"136": [
|
||||
"p136_img01.jpg"
|
||||
],
|
||||
"140": [
|
||||
"p140_img02.jpg"
|
||||
],
|
||||
"141": [
|
||||
"p141_img01.jpg"
|
||||
],
|
||||
"143": [
|
||||
"p143_img03.jpg",
|
||||
"p143_img04.jpg",
|
||||
"p143_img07.jpg"
|
||||
],
|
||||
"144": [
|
||||
"p144_img01.jpg"
|
||||
],
|
||||
"150": [
|
||||
"p150_img01.jpg"
|
||||
],
|
||||
"151": [
|
||||
"p151_img01.jpg"
|
||||
],
|
||||
"155": [
|
||||
"p155_img01.jpg"
|
||||
],
|
||||
"156": [
|
||||
"p156_img01.jpg",
|
||||
"p156_img02.jpg",
|
||||
"p156_img03.jpg",
|
||||
"p156_img04.jpg",
|
||||
"p156_img05.jpg"
|
||||
],
|
||||
"158": [
|
||||
"p158_img01.jpg"
|
||||
],
|
||||
"160": [
|
||||
"p160_img01.jpg",
|
||||
"p160_img02.jpg"
|
||||
],
|
||||
"161": [
|
||||
"p161_img01.jpg",
|
||||
"p161_img02.jpg"
|
||||
],
|
||||
"170": [
|
||||
"p170_img05.jpg"
|
||||
],
|
||||
"180": [
|
||||
"p180_img01.jpg",
|
||||
"p180_img03.jpg"
|
||||
],
|
||||
"181": [
|
||||
"p181_img05.jpg",
|
||||
"p181_img06.jpg"
|
||||
],
|
||||
"190": [
|
||||
"p190_img01.jpg"
|
||||
],
|
||||
"192": [
|
||||
"p192_img01.jpg",
|
||||
"p192_img02.jpg"
|
||||
],
|
||||
"195": [
|
||||
"p195_img01.jpg"
|
||||
],
|
||||
"196": [
|
||||
"p196_img01.jpg",
|
||||
"p196_img02.jpg",
|
||||
"p196_img03.jpg"
|
||||
],
|
||||
"198": [
|
||||
"p198_img01.jpg"
|
||||
],
|
||||
"200": [
|
||||
"p200_img01.jpg"
|
||||
],
|
||||
"202": [
|
||||
"p202_img01.jpg"
|
||||
],
|
||||
"204": [
|
||||
"p204_img01.jpg"
|
||||
],
|
||||
"206": [
|
||||
"p206_img01.jpg"
|
||||
],
|
||||
"207": [
|
||||
"p207_img01.jpg"
|
||||
],
|
||||
"208": [
|
||||
"p208_img01.jpg"
|
||||
],
|
||||
"209": [
|
||||
"p209_img01.jpg",
|
||||
"p209_img02.jpg"
|
||||
],
|
||||
"210": [
|
||||
"p210_img01.jpg"
|
||||
],
|
||||
"212": [
|
||||
"p212_img01.jpg",
|
||||
"p212_img02.jpg",
|
||||
"p212_img03.jpg",
|
||||
"p212_img04.jpg"
|
||||
],
|
||||
"213": [
|
||||
"p213_img01.jpg"
|
||||
],
|
||||
"214": [
|
||||
"p214_img01.jpg",
|
||||
"p214_img02.jpg",
|
||||
"p214_img03.jpg",
|
||||
"p214_img04.jpg"
|
||||
],
|
||||
"217": [
|
||||
"p217_img01.jpg"
|
||||
],
|
||||
"219": [
|
||||
"p219_img01.jpg",
|
||||
"p219_img02.jpg",
|
||||
"p219_img03.jpg",
|
||||
"p219_img04.jpg"
|
||||
],
|
||||
"226": [
|
||||
"p226_img01.jpg",
|
||||
"p226_img02.jpg"
|
||||
],
|
||||
"227": [
|
||||
"p227_img01.jpg"
|
||||
],
|
||||
"228": [
|
||||
"p228_img02.jpg"
|
||||
],
|
||||
"229": [
|
||||
"p229_img01.jpg"
|
||||
],
|
||||
"230": [
|
||||
"p230_img01.jpg"
|
||||
],
|
||||
"231": [
|
||||
"p231_img09.jpg"
|
||||
],
|
||||
"236": [
|
||||
"p236_img03.jpg"
|
||||
],
|
||||
"237": [
|
||||
"p237_img01.jpg",
|
||||
"p237_img02.jpg"
|
||||
],
|
||||
"135": ["p135_img01.jpg"],
|
||||
"136": ["p136_img01.jpg"],
|
||||
"140": ["p140_img02.jpg"],
|
||||
"141": ["p141_img01.jpg"],
|
||||
"143": ["p143_img03.jpg", "p143_img04.jpg", "p143_img07.jpg"],
|
||||
"144": ["p144_img01.jpg"],
|
||||
"150": ["p150_img01.jpg"],
|
||||
"151": ["p151_img01.jpg"],
|
||||
"155": ["p155_img01.jpg"],
|
||||
"156": ["p156_img01.jpg", "p156_img02.jpg", "p156_img03.jpg", "p156_img04.jpg", "p156_img05.jpg"],
|
||||
"158": ["p158_img01.jpg"],
|
||||
"160": ["p160_img01.jpg", "p160_img02.jpg"],
|
||||
"161": ["p161_img01.jpg", "p161_img02.jpg"],
|
||||
"170": ["p170_img05.jpg"],
|
||||
"180": ["p180_img01.jpg", "p180_img03.jpg"],
|
||||
"181": ["p181_img05.jpg", "p181_img06.jpg"],
|
||||
"190": ["p190_img01.jpg"],
|
||||
"192": ["p192_img01.jpg", "p192_img02.jpg"],
|
||||
"195": ["p195_img01.jpg"],
|
||||
"196": ["p196_img01.jpg", "p196_img02.jpg", "p196_img03.jpg"],
|
||||
"198": ["p198_img01.jpg"],
|
||||
"200": ["p200_img01.jpg"],
|
||||
"202": ["p202_img01.jpg"],
|
||||
"204": ["p204_img01.jpg"],
|
||||
"206": ["p206_img01.jpg"],
|
||||
"207": ["p207_img01.jpg"],
|
||||
"208": ["p208_img01.jpg"],
|
||||
"209": ["p209_img01.jpg", "p209_img02.jpg"],
|
||||
"210": ["p210_img01.jpg"],
|
||||
"212": ["p212_img01.jpg", "p212_img02.jpg", "p212_img03.jpg", "p212_img04.jpg"],
|
||||
"213": ["p213_img01.jpg"],
|
||||
"214": ["p214_img01.jpg", "p214_img02.jpg", "p214_img03.jpg", "p214_img04.jpg"],
|
||||
"217": ["p217_img01.jpg"],
|
||||
"219": ["p219_img01.jpg", "p219_img02.jpg", "p219_img03.jpg", "p219_img04.jpg"],
|
||||
"226": ["p226_img01.jpg", "p226_img02.jpg"],
|
||||
"227": ["p227_img01.jpg"],
|
||||
"228": ["p228_img02.jpg"],
|
||||
"229": ["p229_img01.jpg"],
|
||||
"230": ["p230_img01.jpg"],
|
||||
"231": ["p231_img09.jpg"],
|
||||
"236": ["p236_img03.jpg"],
|
||||
"237": ["p237_img01.jpg", "p237_img02.jpg"],
|
||||
"238": [
|
||||
"p238_img01.jpg",
|
||||
"p238_img02.jpg",
|
||||
@ -427,22 +188,10 @@
|
||||
"p238_img05.jpg",
|
||||
"p238_img06.jpg"
|
||||
],
|
||||
"239": [
|
||||
"p239_img01.jpg",
|
||||
"p239_img02.jpg",
|
||||
"p239_img03.jpg"
|
||||
],
|
||||
"242": [
|
||||
"p242_img02.jpg",
|
||||
"p242_img03.jpg",
|
||||
"p242_img04.jpg"
|
||||
],
|
||||
"244": [
|
||||
"p244_img01.jpg"
|
||||
],
|
||||
"245": [
|
||||
"p245_img01.jpg"
|
||||
],
|
||||
"239": ["p239_img01.jpg", "p239_img02.jpg", "p239_img03.jpg"],
|
||||
"242": ["p242_img02.jpg", "p242_img03.jpg", "p242_img04.jpg"],
|
||||
"244": ["p244_img01.jpg"],
|
||||
"245": ["p245_img01.jpg"],
|
||||
"248": [
|
||||
"p248_img01.jpg",
|
||||
"p248_img02.jpg",
|
||||
@ -457,140 +206,39 @@
|
||||
"p248_img13.jpg",
|
||||
"p248_img14.jpg"
|
||||
],
|
||||
"249": [
|
||||
"p249_img01.jpg"
|
||||
],
|
||||
"250": [
|
||||
"p250_img01.jpg"
|
||||
],
|
||||
"254": [
|
||||
"p254_img01.jpg"
|
||||
],
|
||||
"257": [
|
||||
"p257_img01.jpg",
|
||||
"p257_img02.jpg",
|
||||
"p257_img03.jpg",
|
||||
"p257_img04.jpg"
|
||||
],
|
||||
"259": [
|
||||
"p259_img01.jpg"
|
||||
],
|
||||
"262": [
|
||||
"p262_img01.jpg"
|
||||
],
|
||||
"263": [
|
||||
"p263_img04.jpg"
|
||||
],
|
||||
"264": [
|
||||
"p264_img01.jpg",
|
||||
"p264_img02.jpg"
|
||||
],
|
||||
"266": [
|
||||
"p266_img01.jpg",
|
||||
"p266_img02.jpg"
|
||||
],
|
||||
"267": [
|
||||
"p267_img03.jpg",
|
||||
"p267_img04.jpg",
|
||||
"p267_img05.jpg"
|
||||
],
|
||||
"268": [
|
||||
"p268_img01.jpg"
|
||||
],
|
||||
"272": [
|
||||
"p272_img01.jpg",
|
||||
"p272_img02.jpg",
|
||||
"p272_img03.jpg",
|
||||
"p272_img04.jpg"
|
||||
],
|
||||
"273": [
|
||||
"p273_img01.jpg"
|
||||
],
|
||||
"274": [
|
||||
"p274_img01.jpg"
|
||||
],
|
||||
"275": [
|
||||
"p275_img01.jpg",
|
||||
"p275_img02.jpg"
|
||||
],
|
||||
"276": [
|
||||
"p276_img01.jpg",
|
||||
"p276_img02.jpg"
|
||||
],
|
||||
"278": [
|
||||
"p278_img01.jpg",
|
||||
"p278_img02.jpg",
|
||||
"p278_img03.jpg"
|
||||
],
|
||||
"279": [
|
||||
"p279_img01.jpg"
|
||||
],
|
||||
"280": [
|
||||
"p280_img01.jpg"
|
||||
],
|
||||
"281": [
|
||||
"p281_img01.jpg"
|
||||
],
|
||||
"283": [
|
||||
"p283_img01.jpg",
|
||||
"p283_img02.jpg",
|
||||
"p283_img03.jpg",
|
||||
"p283_img04.jpg",
|
||||
"p283_img05.jpg"
|
||||
],
|
||||
"286": [
|
||||
"p286_img01.jpg",
|
||||
"p286_img02.jpg"
|
||||
],
|
||||
"287": [
|
||||
"p287_img01.jpg",
|
||||
"p287_img02.jpg",
|
||||
"p287_img03.jpg",
|
||||
"p287_img04.jpg"
|
||||
],
|
||||
"290": [
|
||||
"p290_img01.jpg",
|
||||
"p290_img02.jpg",
|
||||
"p290_img03.jpg"
|
||||
],
|
||||
"293": [
|
||||
"p293_img03.jpg"
|
||||
],
|
||||
"294": [
|
||||
"p294_img01.jpg",
|
||||
"p294_img02.jpg",
|
||||
"p294_img03.jpg",
|
||||
"p294_img04.jpg"
|
||||
],
|
||||
"298": [
|
||||
"p298_img01.jpg",
|
||||
"p298_img02.jpg"
|
||||
],
|
||||
"306": [
|
||||
"p306_img01.jpg",
|
||||
"p306_img02.jpg"
|
||||
],
|
||||
"307": [
|
||||
"p307_img03.jpg"
|
||||
],
|
||||
"309": [
|
||||
"p309_img01.jpg",
|
||||
"p309_img02.jpg"
|
||||
],
|
||||
"312": [
|
||||
"p312_img01.jpg"
|
||||
],
|
||||
"314": [
|
||||
"p314_img01.jpg"
|
||||
],
|
||||
"315": [
|
||||
"p315_img01.jpg"
|
||||
],
|
||||
"316": [
|
||||
"p316_img01.jpg"
|
||||
],
|
||||
"337": [
|
||||
"p337_img01.jpg",
|
||||
"p337_img02.jpg"
|
||||
]
|
||||
"249": ["p249_img01.jpg"],
|
||||
"250": ["p250_img01.jpg"],
|
||||
"254": ["p254_img01.jpg"],
|
||||
"257": ["p257_img01.jpg", "p257_img02.jpg", "p257_img03.jpg", "p257_img04.jpg"],
|
||||
"259": ["p259_img01.jpg"],
|
||||
"262": ["p262_img01.jpg"],
|
||||
"263": ["p263_img04.jpg"],
|
||||
"264": ["p264_img01.jpg", "p264_img02.jpg"],
|
||||
"266": ["p266_img01.jpg", "p266_img02.jpg"],
|
||||
"267": ["p267_img03.jpg", "p267_img04.jpg", "p267_img05.jpg"],
|
||||
"268": ["p268_img01.jpg"],
|
||||
"272": ["p272_img01.jpg", "p272_img02.jpg", "p272_img03.jpg", "p272_img04.jpg"],
|
||||
"273": ["p273_img01.jpg"],
|
||||
"274": ["p274_img01.jpg"],
|
||||
"275": ["p275_img01.jpg", "p275_img02.jpg"],
|
||||
"276": ["p276_img01.jpg", "p276_img02.jpg"],
|
||||
"278": ["p278_img01.jpg", "p278_img02.jpg", "p278_img03.jpg"],
|
||||
"279": ["p279_img01.jpg"],
|
||||
"280": ["p280_img01.jpg"],
|
||||
"281": ["p281_img01.jpg"],
|
||||
"283": ["p283_img01.jpg", "p283_img02.jpg", "p283_img03.jpg", "p283_img04.jpg", "p283_img05.jpg"],
|
||||
"286": ["p286_img01.jpg", "p286_img02.jpg"],
|
||||
"287": ["p287_img01.jpg", "p287_img02.jpg", "p287_img03.jpg", "p287_img04.jpg"],
|
||||
"290": ["p290_img01.jpg", "p290_img02.jpg", "p290_img03.jpg"],
|
||||
"293": ["p293_img03.jpg"],
|
||||
"294": ["p294_img01.jpg", "p294_img02.jpg", "p294_img03.jpg", "p294_img04.jpg"],
|
||||
"298": ["p298_img01.jpg", "p298_img02.jpg"],
|
||||
"306": ["p306_img01.jpg", "p306_img02.jpg"],
|
||||
"307": ["p307_img03.jpg"],
|
||||
"309": ["p309_img01.jpg", "p309_img02.jpg"],
|
||||
"312": ["p312_img01.jpg"],
|
||||
"314": ["p314_img01.jpg"],
|
||||
"315": ["p315_img01.jpg"],
|
||||
"316": ["p316_img01.jpg"],
|
||||
"337": ["p337_img01.jpg", "p337_img02.jpg"]
|
||||
}
|
||||
@ -1,135 +1,158 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google'
|
||||
import type { MainTab } from '@common/types/navigation'
|
||||
import { MainLayout } from '@common/components/layout/MainLayout'
|
||||
import { LoginPage } from '@common/components/auth/LoginPage'
|
||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
import { OilSpillView } from '@tabs/prediction'
|
||||
import { ReportsView } from '@tabs/reports'
|
||||
import { HNSView } from '@tabs/hns'
|
||||
import { AerialView } from '@tabs/aerial'
|
||||
import { AssetsView } from '@tabs/assets'
|
||||
import { BoardView } from '@tabs/board'
|
||||
import { WeatherView } from '@tabs/weather'
|
||||
import { IncidentsView } from '@tabs/incidents'
|
||||
import { AdminView } from '@tabs/admin'
|
||||
import { ScatView } from '@tabs/scat'
|
||||
import { RescueView } from '@tabs/rescue'
|
||||
import { DesignPage } from '@/pages/design/DesignPage'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
import type { MainTab } from '@/types/navigation';
|
||||
import { MainLayout } from '@components/common/layout/MainLayout';
|
||||
import { LoginPage } from '@components/common/auth/LoginPage';
|
||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { useMenuStore } from '@common/store/menuStore';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { API_BASE_URL } from '@common/services/api';
|
||||
import { OilSpillView } from '@components/prediction';
|
||||
import { ReportsView } from '@components/reports';
|
||||
import { HNSView } from '@components/hns';
|
||||
import { AerialView } from '@components/aerial';
|
||||
import { AssetsView } from '@components/assets';
|
||||
import { BoardView } from '@components/board';
|
||||
import { WeatherView } from '@components/weather';
|
||||
import { IncidentsView } from '@components/incidents';
|
||||
import { AdminView } from '@components/admin';
|
||||
import { ScatView } from '@components/scat';
|
||||
import { RescueView } from '@components/rescue';
|
||||
import { DesignPage } from '@/pages/design/DesignPage';
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||
|
||||
function App() {
|
||||
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
|
||||
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
|
||||
const { loadMenuConfig } = useMenuStore()
|
||||
const { loadMapTypes } = useMapStore()
|
||||
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction');
|
||||
const { isAuthenticated, isLoading, checkSession } = useAuthStore();
|
||||
const { loadMenuConfig } = useMenuStore();
|
||||
const { loadMapTypes } = useMapStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkSession()
|
||||
}, [checkSession])
|
||||
checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadMenuConfig()
|
||||
loadMapTypes()
|
||||
loadMenuConfig();
|
||||
loadMapTypes();
|
||||
}
|
||||
}, [isAuthenticated, loadMenuConfig, loadMapTypes])
|
||||
}, [isAuthenticated, loadMenuConfig, loadMapTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
registerMainTabSwitcher(setActiveMainTab)
|
||||
}, [])
|
||||
registerMainTabSwitcher(setActiveMainTab);
|
||||
}, []);
|
||||
|
||||
// 감사 로그: 탭 이동 기록
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
if (!isAuthenticated) return;
|
||||
const blob = new Blob([JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||
}, [activeMainTab, isAuthenticated]);
|
||||
|
||||
// 세션 확인 중 스플래시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw', height: '100vh', display: 'flex',
|
||||
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#001028', gap: 16,
|
||||
}}>
|
||||
<img src="/wing_logo_text_white.svg" alt="WING" style={{ height: 28, opacity: 0.8 }} />
|
||||
<div style={{
|
||||
width: 32, height: 32, border: '3px solid rgba(6,182,212,0.2)',
|
||||
borderTop: '3px solid rgba(6,182,212,0.8)', borderRadius: '50%',
|
||||
animation: 'loginSpin 0.8s linear infinite',
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-base)',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/wing_logo_text_white.svg"
|
||||
alt="WING"
|
||||
className="wing-logo"
|
||||
style={{ height: 28, opacity: 0.8 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: '3px solid rgba(6,182,212,0.2)',
|
||||
borderTop: '3px solid rgba(6,182,212,0.8)',
|
||||
borderRadius: '50%',
|
||||
animation: 'loginSpin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 미인증 → 로그인 페이지
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
switch (activeMainTab) {
|
||||
case 'prediction':
|
||||
return <OilSpillView />
|
||||
return <OilSpillView />;
|
||||
case 'reports':
|
||||
return <ReportsView />
|
||||
return <ReportsView />;
|
||||
case 'hns':
|
||||
return <HNSView />
|
||||
return <HNSView />;
|
||||
case 'aerial':
|
||||
return <AerialView />
|
||||
return <AerialView />;
|
||||
case 'assets':
|
||||
return <AssetsView />
|
||||
return <AssetsView />;
|
||||
case 'board':
|
||||
return <BoardView />
|
||||
return <BoardView />;
|
||||
case 'weather':
|
||||
return <WeatherView />
|
||||
return <WeatherView />;
|
||||
case 'incidents':
|
||||
return <IncidentsView />
|
||||
return <IncidentsView />;
|
||||
case 'scat':
|
||||
return <ScatView />
|
||||
return <ScatView />;
|
||||
case 'admin':
|
||||
return <AdminView />
|
||||
return <AdminView />;
|
||||
case 'rescue':
|
||||
return <RescueView />
|
||||
return <RescueView />;
|
||||
case 'monitor':
|
||||
return null
|
||||
return null;
|
||||
default:
|
||||
return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled">
|
||||
준비 중입니다...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/design" element={<DesignPage />} />
|
||||
<Route path="*" element={
|
||||
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
|
||||
{renderView()}
|
||||
</MainLayout>
|
||||
} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
|
||||
{renderView()}
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
return <App />
|
||||
return <App />;
|
||||
}
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||
<App />
|
||||
</GoogleOAuthProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default AppWithProviders
|
||||
export default AppWithProviders;
|
||||
|
||||
@ -1,325 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
/* Demo accounts (개발 모드 전용) */
|
||||
const DEMO_ACCOUNTS = [
|
||||
{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' },
|
||||
]
|
||||
|
||||
export function LoginPage() {
|
||||
const [userId, setUserId] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const { login, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore()
|
||||
const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('wing_remember')
|
||||
if (saved) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUserId(saved)
|
||||
setRemember(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGoogleSuccess = async (response: CredentialResponse) => {
|
||||
if (response.credential) {
|
||||
clearError()
|
||||
try {
|
||||
await googleLogin(response.credential)
|
||||
} catch {
|
||||
// 에러는 authStore에서 관리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
clearError()
|
||||
|
||||
if (!userId.trim() || !password.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await login(userId.trim(), password)
|
||||
if (remember) {
|
||||
localStorage.setItem('wing_remember', userId.trim())
|
||||
} else {
|
||||
localStorage.removeItem('wing_remember')
|
||||
}
|
||||
} catch {
|
||||
// 에러는 authStore에서 관리
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex overflow-hidden relative bg-[#001028]">
|
||||
{/* Background image */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
backgroundImage: 'url(/24.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}} />
|
||||
{/* Overlay */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(90deg, rgba(0,8,20,0.4) 0%, rgba(0,8,20,0.2) 25%, rgba(0,8,20,0.05) 50%, rgba(0,8,20,0.15) 75%, rgba(0,8,20,0.5) 100%)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(0,6,18,0.15) 0%, transparent 30%, transparent 70%, rgba(0,6,18,0.4) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Center: Login Form */}
|
||||
<div className="w-full flex flex-col items-start justify-center relative z-[1] px-[120px] py-[40px]" style={{ paddingLeft: 120, paddingRight: 50 }}>
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-9">
|
||||
<img
|
||||
src="/wing_logo_text_white.svg"
|
||||
alt="WING 해양환경 위기대응 통합시스템"
|
||||
className="h-7 mx-auto block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div style={{
|
||||
padding: '32px 28px', borderRadius: 12,
|
||||
background: 'linear-gradient(180deg, rgba(4,16,36,0.88) 0%, rgba(2,10,26,0.92) 100%)',
|
||||
border: '1px solid rgba(60,120,180,0.12)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
boxShadow: '0 8px 48px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* User ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
아이디
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute text-sm text-text-3 pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => { setUserId(e.target.value); clearError() }}
|
||||
placeholder="사용자 아이디 입력"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="w-full bg-bg-2 border border-border rounded-md text-[13px] outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1.5" style={{ letterSpacing: '0.3px' }}>
|
||||
비밀번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute text-sm text-text-3 pointer-events-none" style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); clearError() }}
|
||||
placeholder="비밀번호 입력"
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-bg-2 border border-border rounded-md text-[13px] outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)'
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-text-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="accent-[var(--cyan)]"
|
||||
/>
|
||||
아이디 저장
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-primary-cyan cursor-pointer bg-transparent border-none"
|
||||
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||
>
|
||||
비밀번호 찾기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending approval */}
|
||||
{pendingMessage && (
|
||||
<div className="flex items-start gap-2 text-[11px] rounded-sm mb-4" style={{
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)',
|
||||
color: '#67e8f9',
|
||||
}}>
|
||||
<span className="text-sm shrink-0 mt-px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</span>
|
||||
<span>{pendingMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] rounded-sm mb-4" style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
|
||||
color: '#f87171',
|
||||
}}>
|
||||
<span className="text-[13px]">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
||||
</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login button */}
|
||||
<button type="submit" disabled={isLoading} className="w-full text-primary-cyan text-sm font-bold rounded-md border"
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(6,182,212,0.15)'
|
||||
: 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))',
|
||||
borderColor: 'rgba(6,182,212,0.3)',
|
||||
cursor: isLoading ? 'wait' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 4px 16px rgba(6,182,212,0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.3), rgba(59,130,246,0.2))'
|
||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(6,182,212,0.15)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))'
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(6,182,212,0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span style={{
|
||||
width: 14, height: 14, border: '2px solid rgba(6,182,212,0.3)',
|
||||
borderTop: '2px solid var(--cyan)', borderRadius: '50%',
|
||||
animation: 'loginSpin 0.8s linear infinite', display: 'inline-block',
|
||||
}} />
|
||||
인증 중...
|
||||
</span>
|
||||
) : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
<span className="text-[9px] text-text-3">또는</span>
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
</div>
|
||||
|
||||
{/* Google / Certificate */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{GOOGLE_ENABLED && (
|
||||
<div className="flex justify-center rounded-md overflow-hidden">
|
||||
<GoogleLogin
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={() => { /* 팝업 닫힘 등 — 별도 처리 불필요 */ }}
|
||||
theme="filled_black"
|
||||
size="large"
|
||||
shape="rectangular"
|
||||
width={304}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="w-full rounded-md bg-bg-3 border border-border text-text-2 text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
|
||||
style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
공무원 인증서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Demo accounts info (DEV only) */}
|
||||
{import.meta.env.DEV && (
|
||||
<div className="rounded-md mt-6" style={{
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)',
|
||||
}}>
|
||||
<div className="text-[9px] font-bold text-primary-cyan mb-1.5">
|
||||
데모 계정
|
||||
</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{DEMO_ACCOUNTS.map((acc) => (
|
||||
<div key={acc.id}
|
||||
onClick={() => { setUserId(acc.id); setPassword(acc.password); clearError() }}
|
||||
className="flex justify-between items-center cursor-pointer rounded"
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(6,182,212,0.06)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<span className="text-[9px] text-text-2 font-mono">
|
||||
{acc.id} / {acc.password}
|
||||
</span>
|
||||
<span className="text-[8px] text-text-3">
|
||||
{acc.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* end form card */}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-[9px] text-text-3 mt-6 leading-[1.6]">
|
||||
<div>WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템</div>
|
||||
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
|
||||
© 2026 Korea Coast Guard. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,265 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { Layer } from '@common/services/layerService'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
|
||||
'#3b82f6','#8b5cf6','#a855f7','#ec4899','#f43f5e',
|
||||
'#64748b','#ffffff',
|
||||
]
|
||||
|
||||
interface LayerTreeProps {
|
||||
layers: (Layer & { children?: Layer[] })[]
|
||||
enabledLayers: Set<string>
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void
|
||||
layerColors?: Record<string, string>
|
||||
onColorChange?: (layerId: string, color: string) => void
|
||||
}
|
||||
|
||||
export function LayerTree({ layers, enabledLayers, onToggleLayer, layerColors = {}, onColorChange }: LayerTreeProps) {
|
||||
const allLeafIds = getAllLeafIds(layers)
|
||||
const allEnabled = allLeafIds.length > 0 && allLeafIds.every(id => enabledLayers.has(id))
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const newState = !allEnabled
|
||||
getAllNodeIds(layers).forEach(id => onToggleLayer(id, newState))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-[10px] font-semibold text-text-3">
|
||||
전체 레이어
|
||||
</span>
|
||||
<div
|
||||
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
|
||||
onClick={handleToggleAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layers.map(layer => (
|
||||
<LayerNode
|
||||
key={layer.id}
|
||||
layer={layer}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerColors={layerColors}
|
||||
onColorChange={onColorChange}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAllLeafIds(layers: (Layer & { children?: Layer[] })[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const l of layers) {
|
||||
if (l.children && l.children.length > 0) {
|
||||
ids.push(...getAllLeafIds(l.children))
|
||||
} else {
|
||||
ids.push(l.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function getAllNodeIds(layers: (Layer & { children?: Layer[] })[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const l of layers) {
|
||||
ids.push(l.id)
|
||||
if (l.children && l.children.length > 0) {
|
||||
ids.push(...getAllNodeIds(l.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
interface LayerNodeProps {
|
||||
layer: Layer & { children?: Layer[] }
|
||||
enabledLayers: Set<string>
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void
|
||||
layerColors: Record<string, string>
|
||||
onColorChange?: (layerId: string, color: string) => void
|
||||
depth: number
|
||||
}
|
||||
|
||||
function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorChange, depth }: LayerNodeProps) {
|
||||
const [expanded, setExpanded] = useState(depth < 1)
|
||||
const hasChildren = layer.children && layer.children.length > 0
|
||||
const isEnabled = enabledLayers.has(layer.id)
|
||||
|
||||
const getAllDescendantIds = (node: Layer & { children?: Layer[] }): string[] => {
|
||||
const ids: string[] = []
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
ids.push(child.id)
|
||||
ids.push(...getAllDescendantIds(child))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const handleSwitchClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const newState = !isEnabled
|
||||
onToggleLayer(layer.id, newState)
|
||||
if (hasChildren) {
|
||||
getAllDescendantIds(layer).forEach(id => onToggleLayer(id, newState))
|
||||
}
|
||||
}
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (hasChildren) setExpanded(!expanded)
|
||||
}
|
||||
|
||||
// depth 0 — 대분류 (lyr-g1 / lyr-h1)
|
||||
if (depth === 0) {
|
||||
return (
|
||||
<div className="lyr-g1">
|
||||
<div className="lyr-h1" onClick={handleHeaderClick}>
|
||||
{hasChildren ? (
|
||||
<span className={`lyr-arr ${expanded ? 'open' : ''}`}>▶</span>
|
||||
) : (
|
||||
<span className="lyr-arr invisible">▶</span>
|
||||
)}
|
||||
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
|
||||
{layer.icon && <span>{layer.icon}</span>}
|
||||
{layer.name}
|
||||
{layer.count !== undefined && (
|
||||
<span className="lyr-h1-cnt">{layer.count.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className={`lyr-c1 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '800px' : '0' }}>
|
||||
{layer.children!.map(child => (
|
||||
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// depth 1 — 중분류 (lyr-g2 / lyr-h2) 또는 leaf
|
||||
if (depth === 1) {
|
||||
// 자식 없는 depth 1 → leaf로 렌더링 (해양관측·기상 하위 등)
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
|
||||
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
|
||||
{layer.icon && <span>{layer.icon}</span>}
|
||||
<span className="flex-1">{layer.name}</span>
|
||||
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
|
||||
{onColorChange && (
|
||||
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lyr-g2">
|
||||
<div className="lyr-h2" onClick={handleHeaderClick}>
|
||||
{hasChildren ? (
|
||||
<span className={`lyr-arr ${expanded ? 'open' : ''}`}>▶</span>
|
||||
) : (
|
||||
<span className="lyr-arr invisible">▶</span>
|
||||
)}
|
||||
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
|
||||
{layer.icon && <span>{layer.icon}</span>}
|
||||
{layer.name}
|
||||
{layer.count !== undefined && (
|
||||
<span className="lyr-h2-cnt">{layer.count.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`lyr-c2 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '600px' : '0' }}>
|
||||
{layer.children!.map(child => (
|
||||
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// depth 2+ leaf — 색상 스와치 포함
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
|
||||
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
|
||||
{layer.icon && <span>{layer.icon}</span>}
|
||||
<span className="flex-1">{layer.name}</span>
|
||||
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
|
||||
{onColorChange && (
|
||||
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// depth 2+ with children
|
||||
return (
|
||||
<div>
|
||||
<div className="lyr-t gap-1.5">
|
||||
<span className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-[7px] w-[10px] text-center`} onClick={handleHeaderClick}>▶</span>
|
||||
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
|
||||
{layer.icon && <span>{layer.icon}</span>}
|
||||
<span onClick={handleHeaderClick} className="cursor-pointer flex-1">{layer.name}</span>
|
||||
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="pl-4">
|
||||
{layer.children!.map(child => (
|
||||
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 색상 스와치 + 피커
|
||||
function ColorSwatch({ color, onChange }: { color?: string; onChange: (c: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<div
|
||||
className={`lyr-csw ${color ? 'has-color' : ''}`}
|
||||
style={color ? { borderColor: color, background: color } : {}}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
/>
|
||||
{open && (
|
||||
<div className="lyr-cpop show" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="lyr-cpr">
|
||||
{PRESET_COLORS.map(pc => (
|
||||
<div
|
||||
key={pc}
|
||||
className={`lyr-cpr-i ${color === pc ? 'sel' : ''}`}
|
||||
style={{ background: pc }}
|
||||
onClick={() => { onChange(pc); setOpen(false) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="lyr-ccustom">
|
||||
<label>커스텀</label>
|
||||
<input
|
||||
type="color"
|
||||
value={color || '#06b6d4'}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(false) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { TopBar } from './TopBar'
|
||||
import { SubMenuBar } from './SubMenuBar'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode
|
||||
activeMainTab: MainTab
|
||||
onMainTabChange: (tab: MainTab) => void
|
||||
}
|
||||
|
||||
export function MainLayout({ children, activeMainTab, onMainTabChange }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-bg-0 text-text-1 overflow-hidden">
|
||||
{/* Top Navigation - Level 1 */}
|
||||
<TopBar activeTab={activeMainTab} onTabChange={onMainTabChange} />
|
||||
|
||||
{/* Sub Menu Navigation - Level 2 */}
|
||||
<SubMenuBar activeMainTab={activeMainTab} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { useSubMenu } from '../../hooks/useSubMenu'
|
||||
|
||||
interface SubMenuBarProps {
|
||||
activeMainTab: MainTab
|
||||
}
|
||||
|
||||
export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
|
||||
const { activeSubTab, setActiveSubTab, subMenuConfig } = useSubMenu(activeMainTab)
|
||||
|
||||
// 서브 메뉴가 없는 탭은 표시하지 않음
|
||||
if (!subMenuConfig || subMenuConfig.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border bg-bg-1 shrink-0">
|
||||
<div className="flex px-5">
|
||||
{subMenuConfig.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSubTab(item.id)}
|
||||
className={`
|
||||
px-4 py-2.5 text-[13px] font-bold transition-all duration-200
|
||||
font-korean tracking-tight
|
||||
${
|
||||
activeSubTab === item.id
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.12)] border-b-2 border-primary-cyan'
|
||||
: 'text-text-2 hover:text-text-1 hover:bg-[rgba(255,255,255,0.06)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="mr-1.5">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useMenuStore } from '../../store/menuStore'
|
||||
import { useMapStore } from '../../store/mapStore'
|
||||
import UserManualPopup from '../ui/UserManualPopup'
|
||||
|
||||
interface TopBarProps {
|
||||
activeTab: MainTab
|
||||
onTabChange: (tab: MainTab) => void
|
||||
}
|
||||
|
||||
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
const [showQuickMenu, setShowQuickMenu] = useState(false)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
const quickMenuRef = useRef<HTMLDivElement>(null)
|
||||
const { hasPermission, user, logout } = useAuthStore()
|
||||
const { menuConfig, isLoaded } = useMenuStore()
|
||||
const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore()
|
||||
|
||||
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'incidents'])
|
||||
const isMapTab = MAP_TABS.has(activeTab)
|
||||
|
||||
const handleToggleMeasure = (mode: 'distance' | 'area') => {
|
||||
if (!isMapTab) return;
|
||||
setMeasureMode(measureMode === mode ? null : mode);
|
||||
setShowQuickMenu(false);
|
||||
};
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (!isLoaded || menuConfig.length === 0) return []
|
||||
|
||||
return menuConfig
|
||||
.filter((m) => m.enabled && hasPermission(m.id))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}, [hasPermission, user?.permissions, menuConfig, isLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (quickMenuRef.current && !quickMenuRef.current.contains(e.target as Node)) setShowQuickMenu(false)
|
||||
}
|
||||
if (showQuickMenu) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showQuickMenu])
|
||||
|
||||
return (
|
||||
<div className="h-[52px] bg-bg-1 border-b border-border flex items-center justify-between px-5 relative z-[100]">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={() => tabs[0] && onTabChange(tabs[0].id as MainTab)}
|
||||
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="홈으로 이동"
|
||||
>
|
||||
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-border-light" />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0.5">
|
||||
{tabs.map((tab) => {
|
||||
const isIncident = tab.id === 'incidents'
|
||||
const isMonitor = tab.id === 'monitor'
|
||||
const handleClick = () => {
|
||||
if (isMonitor) {
|
||||
window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank')
|
||||
} else {
|
||||
onTabChange(tab.id as MainTab)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={handleClick}
|
||||
title={tab.label}
|
||||
className={`
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||
font-korean tracking-[0.2px]
|
||||
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
|
||||
${isMonitor ? 'border-l border-l-[rgba(239,68,68,0.25)] ml-1 flex items-center gap-1.5' : ''}
|
||||
${
|
||||
isMonitor
|
||||
? 'text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
|
||||
: activeTab === tab.id
|
||||
? isIncident
|
||||
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
|
||||
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
|
||||
: isIncident
|
||||
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
|
||||
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isMonitor ? (
|
||||
<>
|
||||
<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" />
|
||||
{tab.label}
|
||||
</span>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
<span className="hidden xl:inline">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-status-red animate-pulse">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
||||
사고 진행중
|
||||
</div>
|
||||
|
||||
{/* Icon Buttons */}
|
||||
<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>
|
||||
{hasPermission('admin') && (
|
||||
<button
|
||||
onClick={() => onTabChange('admin')}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
activeTab === 'admin'
|
||||
? '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>
|
||||
)}
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 pl-2 border-l border-border">
|
||||
<span className="text-[11px] text-text-2 font-korean">{user.name}</span>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-bg-hover hover:text-text-1 transition-all font-korean"
|
||||
title="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Menu */}
|
||||
<div ref={quickMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowQuickMenu(!showQuickMenu)}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
showQuickMenu
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="2" y1="4" x2="14" y2="4" /><line x1="2" y1="8" x2="14" y2="8" /><line x1="2" y1="12" x2="14" y2="12" /></svg>
|
||||
</button>
|
||||
|
||||
{showQuickMenu && (
|
||||
<div className="absolute top-[44px] right-0 w-[220px] bg-[rgba(18,25,41,0.97)] backdrop-blur-xl border border-border rounded-lg shadow-2xl z-[200] py-2 font-korean">
|
||||
{/* 거리·면적 계산 */}
|
||||
{/* <div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<span>📐</span> 거리·면적 계산
|
||||
</div> */}
|
||||
<button
|
||||
onClick={() => handleToggleMeasure('distance')}
|
||||
disabled={!isMapTab}
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
|
||||
!isMapTab
|
||||
? 'text-text-3 opacity-40 cursor-not-allowed'
|
||||
: measureMode === 'distance'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px]">↗</span> 거리 재기
|
||||
{measureMode === 'distance' && <span className="ml-auto text-[10px] text-primary-cyan">활성</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleMeasure('area')}
|
||||
disabled={!isMapTab}
|
||||
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
|
||||
!isMapTab
|
||||
? 'text-text-3 opacity-40 cursor-not-allowed'
|
||||
: measureMode === 'area'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
|
||||
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px]">⭕</span> 면적 재기
|
||||
{measureMode === 'area' && <span className="ml-auto text-[10px] text-primary-cyan">활성</span>}
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
|
||||
{/* 출력 */}
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<span>🖨</span> 출력
|
||||
</div>
|
||||
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
|
||||
<span className="text-[13px]">📸</span> 화면 캡쳐 다운로드
|
||||
</button>
|
||||
<button onClick={() => window.print()} className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
|
||||
<span className="text-[13px]">🖨</span> 인쇄
|
||||
</button>
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
|
||||
{/* 지도 유형 */}
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<span>🗺</span> 지도 유형
|
||||
</div>
|
||||
{mapTypes.map(item => (
|
||||
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-[13px]">🗺</span> {item.mapNm}
|
||||
</span>
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
|
||||
{/* 매뉴얼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowManual(true)
|
||||
setShowQuickMenu(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all"
|
||||
>
|
||||
<span className="text-[13px]">📖</span> 사용자 매뉴얼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 매뉴얼 팝업 */}
|
||||
<UserManualPopup isOpen={showManual} onClose={() => setShowManual(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,212 +0,0 @@
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
|
||||
interface BacktrackReplayBarProps {
|
||||
isPlaying: boolean
|
||||
replayFrame: number
|
||||
totalFrames: number
|
||||
replaySpeed: number
|
||||
onTogglePlay: () => void
|
||||
onSeek: (frame: number) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onClose: () => void
|
||||
replayShips: ReplayShip[]
|
||||
collisionEvent: CollisionEvent | null
|
||||
replayTimeRange?: { start: string; end: string }
|
||||
}
|
||||
|
||||
export function BacktrackReplayBar({
|
||||
isPlaying,
|
||||
replayFrame,
|
||||
totalFrames,
|
||||
replaySpeed,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
onSpeedChange,
|
||||
onClose,
|
||||
replayShips,
|
||||
collisionEvent,
|
||||
replayTimeRange,
|
||||
}: BacktrackReplayBarProps) {
|
||||
const progress = (replayFrame / totalFrames) * 100
|
||||
|
||||
// 타임 계산
|
||||
let startLabel: string
|
||||
let endLabel: string
|
||||
let currentTimeLabel: string
|
||||
|
||||
if (replayTimeRange) {
|
||||
const startMs = new Date(replayTimeRange.start).getTime()
|
||||
const endMs = new Date(replayTimeRange.end).getTime()
|
||||
const currentMs = startMs + (replayFrame / totalFrames) * (endMs - startMs)
|
||||
const fmt = (ms: number) => {
|
||||
const d = new Date(ms + 9 * 3600000) // KST
|
||||
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
const hh = String(d.getUTCHours()).padStart(2, '0')
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, '0')
|
||||
return { date: `${mo}-${day}`, time: `${hh}:${mm}` }
|
||||
}
|
||||
const startFmt = fmt(startMs)
|
||||
const endFmt = fmt(endMs)
|
||||
const curFmt = fmt(currentMs)
|
||||
startLabel = `${startFmt.date} ${startFmt.time}`
|
||||
endLabel = `${endFmt.date} ${endFmt.time}`
|
||||
currentTimeLabel = `${curFmt.date} ${curFmt.time} KST`
|
||||
} else {
|
||||
// 기존 하드코딩 폴백
|
||||
const hours = 18.5 + (replayFrame / totalFrames) * 12
|
||||
const displayHours = hours >= 24 ? hours - 24 : hours
|
||||
const h = Math.floor(displayHours)
|
||||
const m = Math.round((displayHours - h) * 60)
|
||||
const dayLabel = hours >= 24 ? '02-10' : '02-09'
|
||||
startLabel = '18:30'
|
||||
endLabel = '06:30'
|
||||
currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`
|
||||
}
|
||||
|
||||
const handleSeekClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
onSeek(Math.round(pct * totalFrames))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute flex flex-col"
|
||||
style={{
|
||||
bottom: '80px', left: '50%', transform: 'translateX(-50%)',
|
||||
minWidth: '480px', maxWidth: '680px', width: '60%',
|
||||
background: 'rgba(10,15,25,0.92)', backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '12px',
|
||||
padding: '12px 18px', zIndex: 1200,
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full bg-primary-purple"
|
||||
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
역추적 리플레이
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Speed buttons */}
|
||||
{[1, 2, 4].map((spd) => (
|
||||
<button
|
||||
key={spd}
|
||||
onClick={() => onSpeedChange(spd)}
|
||||
className={`bt-spd-btn ${replaySpeed === spd ? 'active' : ''}`}
|
||||
>
|
||||
{spd}×
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-2" />
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-status-red cursor-pointer font-bold"
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '6px', fontSize: '10px',
|
||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
||||
style={{
|
||||
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
|
||||
border: `2px solid ${isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.4)'}`,
|
||||
color: isPlaying ? '#fff' : 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="relative h-5 flex items-center cursor-pointer"
|
||||
onClick={handleSeekClick}
|
||||
>
|
||||
<div
|
||||
className="w-full h-1 bg-border relative overflow-visible"
|
||||
style={{ borderRadius: '2px' }}
|
||||
>
|
||||
{/* Fill */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--purple), var(--cyan))',
|
||||
borderRadius: '2px', transition: 'width 0.05s',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Collision marker */}
|
||||
{collisionEvent && (
|
||||
<div
|
||||
className="absolute text-[10px] cursor-pointer"
|
||||
style={{
|
||||
top: '-14px',
|
||||
left: `${collisionEvent.progressPercent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
title={collisionEvent.timeLabel}
|
||||
>
|
||||
💥
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className="absolute top-1/2 w-3.5 h-3.5 bg-white rounded-full"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: '3px solid var(--purple)',
|
||||
boxShadow: '0 0 8px rgba(168,85,247,0.4)',
|
||||
zIndex: 2, transition: 'left 0.05s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time labels */}
|
||||
<div className="flex justify-between text-[9px] font-mono">
|
||||
<span className="text-text-3">{startLabel}</span>
|
||||
<span className="font-semibold text-primary-purple">{currentTimeLabel}</span>
|
||||
<span className="text-text-3">{endLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend row */}
|
||||
<div className="flex items-center gap-[14px] pt-1 border-t border-border">
|
||||
{replayShips.map((ship) => (
|
||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||||
<span className="text-[9px] text-text-2 font-mono">
|
||||
{ship.vesselName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
|
||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
|
||||
import { hexToRgba } from './mapUtils'
|
||||
|
||||
function getInterpolatedPosition(
|
||||
path: ReplayPathPoint[],
|
||||
frame: number,
|
||||
totalFrames: number
|
||||
): { lat: number; lon: number; segmentIndex: number } {
|
||||
const progress = Math.min(frame / totalFrames, 1)
|
||||
const floatIndex = progress * (path.length - 1)
|
||||
const idx = Math.min(Math.floor(floatIndex), path.length - 2)
|
||||
const frac = floatIndex - idx
|
||||
return {
|
||||
lat: path[idx].lat + (path[idx + 1].lat - path[idx].lat) * frac,
|
||||
lon: path[idx].lon + (path[idx + 1].lon - path[idx].lon) * frac,
|
||||
segmentIndex: idx,
|
||||
}
|
||||
}
|
||||
|
||||
interface BacktrackReplayParams {
|
||||
replayShips: ReplayShip[]
|
||||
collisionEvent: CollisionEvent | null
|
||||
replayFrame: number
|
||||
totalFrames: number
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
|
||||
const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layers: any[] = []
|
||||
const progress = replayFrame / totalFrames
|
||||
|
||||
// Per-ship track lines + waypoints + ship position
|
||||
const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> = []
|
||||
const allWaypoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
|
||||
const allShipPositions: Array<{ position: [number, number]; color: [number, number, number, number]; name: string }> = []
|
||||
|
||||
replayShips.forEach((ship) => {
|
||||
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames)
|
||||
const trackPath: [number, number][] = ship.path
|
||||
.slice(0, pos.segmentIndex + 2)
|
||||
.map((p, i, arr) => {
|
||||
if (i === arr.length - 1) return [pos.lon, pos.lat]
|
||||
return [p.lon, p.lat]
|
||||
})
|
||||
|
||||
const rgba = hexToRgba(ship.color, 180)
|
||||
allTrackData.push({ path: trackPath, color: rgba })
|
||||
|
||||
ship.path.slice(0, pos.segmentIndex + 1).forEach((p) => {
|
||||
allWaypoints.push({ position: [p.lon, p.lat], color: hexToRgba(ship.color, 130) })
|
||||
})
|
||||
|
||||
allShipPositions.push({
|
||||
position: [pos.lon, pos.lat],
|
||||
color: hexToRgba(ship.color),
|
||||
name: ship.vesselName,
|
||||
})
|
||||
})
|
||||
|
||||
// Track lines
|
||||
layers.push(
|
||||
new PathLayer({
|
||||
id: 'bt-tracks',
|
||||
data: allTrackData,
|
||||
getPath: (d: (typeof allTrackData)[0]) => d.path,
|
||||
getColor: (d: (typeof allTrackData)[0]) => d.color,
|
||||
getWidth: 2,
|
||||
getDashArray: [6, 4],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
extensions: [],
|
||||
})
|
||||
)
|
||||
|
||||
// Waypoint dots
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-waypoints',
|
||||
data: allWaypoints,
|
||||
getPosition: (d: (typeof allWaypoints)[0]) => d.position,
|
||||
getRadius: 3,
|
||||
getFillColor: (d: (typeof allWaypoints)[0]) => d.color,
|
||||
radiusMinPixels: 3,
|
||||
radiusMaxPixels: 5,
|
||||
})
|
||||
)
|
||||
|
||||
// Ship position markers
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-ships',
|
||||
data: allShipPositions,
|
||||
getPosition: (d: (typeof allShipPositions)[0]) => d.position,
|
||||
getRadius: 8,
|
||||
getFillColor: (d: (typeof allShipPositions)[0]) => d.color,
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 8,
|
||||
radiusMaxPixels: 14,
|
||||
pickable: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Collision point
|
||||
const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75
|
||||
const showCollision = progress >= collisionProgress
|
||||
|
||||
if (showCollision && collisionEvent) {
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-collision',
|
||||
data: [{ position: [collisionEvent.position.lon, collisionEvent.position.lat] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 12,
|
||||
getFillColor: [239, 68, 68, 80],
|
||||
getLineColor: [239, 68, 68, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 12,
|
||||
pickable: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Oil spill expansion
|
||||
const spillSize = Math.min(500, ((progress - collisionProgress) / (1 - collisionProgress)) * 500)
|
||||
if (spillSize > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-spill',
|
||||
data: [{ position: [incidentCoord.lon, incidentCoord.lat], radius: spillSize }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: (d: { radius: number }) => d.radius,
|
||||
getFillColor: [249, 115, 22, 50],
|
||||
getLineColor: [249, 115, 22, 100],
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters' as const,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return layers
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
interface HydrParticleOverlayProps {
|
||||
hydrStep: HydrDataStep | null;
|
||||
lightMode?: boolean;
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 3000;
|
||||
const MAX_AGE = 300;
|
||||
const SPEED_SCALE = 0.1;
|
||||
const DT = 600;
|
||||
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
|
||||
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
|
||||
|
||||
interface TrailPoint { x: number; y: number; }
|
||||
interface Particle {
|
||||
lon: number;
|
||||
lat: number;
|
||||
trail: TrailPoint[];
|
||||
age: number;
|
||||
}
|
||||
|
||||
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
|
||||
const { current: map } = useMap();
|
||||
const animRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !hydrStep) return;
|
||||
|
||||
const container = map.getContainer();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:5;';
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
container.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const { value: [u2d, v2d], grid } = hydrStep;
|
||||
const { boundLonLat, lonInterval, latInterval } = grid;
|
||||
|
||||
const lons: number[] = [boundLonLat.left];
|
||||
for (const d of lonInterval) lons.push(lons[lons.length - 1] + d);
|
||||
const lats: number[] = [boundLonLat.bottom];
|
||||
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
|
||||
|
||||
function getUV(lon: number, lat: number): [number, number] {
|
||||
let col = -1, row = -1;
|
||||
for (let i = 0; i < lons.length - 1; i++) {
|
||||
if (lon >= lons[i] && lon < lons[i + 1]) { col = i; break; }
|
||||
}
|
||||
for (let i = 0; i < lats.length - 1; i++) {
|
||||
if (lat >= lats[i] && lat < lats[i + 1]) { row = i; break; }
|
||||
}
|
||||
if (col < 0 || row < 0) return [0, 0];
|
||||
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
|
||||
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
|
||||
const u00 = u2d[row]?.[col] ?? 0, u01 = u2d[row]?.[col + 1] ?? u00;
|
||||
const u10 = u2d[row + 1]?.[col] ?? u00, u11 = u2d[row + 1]?.[col + 1] ?? u00;
|
||||
const v00 = v2d[row]?.[col] ?? 0, v01 = v2d[row]?.[col + 1] ?? v00;
|
||||
const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00;
|
||||
const u = u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
|
||||
const v = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
|
||||
return [u, v];
|
||||
}
|
||||
|
||||
const bbox = boundLonLat;
|
||||
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||
lon: bbox.left + Math.random() * (bbox.right - bbox.left),
|
||||
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
|
||||
trail: [],
|
||||
age: Math.floor(Math.random() * MAX_AGE),
|
||||
}));
|
||||
|
||||
function resetParticle(p: Particle) {
|
||||
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left);
|
||||
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom);
|
||||
p.trail = [];
|
||||
p.age = 0;
|
||||
}
|
||||
|
||||
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
|
||||
const onMove = () => { for (const p of particles) p.trail = []; };
|
||||
map.on('move', onMove);
|
||||
|
||||
function animate() {
|
||||
// 매 프레임 완전 초기화 → 잔상 없음
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
|
||||
const bands: [number, number, number, number][][] =
|
||||
Array.from({ length: NUM_ALPHA_BANDS }, () => []);
|
||||
|
||||
for (const p of particles) {
|
||||
const [u, v] = getUV(p.lon, p.lat);
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
if (speed < 0.001) { resetParticle(p); continue; }
|
||||
|
||||
const cosLat = Math.cos(p.lat * Math.PI / 180);
|
||||
p.lon += u * SPEED_SCALE * DT / (cosLat * 111320);
|
||||
p.lat += v * SPEED_SCALE * DT / 111320;
|
||||
p.age++;
|
||||
|
||||
if (
|
||||
p.lon < bbox.left || p.lon > bbox.right ||
|
||||
p.lat < bbox.bottom || p.lat > bbox.top ||
|
||||
p.age > MAX_AGE
|
||||
) { resetParticle(p); continue; }
|
||||
|
||||
const curr = map.project([p.lon, p.lat]);
|
||||
if (!curr) continue;
|
||||
|
||||
p.trail.push({ x: curr.x, y: curr.y });
|
||||
if (p.trail.length > TRAIL_LENGTH) p.trail.shift();
|
||||
if (p.trail.length < 2) continue;
|
||||
|
||||
for (let i = 1; i < p.trail.length; i++) {
|
||||
const t = i / p.trail.length; // 0=oldest, 1=newest
|
||||
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
|
||||
const a = p.trail[i - 1], b = p.trail[i];
|
||||
bands[band].push([a.x, a.y, b.x, b.y]);
|
||||
}
|
||||
}
|
||||
|
||||
// alpha band별 일괄 렌더링
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
|
||||
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
|
||||
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
|
||||
ctx.beginPath();
|
||||
for (const [x1, y1, x2, y2] of bands[b]) {
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
|
||||
const onResize = () => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
};
|
||||
map.on('resize', onResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current!);
|
||||
map.off('resize', onResize);
|
||||
map.off('move', onMove);
|
||||
canvas.remove();
|
||||
};
|
||||
}, [map, hydrStep, lightMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1187
frontend/src/common/data/chapters.json
Normal file
1187
frontend/src/common/data/chapters.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,45 +1,160 @@
|
||||
// HTML 시안 기반 레이어 트리 구조
|
||||
|
||||
export interface LayerNode {
|
||||
code: string
|
||||
parentCode: string | null
|
||||
fullName: string
|
||||
name: string
|
||||
level: number
|
||||
layerName: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
defaultOn?: boolean
|
||||
children?: LayerNode[]
|
||||
code: string;
|
||||
parentCode: string | null;
|
||||
fullName: string;
|
||||
name: string;
|
||||
level: number;
|
||||
layerName: string | null;
|
||||
dataTblNm?: string | null;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
defaultOn?: boolean;
|
||||
children?: LayerNode[];
|
||||
}
|
||||
|
||||
export const layerData: LayerNode[] = [
|
||||
// ─── 1. 해양생물자원 ───
|
||||
{
|
||||
code: 'BIO', parentCode: null, fullName: '해양생물자원', name: '해양생물자원',
|
||||
level: 1, layerName: null, icon: '🐟', count: 17129, defaultOn: true,
|
||||
code: 'BIO',
|
||||
parentCode: null,
|
||||
fullName: '해양생물자원',
|
||||
name: '해양생물자원',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '🐟',
|
||||
count: 17129,
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{
|
||||
code: 'BIO_FARM', parentCode: 'BIO', fullName: '양식장', name: '양식장',
|
||||
level: 2, layerName: null, icon: '🦪', count: 3947, defaultOn: true,
|
||||
code: 'BIO_FARM',
|
||||
parentCode: 'BIO',
|
||||
fullName: '양식장',
|
||||
name: '양식장',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🦪',
|
||||
count: 3947,
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{ code: 'BIO_FARM_FISH', parentCode: 'BIO_FARM', fullName: '어류양식장', name: '어류양식장', level: 3, layerName: 'mpc:600', icon: '🐟', count: 87, defaultOn: true },
|
||||
{ code: 'BIO_FARM_SHELL', parentCode: 'BIO_FARM', fullName: '패류양식장', name: '패류양식장 (굴·전복·홍합)', level: 3, layerName: 'mpc:506', icon: '🦪', count: 720, defaultOn: true },
|
||||
{ code: 'BIO_FARM_SEAWEED', parentCode: 'BIO_FARM', fullName: '해조류양식장', name: '해조류양식장 (김·미역·다시마)', level: 3, layerName: 'mpc:503', icon: '🌿', count: 1438, defaultOn: true },
|
||||
{ code: 'BIO_FARM_CAGE', parentCode: 'BIO_FARM', fullName: '가두리양식장', name: '가두리양식장', level: 3, layerName: 'mpc:472', icon: '🔲', count: 59 },
|
||||
{ code: 'BIO_FARM_CRUST', parentCode: 'BIO_FARM', fullName: '갑각류양식장', name: '갑각류양식장', level: 3, layerName: 'mpc:473', icon: '🦐', count: 470 },
|
||||
{ code: 'BIO_FARM_ETC', parentCode: 'BIO_FARM', fullName: '기타양식장', name: '기타양식장', level: 3, layerName: 'mpc:486', icon: '📦', count: 1173 },
|
||||
{
|
||||
code: 'BIO_FARM_FISH',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '어류양식장',
|
||||
name: '어류양식장',
|
||||
level: 3,
|
||||
layerName: 'mpc:600',
|
||||
icon: '🐟',
|
||||
count: 87,
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'BIO_FARM_SHELL',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '패류양식장',
|
||||
name: '패류양식장 (굴·전복·홍합)',
|
||||
level: 3,
|
||||
layerName: 'mpc:506',
|
||||
icon: '🦪',
|
||||
count: 720,
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'BIO_FARM_SEAWEED',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '해조류양식장',
|
||||
name: '해조류양식장 (김·미역·다시마)',
|
||||
level: 3,
|
||||
layerName: 'mpc:503',
|
||||
icon: '🌿',
|
||||
count: 1438,
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'BIO_FARM_CAGE',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '가두리양식장',
|
||||
name: '가두리양식장',
|
||||
level: 3,
|
||||
layerName: 'mpc:472',
|
||||
icon: '🔲',
|
||||
count: 59,
|
||||
},
|
||||
{
|
||||
code: 'BIO_FARM_CRUST',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '갑각류양식장',
|
||||
name: '갑각류양식장',
|
||||
level: 3,
|
||||
layerName: 'mpc:473',
|
||||
icon: '🦐',
|
||||
count: 470,
|
||||
},
|
||||
{
|
||||
code: 'BIO_FARM_ETC',
|
||||
parentCode: 'BIO_FARM',
|
||||
fullName: '기타양식장',
|
||||
name: '기타양식장',
|
||||
level: 3,
|
||||
layerName: 'mpc:486',
|
||||
icon: '📦',
|
||||
count: 1173,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'BIO_REEF', parentCode: 'BIO', fullName: '어초·암초', name: '어초·암초',
|
||||
level: 2, layerName: null, icon: '🪸', count: 13182, defaultOn: true,
|
||||
code: 'BIO_REEF',
|
||||
parentCode: 'BIO',
|
||||
fullName: '어초·암초',
|
||||
name: '어초·암초',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🪸',
|
||||
count: 13182,
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{ code: 'BIO_REEF_ART', parentCode: 'BIO_REEF', fullName: '인공어초', name: '인공어초', level: 3, layerName: 'mpc:495', icon: '🪸', count: 6683, defaultOn: true },
|
||||
{ code: 'BIO_REEF_NAT', parentCode: 'BIO_REEF', fullName: '암초 (자연)', name: '암초 (자연)', level: 3, layerName: 'mpc:497', icon: '🪨', count: 6331 },
|
||||
{ code: 'BIO_REEF_WRECK', parentCode: 'BIO_REEF', fullName: '침선', name: '침선', level: 3, layerName: 'mpc:488', icon: '🚢', count: 88 },
|
||||
{ code: 'BIO_REEF_OBS', parentCode: 'BIO_REEF', fullName: '기타 장애물', name: '기타 장애물', level: 3, layerName: 'mpc:470', icon: '⚠', count: 80 },
|
||||
{
|
||||
code: 'BIO_REEF_ART',
|
||||
parentCode: 'BIO_REEF',
|
||||
fullName: '인공어초',
|
||||
name: '인공어초',
|
||||
level: 3,
|
||||
layerName: 'mpc:495',
|
||||
icon: '🪸',
|
||||
count: 6683,
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'BIO_REEF_NAT',
|
||||
parentCode: 'BIO_REEF',
|
||||
fullName: '암초 (자연)',
|
||||
name: '암초 (자연)',
|
||||
level: 3,
|
||||
layerName: 'mpc:497',
|
||||
icon: '🪨',
|
||||
count: 6331,
|
||||
},
|
||||
{
|
||||
code: 'BIO_REEF_WRECK',
|
||||
parentCode: 'BIO_REEF',
|
||||
fullName: '침선',
|
||||
name: '침선',
|
||||
level: 3,
|
||||
layerName: 'mpc:488',
|
||||
icon: '🚢',
|
||||
count: 88,
|
||||
},
|
||||
{
|
||||
code: 'BIO_REEF_OBS',
|
||||
parentCode: 'BIO_REEF',
|
||||
fullName: '기타 장애물',
|
||||
name: '기타 장애물',
|
||||
level: 3,
|
||||
layerName: 'mpc:470',
|
||||
icon: '⚠',
|
||||
count: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -47,26 +162,97 @@ export const layerData: LayerNode[] = [
|
||||
|
||||
// ─── 2. 환경·보호구역 ───
|
||||
{
|
||||
code: 'ENV', parentCode: null, fullName: '환경·보호구역', name: '환경·보호구역',
|
||||
level: 1, layerName: null, icon: '🏛',
|
||||
code: 'ENV',
|
||||
parentCode: null,
|
||||
fullName: '환경·보호구역',
|
||||
name: '환경·보호구역',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '🏛',
|
||||
children: [
|
||||
{
|
||||
code: 'ENV_ECO', parentCode: 'ENV', fullName: '생태보호구역', name: '생태보호구역',
|
||||
level: 2, layerName: null, icon: '🦅',
|
||||
code: 'ENV_ECO',
|
||||
parentCode: 'ENV',
|
||||
fullName: '생태보호구역',
|
||||
name: '생태보호구역',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🦅',
|
||||
children: [
|
||||
{ code: 'ENV_ECO_MARINE', parentCode: 'ENV_ECO', fullName: '해양보호구역', name: '해양보호구역', level: 3, layerName: 'mpc:505', icon: '🌿' },
|
||||
{ code: 'ENV_ECO_BIRD', parentCode: 'ENV_ECO', fullName: '철새도래지', name: '철새도래지', level: 3, layerName: 'mpc:254', icon: '🐦' },
|
||||
{ code: 'ENV_ECO_WETLAND', parentCode: 'ENV_ECO', fullName: '습지보호구역', name: '습지보호구역', level: 3, layerName: 'mpc:468', icon: '🏖' },
|
||||
{ code: 'ENV_ECO_ENDANG', parentCode: 'ENV_ECO', fullName: '보호종 서식지', name: '보호종 서식지', level: 3, layerName: 'mpc:255', icon: '🐢' },
|
||||
{
|
||||
code: 'ENV_ECO_MARINE',
|
||||
parentCode: 'ENV_ECO',
|
||||
fullName: '해양보호구역',
|
||||
name: '해양보호구역',
|
||||
level: 3,
|
||||
layerName: 'mpc:505',
|
||||
icon: '🌿',
|
||||
},
|
||||
{
|
||||
code: 'ENV_ECO_BIRD',
|
||||
parentCode: 'ENV_ECO',
|
||||
fullName: '철새도래지',
|
||||
name: '철새도래지',
|
||||
level: 3,
|
||||
layerName: 'mpc:254',
|
||||
icon: '🐦',
|
||||
},
|
||||
{
|
||||
code: 'ENV_ECO_WETLAND',
|
||||
parentCode: 'ENV_ECO',
|
||||
fullName: '습지보호구역',
|
||||
name: '습지보호구역',
|
||||
level: 3,
|
||||
layerName: 'mpc:468',
|
||||
icon: '🏖',
|
||||
},
|
||||
{
|
||||
code: 'ENV_ECO_ENDANG',
|
||||
parentCode: 'ENV_ECO',
|
||||
fullName: '보호종 서식지',
|
||||
name: '보호종 서식지',
|
||||
level: 3,
|
||||
layerName: 'mpc:255',
|
||||
icon: '🐢',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'ENV_COAST', parentCode: 'ENV', fullName: '해안·연안', name: '해안·연안',
|
||||
level: 2, layerName: null, icon: '🏖',
|
||||
code: 'ENV_COAST',
|
||||
parentCode: 'ENV',
|
||||
fullName: '해안·연안',
|
||||
name: '해안·연안',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🏖',
|
||||
children: [
|
||||
{ code: 'ENV_COAST_BEACH', parentCode: 'ENV_COAST', fullName: '해수욕장', name: '해수욕장', level: 3, layerName: 'mpc:501', icon: '🏖' },
|
||||
{ code: 'ENV_COAST_MUD', parentCode: 'ENV_COAST', fullName: '갯벌', name: '갯벌', level: 3, layerName: 'mpc:363', icon: '🪨' },
|
||||
{ code: 'ENV_COAST_INTAKE', parentCode: 'ENV_COAST', fullName: '취수구·배수구', name: '취수구·배수구', level: 3, layerName: 'mpc:466', icon: '🚰' },
|
||||
{
|
||||
code: 'ENV_COAST_BEACH',
|
||||
parentCode: 'ENV_COAST',
|
||||
fullName: '해수욕장',
|
||||
name: '해수욕장',
|
||||
level: 3,
|
||||
layerName: 'mpc:501',
|
||||
icon: '🏖',
|
||||
},
|
||||
{
|
||||
code: 'ENV_COAST_MUD',
|
||||
parentCode: 'ENV_COAST',
|
||||
fullName: '갯벌',
|
||||
name: '갯벌',
|
||||
level: 3,
|
||||
layerName: 'mpc:363',
|
||||
icon: '🪨',
|
||||
},
|
||||
{
|
||||
code: 'ENV_COAST_INTAKE',
|
||||
parentCode: 'ENV_COAST',
|
||||
fullName: '취수구·배수구',
|
||||
name: '취수구·배수구',
|
||||
level: 3,
|
||||
layerName: 'mpc:466',
|
||||
icon: '🚰',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -74,26 +260,97 @@ export const layerData: LayerNode[] = [
|
||||
|
||||
// ─── 3. 해양시설·인프라 ───
|
||||
{
|
||||
code: 'INF', parentCode: null, fullName: '해양시설·인프라', name: '해양시설·인프라',
|
||||
level: 1, layerName: null, icon: '⚓',
|
||||
code: 'INF',
|
||||
parentCode: null,
|
||||
fullName: '해양시설·인프라',
|
||||
name: '해양시설·인프라',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '⚓',
|
||||
children: [
|
||||
{
|
||||
code: 'INF_PORT', parentCode: 'INF', fullName: '항만·항로', name: '항만·항로',
|
||||
level: 2, layerName: null, icon: '🚢',
|
||||
code: 'INF_PORT',
|
||||
parentCode: 'INF',
|
||||
fullName: '항만·항로',
|
||||
name: '항만·항로',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🚢',
|
||||
children: [
|
||||
{ code: 'INF_PORT_AREA', parentCode: 'INF_PORT', fullName: '항만 구역', name: '항만 구역', level: 3, layerName: 'mpc:469', icon: '⚓' },
|
||||
{ code: 'INF_PORT_ROUTE', parentCode: 'INF_PORT', fullName: '항로', name: '항로', level: 3, layerName: 'mpc:601', icon: '🚢' },
|
||||
{ code: 'INF_PORT_ANCHOR', parentCode: 'INF_PORT', fullName: '정박지', name: '정박지', level: 3, layerName: 'mpc:471', icon: '⛵' },
|
||||
{ code: 'INF_PORT_BUOY', parentCode: 'INF_PORT', fullName: '항로표지', name: '항로표지', level: 3, layerName: 'mpc:492', icon: '🔴' },
|
||||
{
|
||||
code: 'INF_PORT_AREA',
|
||||
parentCode: 'INF_PORT',
|
||||
fullName: '항만 구역',
|
||||
name: '항만 구역',
|
||||
level: 3,
|
||||
layerName: 'mpc:469',
|
||||
icon: '⚓',
|
||||
},
|
||||
{
|
||||
code: 'INF_PORT_ROUTE',
|
||||
parentCode: 'INF_PORT',
|
||||
fullName: '항로',
|
||||
name: '항로',
|
||||
level: 3,
|
||||
layerName: 'mpc:601',
|
||||
icon: '🚢',
|
||||
},
|
||||
{
|
||||
code: 'INF_PORT_ANCHOR',
|
||||
parentCode: 'INF_PORT',
|
||||
fullName: '정박지',
|
||||
name: '정박지',
|
||||
level: 3,
|
||||
layerName: 'mpc:471',
|
||||
icon: '⛵',
|
||||
},
|
||||
{
|
||||
code: 'INF_PORT_BUOY',
|
||||
parentCode: 'INF_PORT',
|
||||
fullName: '항로표지',
|
||||
name: '항로표지',
|
||||
level: 3,
|
||||
layerName: 'mpc:492',
|
||||
icon: '🔴',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'INF_IND', parentCode: 'INF', fullName: '산업시설', name: '산업시설',
|
||||
level: 2, layerName: null, icon: '🏭',
|
||||
code: 'INF_IND',
|
||||
parentCode: 'INF',
|
||||
fullName: '산업시설',
|
||||
name: '산업시설',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🏭',
|
||||
children: [
|
||||
{ code: 'INF_IND_POWER', parentCode: 'INF_IND', fullName: '발전소·산단', name: '발전소·산단', level: 3, layerName: 'mpc:474', icon: '🏭' },
|
||||
{ code: 'INF_IND_OIL', parentCode: 'INF_IND', fullName: '저유시설', name: '저유시설', level: 3, layerName: 'mpc:496', icon: '🛢' },
|
||||
{ code: 'INF_IND_CABLE', parentCode: 'INF_IND', fullName: '해저케이블·배관', name: '해저케이블·배관', level: 3, layerName: 'mpc:499', icon: '🔌' },
|
||||
{
|
||||
code: 'INF_IND_POWER',
|
||||
parentCode: 'INF_IND',
|
||||
fullName: '발전소·산단',
|
||||
name: '발전소·산단',
|
||||
level: 3,
|
||||
layerName: 'mpc:474',
|
||||
icon: '🏭',
|
||||
},
|
||||
{
|
||||
code: 'INF_IND_OIL',
|
||||
parentCode: 'INF_IND',
|
||||
fullName: '저유시설',
|
||||
name: '저유시설',
|
||||
level: 3,
|
||||
layerName: 'mpc:496',
|
||||
icon: '🛢',
|
||||
},
|
||||
{
|
||||
code: 'INF_IND_CABLE',
|
||||
parentCode: 'INF_IND',
|
||||
fullName: '해저케이블·배관',
|
||||
name: '해저케이블·배관',
|
||||
level: 3,
|
||||
layerName: 'mpc:499',
|
||||
icon: '🔌',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -101,16 +358,53 @@ export const layerData: LayerNode[] = [
|
||||
|
||||
// ─── 4. 방제자원 ───
|
||||
{
|
||||
code: 'DEF', parentCode: null, fullName: '방제자원', name: '방제자원',
|
||||
level: 1, layerName: null, icon: '🛡', defaultOn: true,
|
||||
code: 'DEF',
|
||||
parentCode: null,
|
||||
fullName: '방제자원',
|
||||
name: '방제자원',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '🛡',
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{
|
||||
code: 'DEF_DEPLOY', parentCode: 'DEF', fullName: '방제 배치', name: '방제 배치',
|
||||
level: 2, layerName: null, icon: '🛡', defaultOn: true,
|
||||
code: 'DEF_DEPLOY',
|
||||
parentCode: 'DEF',
|
||||
fullName: '방제 배치',
|
||||
name: '방제 배치',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🛡',
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{ code: 'DEF_DEPLOY_BOOM', parentCode: 'DEF_DEPLOY', fullName: '오일펜스 배치선', name: '오일펜스 배치선', level: 3, layerName: 'defense:boom_lines', icon: '🛡', defaultOn: true },
|
||||
{ code: 'DEF_DEPLOY_WARE', parentCode: 'DEF_DEPLOY', fullName: '방제창고', name: '방제창고', level: 3, layerName: 'defense:warehouse', icon: '🏗' },
|
||||
{ code: 'DEF_DEPLOY_SHIP', parentCode: 'DEF_DEPLOY', fullName: '방제선 위치', name: '방제선 위치', level: 3, layerName: 'defense:vessels', icon: '🚢' },
|
||||
{
|
||||
code: 'DEF_DEPLOY_BOOM',
|
||||
parentCode: 'DEF_DEPLOY',
|
||||
fullName: '오일펜스 배치선',
|
||||
name: '오일펜스 배치선',
|
||||
level: 3,
|
||||
layerName: 'defense:boom_lines',
|
||||
icon: '🛡',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'DEF_DEPLOY_WARE',
|
||||
parentCode: 'DEF_DEPLOY',
|
||||
fullName: '방제창고',
|
||||
name: '방제창고',
|
||||
level: 3,
|
||||
layerName: 'defense:warehouse',
|
||||
icon: '🏗',
|
||||
},
|
||||
{
|
||||
code: 'DEF_DEPLOY_SHIP',
|
||||
parentCode: 'DEF_DEPLOY',
|
||||
fullName: '방제선 위치',
|
||||
name: '방제선 위치',
|
||||
level: 3,
|
||||
layerName: 'defense:vessels',
|
||||
icon: '🚢',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -118,47 +412,200 @@ export const layerData: LayerNode[] = [
|
||||
|
||||
// ─── 5. Pre-SCAT 데이터 ───
|
||||
{
|
||||
code: 'SCAT', parentCode: null, fullName: 'Pre-SCAT 데이터', name: 'Pre-SCAT 데이터',
|
||||
level: 1, layerName: null, icon: '📊',
|
||||
code: 'SCAT',
|
||||
parentCode: null,
|
||||
fullName: 'Pre-SCAT 데이터',
|
||||
name: 'Pre-SCAT 데이터',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '📊',
|
||||
children: [
|
||||
{
|
||||
code: 'SCAT_ESI', parentCode: 'SCAT', fullName: '해안분류 (ESI)', name: '해안분류 (ESI)',
|
||||
level: 2, layerName: null, icon: '🏖',
|
||||
code: 'SCAT_ESI',
|
||||
parentCode: 'SCAT',
|
||||
fullName: '해안분류 (ESI)',
|
||||
name: '해안분류 (ESI)',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🏖',
|
||||
children: [
|
||||
{ code: 'SCAT_ESI_ROCK', parentCode: 'SCAT_ESI', fullName: '암반 해안 (ESI 1)', name: '암반 해안 (ESI 1)', level: 3, layerName: 'prescat:esi_rock', icon: '🪨' },
|
||||
{ code: 'SCAT_ESI_SAND', parentCode: 'SCAT_ESI', fullName: '사빈 해안 (ESI 3)', name: '사빈 해안 (ESI 3)', level: 3, layerName: 'prescat:esi_sand', icon: '🏖' },
|
||||
{ code: 'SCAT_ESI_GRAVEL', parentCode: 'SCAT_ESI', fullName: '자갈 해안 (ESI 5)', name: '자갈 해안 (ESI 5)', level: 3, layerName: 'prescat:esi_gravel', icon: '🪹' },
|
||||
{ code: 'SCAT_ESI_MUD', parentCode: 'SCAT_ESI', fullName: '갯벌·습지 (ESI 9)', name: '갯벌·습지 (ESI 9)', level: 3, layerName: 'prescat:esi_mudflat', icon: '🌿' },
|
||||
{ code: 'SCAT_ESI_MANG', parentCode: 'SCAT_ESI', fullName: '맹그로브·염습지 (ESI 10)', name: '맹그로브·염습지 (ESI 10)', level: 3, layerName: 'prescat:esi_mangrove', icon: '🌾' },
|
||||
{
|
||||
code: 'SCAT_ESI_ROCK',
|
||||
parentCode: 'SCAT_ESI',
|
||||
fullName: '암반 해안 (ESI 1)',
|
||||
name: '암반 해안 (ESI 1)',
|
||||
level: 3,
|
||||
layerName: 'prescat:esi_rock',
|
||||
icon: '🪨',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_ESI_SAND',
|
||||
parentCode: 'SCAT_ESI',
|
||||
fullName: '사빈 해안 (ESI 3)',
|
||||
name: '사빈 해안 (ESI 3)',
|
||||
level: 3,
|
||||
layerName: 'prescat:esi_sand',
|
||||
icon: '🏖',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_ESI_GRAVEL',
|
||||
parentCode: 'SCAT_ESI',
|
||||
fullName: '자갈 해안 (ESI 5)',
|
||||
name: '자갈 해안 (ESI 5)',
|
||||
level: 3,
|
||||
layerName: 'prescat:esi_gravel',
|
||||
icon: '🪹',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_ESI_MUD',
|
||||
parentCode: 'SCAT_ESI',
|
||||
fullName: '갯벌·습지 (ESI 9)',
|
||||
name: '갯벌·습지 (ESI 9)',
|
||||
level: 3,
|
||||
layerName: 'prescat:esi_mudflat',
|
||||
icon: '🌿',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_ESI_MANG',
|
||||
parentCode: 'SCAT_ESI',
|
||||
fullName: '맹그로브·염습지 (ESI 10)',
|
||||
name: '맹그로브·염습지 (ESI 10)',
|
||||
level: 3,
|
||||
layerName: 'prescat:esi_mangrove',
|
||||
icon: '🌾',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'SCAT_SENS', parentCode: 'SCAT', fullName: '해안 민감도', name: '해안 민감도',
|
||||
level: 2, layerName: null, icon: '🎯',
|
||||
code: 'SCAT_SENS',
|
||||
parentCode: 'SCAT',
|
||||
fullName: '해안 민감도',
|
||||
name: '해안 민감도',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🎯',
|
||||
children: [
|
||||
{ code: 'SCAT_SENS_MAX', parentCode: 'SCAT_SENS', fullName: '최고 민감 구간', name: '최고 민감 구간', level: 3, layerName: 'prescat:sens_max', icon: '🔴' },
|
||||
{ code: 'SCAT_SENS_HIGH', parentCode: 'SCAT_SENS', fullName: '고민감 구간', name: '고민감 구간', level: 3, layerName: 'prescat:sens_high', icon: '🟠' },
|
||||
{ code: 'SCAT_SENS_MID', parentCode: 'SCAT_SENS', fullName: '중민감 구간', name: '중민감 구간', level: 3, layerName: 'prescat:sens_mid', icon: '🟡' },
|
||||
{ code: 'SCAT_SENS_LOW', parentCode: 'SCAT_SENS', fullName: '저민감 구간', name: '저민감 구간', level: 3, layerName: 'prescat:sens_low', icon: '🟢' },
|
||||
{
|
||||
code: 'SCAT_SENS_MAX',
|
||||
parentCode: 'SCAT_SENS',
|
||||
fullName: '최고 민감 구간',
|
||||
name: '최고 민감 구간',
|
||||
level: 3,
|
||||
layerName: 'prescat:sens_max',
|
||||
icon: '🔴',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_SENS_HIGH',
|
||||
parentCode: 'SCAT_SENS',
|
||||
fullName: '고민감 구간',
|
||||
name: '고민감 구간',
|
||||
level: 3,
|
||||
layerName: 'prescat:sens_high',
|
||||
icon: '🟠',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_SENS_MID',
|
||||
parentCode: 'SCAT_SENS',
|
||||
fullName: '중민감 구간',
|
||||
name: '중민감 구간',
|
||||
level: 3,
|
||||
layerName: 'prescat:sens_mid',
|
||||
icon: '🟡',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_SENS_LOW',
|
||||
parentCode: 'SCAT_SENS',
|
||||
fullName: '저민감 구간',
|
||||
name: '저민감 구간',
|
||||
level: 3,
|
||||
layerName: 'prescat:sens_low',
|
||||
icon: '🟢',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'SCAT_VULN', parentCode: 'SCAT', fullName: '오염 취약성', name: '오염 취약성',
|
||||
level: 2, layerName: null, icon: '🛢',
|
||||
code: 'SCAT_VULN',
|
||||
parentCode: 'SCAT',
|
||||
fullName: '오염 취약성',
|
||||
name: '오염 취약성',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '🛢',
|
||||
children: [
|
||||
{ code: 'SCAT_VULN_RESI', parentCode: 'SCAT_VULN', fullName: '잔류시간 예측', name: '잔류시간 예측', level: 3, layerName: 'prescat:vuln_residency', icon: '⏱' },
|
||||
{ code: 'SCAT_VULN_DIFF', parentCode: 'SCAT_VULN', fullName: '방제 난이도', name: '방제 난이도', level: 3, layerName: 'prescat:vuln_difficulty', icon: '🧹' },
|
||||
{ code: 'SCAT_VULN_SELF', parentCode: 'SCAT_VULN', fullName: '자연정화 기대수준', name: '자연정화 기대수준', level: 3, layerName: 'prescat:vuln_selfclean', icon: '🔄' },
|
||||
{
|
||||
code: 'SCAT_VULN_RESI',
|
||||
parentCode: 'SCAT_VULN',
|
||||
fullName: '잔류시간 예측',
|
||||
name: '잔류시간 예측',
|
||||
level: 3,
|
||||
layerName: 'prescat:vuln_residency',
|
||||
icon: '⏱',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_VULN_DIFF',
|
||||
parentCode: 'SCAT_VULN',
|
||||
fullName: '방제 난이도',
|
||||
name: '방제 난이도',
|
||||
level: 3,
|
||||
layerName: 'prescat:vuln_difficulty',
|
||||
icon: '🧹',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_VULN_SELF',
|
||||
parentCode: 'SCAT_VULN',
|
||||
fullName: '자연정화 기대수준',
|
||||
name: '자연정화 기대수준',
|
||||
level: 3,
|
||||
layerName: 'prescat:vuln_selfclean',
|
||||
icon: '🔄',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'SCAT_STRAT', parentCode: 'SCAT', fullName: '방제전략 권고', name: '방제전략 권고',
|
||||
level: 2, layerName: null, icon: '📋',
|
||||
code: 'SCAT_STRAT',
|
||||
parentCode: 'SCAT',
|
||||
fullName: '방제전략 권고',
|
||||
name: '방제전략 권고',
|
||||
level: 2,
|
||||
layerName: null,
|
||||
icon: '📋',
|
||||
children: [
|
||||
{ code: 'SCAT_STRAT_PRI', parentCode: 'SCAT_STRAT', fullName: '1순위 방어구간', name: '1순위 방어구간', level: 3, layerName: 'prescat:strat_priority', icon: '🛡' },
|
||||
{ code: 'SCAT_STRAT_METHOD', parentCode: 'SCAT_STRAT', fullName: '권고 방제공법', name: '권고 방제공법', level: 3, layerName: 'prescat:strat_method', icon: '🧹' },
|
||||
{ code: 'SCAT_STRAT_BASE', parentCode: 'SCAT_STRAT', fullName: '거점 방제장소', name: '거점 방제장소', level: 3, layerName: 'prescat:strat_base', icon: '📍' },
|
||||
{ code: 'SCAT_STRAT_ACCESS', parentCode: 'SCAT_STRAT', fullName: '접근경로·진입로', name: '접근경로·진입로', level: 3, layerName: 'prescat:strat_access', icon: '🚧' },
|
||||
{
|
||||
code: 'SCAT_STRAT_PRI',
|
||||
parentCode: 'SCAT_STRAT',
|
||||
fullName: '1순위 방어구간',
|
||||
name: '1순위 방어구간',
|
||||
level: 3,
|
||||
layerName: 'prescat:strat_priority',
|
||||
icon: '🛡',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_STRAT_METHOD',
|
||||
parentCode: 'SCAT_STRAT',
|
||||
fullName: '권고 방제공법',
|
||||
name: '권고 방제공법',
|
||||
level: 3,
|
||||
layerName: 'prescat:strat_method',
|
||||
icon: '🧹',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_STRAT_BASE',
|
||||
parentCode: 'SCAT_STRAT',
|
||||
fullName: '거점 방제장소',
|
||||
name: '거점 방제장소',
|
||||
level: 3,
|
||||
layerName: 'prescat:strat_base',
|
||||
icon: '📍',
|
||||
},
|
||||
{
|
||||
code: 'SCAT_STRAT_ACCESS',
|
||||
parentCode: 'SCAT_STRAT',
|
||||
fullName: '접근경로·진입로',
|
||||
name: '접근경로·진입로',
|
||||
level: 3,
|
||||
layerName: 'prescat:strat_access',
|
||||
icon: '🚧',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -166,25 +613,101 @@ export const layerData: LayerNode[] = [
|
||||
|
||||
// ─── 6. 해양관측·기상 ───
|
||||
{
|
||||
code: 'OBS', parentCode: null, fullName: '해양관측·기상', name: '해양관측·기상',
|
||||
level: 1, layerName: null, icon: '🌊', defaultOn: true,
|
||||
code: 'OBS',
|
||||
parentCode: null,
|
||||
fullName: '해양관측·기상',
|
||||
name: '해양관측·기상',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '🌊',
|
||||
defaultOn: true,
|
||||
children: [
|
||||
{ code: 'OBS_CURRENT', parentCode: 'OBS', fullName: '해류 벡터', name: '해류 벡터', level: 2, layerName: 'obs:current_vector', icon: '🌊', defaultOn: true },
|
||||
{ code: 'OBS_TEMP', parentCode: 'OBS', fullName: '수온 분포', name: '수온 분포', level: 2, layerName: 'obs:sst', icon: '🌡' },
|
||||
{ code: 'OBS_BUOY', parentCode: 'OBS', fullName: '해양관측 부이', name: '해양관측 부이', level: 2, layerName: 'obs:buoy', icon: '📡' },
|
||||
{ code: 'OBS_WEATHER', parentCode: 'OBS', fullName: '기상 관측소', name: '기상 관측소', level: 2, layerName: 'obs:weather_station', icon: '🌬' },
|
||||
{ code: 'OBS_SAT', parentCode: 'OBS', fullName: '위성 영상', name: '위성 영상', level: 2, layerName: 'obs:satellite', icon: '🛰' },
|
||||
{
|
||||
code: 'OBS_CURRENT',
|
||||
parentCode: 'OBS',
|
||||
fullName: '해류 벡터',
|
||||
name: '해류 벡터',
|
||||
level: 2,
|
||||
layerName: 'obs:current_vector',
|
||||
icon: '🌊',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
code: 'OBS_TEMP',
|
||||
parentCode: 'OBS',
|
||||
fullName: '수온 분포',
|
||||
name: '수온 분포',
|
||||
level: 2,
|
||||
layerName: 'obs:sst',
|
||||
icon: '🌡',
|
||||
},
|
||||
{
|
||||
code: 'OBS_BUOY',
|
||||
parentCode: 'OBS',
|
||||
fullName: '해양관측 부이',
|
||||
name: '해양관측 부이',
|
||||
level: 2,
|
||||
layerName: 'obs:buoy',
|
||||
icon: '📡',
|
||||
},
|
||||
{
|
||||
code: 'OBS_WEATHER',
|
||||
parentCode: 'OBS',
|
||||
fullName: '기상 관측소',
|
||||
name: '기상 관측소',
|
||||
level: 2,
|
||||
layerName: 'obs:weather_station',
|
||||
icon: '🌬',
|
||||
},
|
||||
{
|
||||
code: 'OBS_SAT',
|
||||
parentCode: 'OBS',
|
||||
fullName: '위성 영상',
|
||||
name: '위성 영상',
|
||||
level: 2,
|
||||
layerName: 'obs:satellite',
|
||||
icon: '🛰',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─── 7. 선박·교통 ───
|
||||
{
|
||||
code: 'SHIP', parentCode: null, fullName: '선박·교통', name: '선박·교통',
|
||||
level: 1, layerName: null, icon: '🚢',
|
||||
code: 'SHIP',
|
||||
parentCode: null,
|
||||
fullName: '선박·교통',
|
||||
name: '선박·교통',
|
||||
level: 1,
|
||||
layerName: null,
|
||||
icon: '🚢',
|
||||
children: [
|
||||
{ code: 'SHIP_AIS', parentCode: 'SHIP', fullName: 'AIS 실시간 선박', name: 'AIS 실시간 선박', level: 2, layerName: 'ship:ais_realtime', icon: '🚢' },
|
||||
{ code: 'SHIP_PATROL', parentCode: 'SHIP', fullName: '경비함정', name: '경비함정', level: 2, layerName: 'ship:patrol', icon: '🛥' },
|
||||
{ code: 'SHIP_AIR', parentCode: 'SHIP', fullName: '항공기·드론', name: '항공기·드론', level: 2, layerName: 'ship:aircraft', icon: '🚁' },
|
||||
{
|
||||
code: 'SHIP_AIS',
|
||||
parentCode: 'SHIP',
|
||||
fullName: 'AIS 실시간 선박',
|
||||
name: 'AIS 실시간 선박',
|
||||
level: 2,
|
||||
layerName: 'ship:ais_realtime',
|
||||
icon: '🚢',
|
||||
},
|
||||
{
|
||||
code: 'SHIP_PATROL',
|
||||
parentCode: 'SHIP',
|
||||
fullName: '경비함정',
|
||||
name: '경비함정',
|
||||
level: 2,
|
||||
layerName: 'ship:patrol',
|
||||
icon: '🛥',
|
||||
},
|
||||
{
|
||||
code: 'SHIP_AIR',
|
||||
parentCode: 'SHIP',
|
||||
fullName: '항공기·드론',
|
||||
name: '항공기·드론',
|
||||
level: 2,
|
||||
layerName: 'ship:aircraft',
|
||||
icon: '🚁',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
30
frontend/src/common/data/manualChapters.ts
Normal file
30
frontend/src/common/data/manualChapters.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import chaptersJson from './chapters.json';
|
||||
|
||||
export interface InputItem {
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface ScreenItem {
|
||||
id: string;
|
||||
name: string;
|
||||
menuPath: string;
|
||||
imageIndex: number;
|
||||
overview: string;
|
||||
description?: string;
|
||||
procedure?: string[];
|
||||
inputs?: InputItem[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
screens: ScreenItem[];
|
||||
}
|
||||
|
||||
export const CHAPTERS = chaptersJson as Chapter[];
|
||||
11
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
11
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles';
|
||||
|
||||
export function useBaseMapStyle(): StyleSpecification {
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
if (mapToggles.s57) return ENC_EMPTY_STYLE;
|
||||
if (mapToggles.threeD) return SATELLITE_3D_STYLE;
|
||||
return LIGHT_STYLE;
|
||||
}
|
||||
@ -15,10 +15,9 @@ export function useFeatureTracking(featureId: string) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !featureId) return;
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
||||
{ type: 'text/plain' },
|
||||
);
|
||||
const blob = new Blob([JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||
}, [featureId, isAuthenticated]);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api'
|
||||
import type { Layer } from '@common/services/layerService'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api';
|
||||
import type { Layer } from '@common/services/layerService';
|
||||
|
||||
// 모든 레이어 조회 훅
|
||||
export function useLayers() {
|
||||
@ -9,7 +9,7 @@ export function useLayers() {
|
||||
queryFn: fetchAllLayers,
|
||||
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
|
||||
retry: 3,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 계층 구조 레이어 트리 조회 훅
|
||||
@ -19,7 +19,7 @@ export function useLayerTree() {
|
||||
queryKey: ['layers', 'tree'],
|
||||
queryFn: fetchLayerTree,
|
||||
retry: 3,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// WMS 레이어만 조회 훅
|
||||
@ -29,5 +29,5 @@ export function useWMSLayers() {
|
||||
queryFn: fetchWMSLayers,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 3,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { MainTab } from '../types/navigation'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import type { MainTab } from '@/types/navigation';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { API_BASE_URL } from '@common/services/api';
|
||||
|
||||
interface SubMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// 메인 탭별 서브 메뉴 설정
|
||||
@ -17,24 +17,24 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
|
||||
{ id: 'manual', label: 'HNS 대응매뉴얼', icon: '📖' },
|
||||
{ id: 'theory', label: '확산모델 이론', icon: '📐' },
|
||||
{ id: 'substance', label: 'HNS 물질정보', icon: '🧬' }
|
||||
{ id: 'substance', label: 'HNS 물질정보', icon: '🧬' },
|
||||
],
|
||||
prediction: [
|
||||
{ id: 'analysis', label: '유출유 확산분석', icon: '🔬' },
|
||||
{ id: 'list', label: '분석 목록', icon: '📋' },
|
||||
{ id: 'theory', label: '유출유확산모델 이론', icon: '📐' },
|
||||
{ id: 'boom-theory', label: '오일펜스 배치 알고리즘 이론', icon: '🛡️' }
|
||||
{ id: 'boom-theory', label: '오일펜스 배치 알고리즘 이론', icon: '🛡️' },
|
||||
],
|
||||
rescue: [
|
||||
{ id: 'rescue', label: '긴급구난예측', icon: '🚨' },
|
||||
{ id: 'list', label: '긴급구난 목록', icon: '📋' },
|
||||
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
|
||||
{ id: 'theory', label: '긴급구난모델 이론', icon: '📚' }
|
||||
{ id: 'theory', label: '긴급구난모델 이론', icon: '📚' },
|
||||
],
|
||||
reports: [
|
||||
{ id: 'report-list', label: '보고서 목록', icon: '📋' },
|
||||
{ id: 'template', label: '표준보고서 템플릿', icon: '📝' },
|
||||
{ id: 'generate', label: '보고서 생성', icon: '🔄' }
|
||||
{ id: 'generate', label: '보고서 생성', icon: '🔄' },
|
||||
],
|
||||
aerial: [
|
||||
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
||||
@ -44,13 +44,13 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
|
||||
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
|
||||
{ id: 'theory', label: '항공탐색 이론', icon: '📐' },
|
||||
],
|
||||
assets: null,
|
||||
scat: [
|
||||
{ id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
|
||||
{ id: 'distribution', label: '해양오염분포도', icon: '🗺' },
|
||||
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' }
|
||||
// { id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
|
||||
// { id: 'distribution', label: '해양오염분포도', icon: '🗺' },
|
||||
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' },
|
||||
],
|
||||
incidents: null,
|
||||
board: [
|
||||
@ -58,11 +58,12 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'notice', label: '공지사항', icon: '📢' },
|
||||
{ id: 'data', label: '자료실', icon: '📂' },
|
||||
{ id: 'qna', label: 'Q&A', icon: '❓' },
|
||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
|
||||
],
|
||||
weather: null,
|
||||
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||
}
|
||||
monitor: null,
|
||||
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||
};
|
||||
|
||||
// 전역 상태 관리 (간단한 방식)
|
||||
const subMenuState: Record<MainTab, string> = {
|
||||
@ -72,82 +73,85 @@ const subMenuState: Record<MainTab, string> = {
|
||||
reports: 'report-list',
|
||||
aerial: 'media',
|
||||
assets: '',
|
||||
scat: 'survey',
|
||||
scat: 'pre-scat',
|
||||
incidents: '',
|
||||
board: 'all',
|
||||
weather: '',
|
||||
admin: 'users'
|
||||
}
|
||||
monitor: '',
|
||||
admin: 'users',
|
||||
};
|
||||
|
||||
const listeners: Set<() => void> = new Set()
|
||||
const listeners: Set<() => void> = new Set();
|
||||
|
||||
function setSubTab(mainTab: MainTab, subTab: string) {
|
||||
subMenuState[mainTab] = subTab
|
||||
listeners.forEach(listener => listener())
|
||||
subMenuState[mainTab] = subTab;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useSubMenu(mainTab: MainTab) {
|
||||
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab]);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||
|
||||
const setActiveSubTab = (subTab: string) => {
|
||||
setSubTab(mainTab, subTab)
|
||||
}
|
||||
setSubTab(mainTab, subTab);
|
||||
};
|
||||
|
||||
// 권한 기반 서브메뉴 필터링
|
||||
const rawConfig = subMenuConfigs[mainTab]
|
||||
const filteredConfig = rawConfig?.filter(item =>
|
||||
hasPermission(`${mainTab}:${item.id}`)
|
||||
) ?? null
|
||||
const rawConfig = subMenuConfigs[mainTab];
|
||||
const filteredConfig =
|
||||
rawConfig?.filter((item) => hasPermission(`${mainTab}:${item.id}`)) ?? null;
|
||||
|
||||
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeSubTab) return
|
||||
const resourcePath = `${mainTab}:${activeSubTab}`
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
|
||||
{ type: 'text/plain' },
|
||||
)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [mainTab, activeSubTab, isAuthenticated])
|
||||
if (!isAuthenticated || !activeSubTab) return;
|
||||
const resourcePath = `${mainTab}:${activeSubTab}`;
|
||||
const blob = new Blob([JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||
}, [mainTab, activeSubTab, isAuthenticated]);
|
||||
|
||||
return {
|
||||
activeSubTab,
|
||||
setActiveSubTab,
|
||||
subMenuConfig: filteredConfig,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 글로벌 메인탭 전환 (크로스 뷰 네비게이션) ─────────────
|
||||
type MainTabListener = (tab: MainTab) => void
|
||||
let mainTabListener: MainTabListener | null = null
|
||||
type MainTabListener = (tab: MainTab) => void;
|
||||
let mainTabListener: MainTabListener | null = null;
|
||||
|
||||
/** App.tsx에서 호출하여 글로벌 탭 전환 리스너 등록 */
|
||||
export function registerMainTabSwitcher(fn: MainTabListener) {
|
||||
mainTabListener = fn
|
||||
mainTabListener = fn;
|
||||
}
|
||||
|
||||
/** 어느 컴포넌트에서든 메인탭 + 서브탭을 한번에 전환 */
|
||||
export function navigateToTab(mainTab: MainTab, subTab?: string) {
|
||||
if (subTab) setSubTab(mainTab, subTab)
|
||||
if (mainTabListener) mainTabListener(mainTab)
|
||||
if (subTab) setSubTab(mainTab, subTab);
|
||||
if (mainTabListener) mainTabListener(mainTab);
|
||||
}
|
||||
|
||||
// ─── 보고서 생성 카테고리 힌트 ──────────────────────────
|
||||
/** 보고서 생성 탭으로 이동 시 초기 카테고리 (0=유출유, 1=HNS, 2=긴급구난) */
|
||||
let _reportGenCategory: number | null = null
|
||||
let _reportGenCategory: number | null = null;
|
||||
|
||||
export function setReportGenCategory(cat: number | null) { _reportGenCategory = cat }
|
||||
export function setReportGenCategory(cat: number | null) {
|
||||
_reportGenCategory = cat;
|
||||
}
|
||||
export function consumeReportGenCategory(): number | null {
|
||||
const v = _reportGenCategory
|
||||
_reportGenCategory = null
|
||||
return v
|
||||
const v = _reportGenCategory;
|
||||
_reportGenCategory = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
// ─── HNS 보고서 실 데이터 전달 ──────────────────────────
|
||||
@ -162,7 +166,9 @@ export interface HnsReportPayload {
|
||||
}
|
||||
|
||||
let _hnsReportPayload: HnsReportPayload | null = null;
|
||||
export function setHnsReportPayload(d: HnsReportPayload | null) { _hnsReportPayload = d; }
|
||||
export function setHnsReportPayload(d: HnsReportPayload | null) {
|
||||
_hnsReportPayload = d;
|
||||
}
|
||||
export function consumeHnsReportPayload(): HnsReportPayload | null {
|
||||
const v = _hnsReportPayload;
|
||||
_hnsReportPayload = null;
|
||||
@ -244,11 +250,13 @@ export interface OilReportPayload {
|
||||
}
|
||||
|
||||
let _oilReportPayload: OilReportPayload | null = null;
|
||||
export function setOilReportPayload(d: OilReportPayload | null) { _oilReportPayload = d; }
|
||||
export function setOilReportPayload(d: OilReportPayload | null) {
|
||||
_oilReportPayload = d;
|
||||
}
|
||||
export function consumeOilReportPayload(): OilReportPayload | null {
|
||||
const v = _oilReportPayload;
|
||||
_oilReportPayload = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
export { subMenuState }
|
||||
export { subMenuState };
|
||||
|
||||
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
createVesselSignalClient,
|
||||
type VesselSignalClient,
|
||||
} from '@common/services/vesselSignalClient';
|
||||
import {
|
||||
getInitialVesselSnapshot,
|
||||
isVesselInitEnabled,
|
||||
} from '@common/services/vesselApi';
|
||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||
|
||||
/**
|
||||
* 선박 신호 실시간 수신 훅
|
||||
*
|
||||
* 개발환경(VITE_VESSEL_SIGNAL_MODE=polling):
|
||||
* - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출
|
||||
*
|
||||
* 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket):
|
||||
* - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신
|
||||
* - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링
|
||||
*
|
||||
* @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox
|
||||
* @returns 현재 뷰포트 내 선박 목록
|
||||
*/
|
||||
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
|
||||
const [vessels, setVessels] = useState<VesselPosition[]>([]);
|
||||
const boundsRef = useRef<MapBounds | null>(mapBounds);
|
||||
const clientRef = useRef<VesselSignalClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
boundsRef.current = mapBounds;
|
||||
}, [mapBounds]);
|
||||
|
||||
const getViewportBounds = useCallback(() => boundsRef.current, []);
|
||||
|
||||
useEffect(() => {
|
||||
const client = createVesselSignalClient();
|
||||
clientRef.current = client;
|
||||
|
||||
// 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드.
|
||||
// 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다.
|
||||
// VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성).
|
||||
if (isVesselInitEnabled()) {
|
||||
getInitialVesselSnapshot()
|
||||
.then((initial) => {
|
||||
const bounds = boundsRef.current;
|
||||
const filtered = bounds
|
||||
? initial.filter(
|
||||
(v) =>
|
||||
v.lon >= bounds.minLon &&
|
||||
v.lon <= bounds.maxLon &&
|
||||
v.lat >= bounds.minLat &&
|
||||
v.lat <= bounds.maxLat,
|
||||
)
|
||||
: initial;
|
||||
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
|
||||
setVessels((prev) => (prev.length === 0 ? filtered : prev));
|
||||
})
|
||||
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
|
||||
}
|
||||
|
||||
client.start(setVessels, getViewportBounds);
|
||||
return () => {
|
||||
client.stop();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [getViewportBounds]);
|
||||
|
||||
// mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침.
|
||||
// MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다.
|
||||
// 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작.
|
||||
useEffect(() => {
|
||||
if (mapBounds && clientRef.current) {
|
||||
clientRef.current.refresh();
|
||||
}
|
||||
}, [mapBounds]);
|
||||
|
||||
return vessels;
|
||||
}
|
||||
@ -1,213 +1,4 @@
|
||||
export interface Vessel {
|
||||
mmsi: number
|
||||
imo: string
|
||||
name: string
|
||||
typS: string
|
||||
flag: string
|
||||
status: string
|
||||
speed: number
|
||||
heading: number
|
||||
lat: number
|
||||
lng: number
|
||||
draft: number
|
||||
depart: string
|
||||
arrive: string
|
||||
etd: string
|
||||
eta: string
|
||||
gt: string
|
||||
dwt: string
|
||||
loa: string
|
||||
beam: string
|
||||
built: string
|
||||
yard: string
|
||||
callSign: string
|
||||
cls: string
|
||||
cargo: string
|
||||
color: string
|
||||
markerType: string
|
||||
}
|
||||
|
||||
export const VESSEL_TYPE_COLORS: Record<string, string> = {
|
||||
Tanker: '#ef4444',
|
||||
Chemical: '#ef4444',
|
||||
Cargo: '#22c55e',
|
||||
Bulk: '#22c55e',
|
||||
Container: '#3b82f6',
|
||||
Passenger: '#a855f7',
|
||||
Fishing: '#f97316',
|
||||
Tug: '#06b6d4',
|
||||
Navy: '#6b7280',
|
||||
Sailing: '#fbbf24',
|
||||
}
|
||||
|
||||
export const VESSEL_LEGEND = [
|
||||
{ type: 'Tanker', color: '#ef4444' },
|
||||
{ type: 'Cargo', color: '#22c55e' },
|
||||
{ type: 'Container', color: '#3b82f6' },
|
||||
{ type: 'Fishing', color: '#f97316' },
|
||||
{ type: 'Passenger', color: '#a855f7' },
|
||||
{ type: 'Tug', color: '#06b6d4' },
|
||||
]
|
||||
|
||||
export const mockVessels: Vessel[] = [
|
||||
{
|
||||
mmsi: 440123456, imo: '9812345', name: 'HANKUK CHEMI', typS: 'Tanker', flag: '🇰🇷',
|
||||
status: '항해중', speed: 8.2, heading: 330, lat: 34.60, lng: 127.50,
|
||||
draft: 5.8, depart: '여수항', arrive: '부산항', etd: '2026-02-25 08:00', eta: '2026-02-25 18:30',
|
||||
gt: '29,246', dwt: '49,999', loa: '183.0m', beam: '32.2m', built: '2018', yard: '현대미포조선',
|
||||
callSign: 'HLKC', cls: '한국선급(KR)', cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
|
||||
color: '#ef4444', markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440234567, imo: '9823456', name: 'DONG-A GLAUCOS', typS: 'Cargo', flag: '🇰🇷',
|
||||
status: '항해중', speed: 11.4, heading: 245, lat: 34.78, lng: 127.80,
|
||||
draft: 7.2, depart: '울산항', arrive: '광양항', etd: '2026-02-25 06:30', eta: '2026-02-25 16:00',
|
||||
gt: '12,450', dwt: '18,800', loa: '144.0m', beam: '22.6m', built: '2015', yard: 'STX조선',
|
||||
callSign: 'HLDG', cls: '한국선급(KR)', cargo: '철강재 · 4,500t',
|
||||
color: '#22c55e', markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440345678, imo: '9834567', name: 'HMM ALGECIRAS', typS: 'Container', flag: '🇰🇷',
|
||||
status: '항해중', speed: 18.5, heading: 195, lat: 35.00, lng: 128.80,
|
||||
draft: 14.5, depart: '부산항', arrive: '싱가포르', etd: '2026-02-25 04:00', eta: '2026-03-02 08:00',
|
||||
gt: '228,283', dwt: '223,092', loa: '399.9m', beam: '61.0m', built: '2020', yard: '대우조선해양',
|
||||
callSign: 'HLHM', cls: "Lloyd's Register", cargo: '컨테이너 · 16,420 TEU',
|
||||
color: '#3b82f6', markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 355678901, imo: '9756789', name: 'STELLAR DAISY', typS: 'Tanker', flag: '🇵🇦',
|
||||
status: '⚠ 사고(좌초)', speed: 0.0, heading: 0, lat: 34.72, lng: 127.72,
|
||||
draft: 8.1, depart: '여수항', arrive: '—', etd: '2026-01-18 12:00', eta: '—',
|
||||
gt: '35,120', dwt: '58,000', loa: '190.0m', beam: '34.0m', built: '2012', yard: 'CSBC Taiwan',
|
||||
callSign: '3FZA7', cls: 'NK', cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
|
||||
color: '#ef4444', markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440456789, imo: '—', name: '제72 금양호', typS: 'Fishing', flag: '🇰🇷',
|
||||
status: '조업중', speed: 4.1, heading: 120, lat: 34.55, lng: 127.35,
|
||||
draft: 2.1, depart: '여수 국동항', arrive: '여수 국동항', etd: '2026-02-25 04:30', eta: '2026-02-25 18:00',
|
||||
gt: '78', dwt: '—', loa: '24.5m', beam: '6.2m', built: '2008', yard: '통영조선',
|
||||
callSign: '—', cls: '한국선급', cargo: '어획물',
|
||||
color: '#f97316', markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 440567890, imo: '9867890', name: 'PAN OCEAN GLORY', typS: 'Bulk', flag: '🇰🇷',
|
||||
status: '항해중', speed: 12.8, heading: 170, lat: 35.60, lng: 126.40,
|
||||
draft: 10.3, depart: '군산항', arrive: '포항항', etd: '2026-02-25 07:00', eta: '2026-02-26 04:00',
|
||||
gt: '43,800', dwt: '76,500', loa: '229.0m', beam: '32.3m', built: '2019', yard: '현대삼호중공업',
|
||||
callSign: 'HLPO', cls: '한국선급(KR)', cargo: '석탄 · 65,000t',
|
||||
color: '#22c55e', markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440678901, imo: '—', name: '여수예인1호', typS: 'Tug', flag: '🇰🇷',
|
||||
status: '방제지원', speed: 6.3, heading: 355, lat: 34.68, lng: 127.60,
|
||||
draft: 3.2, depart: '여수항', arrive: '사고현장', etd: '2026-01-18 16:30', eta: '—',
|
||||
gt: '280', dwt: '—', loa: '32.0m', beam: '9.5m', built: '2016', yard: '삼성중공업',
|
||||
callSign: 'HLYT', cls: '한국선급', cargo: '방제장비 · 오일붐 500m',
|
||||
color: '#06b6d4', markerType: 'tug',
|
||||
},
|
||||
{
|
||||
mmsi: 235012345, imo: '9456789', name: 'QUEEN MARY', typS: 'Passenger', flag: '🇬🇧',
|
||||
status: '항해중', speed: 15.2, heading: 10, lat: 33.80, lng: 127.00,
|
||||
draft: 8.5, depart: '상하이', arrive: '부산항', etd: '2026-02-24 18:00', eta: '2026-02-26 06:00',
|
||||
gt: '148,528', dwt: '18,000', loa: '345.0m', beam: '41.0m', built: '2004', yard: "Chantiers de l'Atlantique",
|
||||
callSign: 'GBQM2', cls: "Lloyd's Register", cargo: '승객 2,620명',
|
||||
color: '#a855f7', markerType: 'passenger',
|
||||
},
|
||||
{
|
||||
mmsi: 353012345, imo: '9811000', name: 'EVER GIVEN', typS: 'Container', flag: '🇹🇼',
|
||||
status: '항해중', speed: 14.7, heading: 220, lat: 35.20, lng: 129.20,
|
||||
draft: 15.7, depart: '부산항', arrive: '카오슝', etd: '2026-02-25 02:00', eta: '2026-02-28 14:00',
|
||||
gt: '220,940', dwt: '199,629', loa: '400.0m', beam: '59.0m', built: '2018', yard: '今治造船',
|
||||
callSign: 'BIXE9', cls: 'ABS', cargo: '컨테이너 · 14,800 TEU',
|
||||
color: '#3b82f6', markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 440789012, imo: '—', name: '제85 대성호', typS: 'Fishing', flag: '🇰🇷',
|
||||
status: '조업중', speed: 3.8, heading: 85, lat: 34.40, lng: 126.30,
|
||||
draft: 1.8, depart: '목포항', arrive: '목포항', etd: '2026-02-25 03:00', eta: '2026-02-25 17:00',
|
||||
gt: '65', dwt: '—', loa: '22.0m', beam: '5.8m', built: '2010', yard: '목포조선',
|
||||
callSign: '—', cls: '한국선급', cargo: '어획물',
|
||||
color: '#f97316', markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 440890123, imo: '9878901', name: 'SK INNOVATION', typS: 'Chemical', flag: '🇰🇷',
|
||||
status: '항해중', speed: 9.6, heading: 340, lat: 35.80, lng: 126.60,
|
||||
draft: 6.5, depart: '대산항', arrive: '여수항', etd: '2026-02-25 10:00', eta: '2026-02-26 02:00',
|
||||
gt: '11,200', dwt: '16,800', loa: '132.0m', beam: '20.4m', built: '2020', yard: '현대미포조선',
|
||||
callSign: 'HLSK', cls: '한국선급(KR)', cargo: '톨루엔 · 8,500kL · IMO Class 3',
|
||||
color: '#ef4444', markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440901234, imo: '9889012', name: 'KOREA EXPRESS', typS: 'Cargo', flag: '🇰🇷',
|
||||
status: '항해중', speed: 10.1, heading: 190, lat: 36.20, lng: 128.50,
|
||||
draft: 6.8, depart: '동해항', arrive: '포항항', etd: '2026-02-25 09:00', eta: '2026-02-25 15:00',
|
||||
gt: '8,500', dwt: '12,000', loa: '118.0m', beam: '18.2m', built: '2014', yard: '대한조선',
|
||||
callSign: 'HLKE', cls: '한국선급', cargo: '일반화물',
|
||||
color: '#22c55e', markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440012345, imo: '—', name: 'ROKS SEJONG', typS: 'Navy', flag: '🇰🇷',
|
||||
status: '작전중', speed: 16.0, heading: 270, lat: 35.30, lng: 129.50,
|
||||
draft: 6.3, depart: '부산 해군기지', arrive: '—', etd: '—', eta: '—',
|
||||
gt: '7,600', dwt: '—', loa: '165.9m', beam: '21.4m', built: '2008', yard: '현대중공업',
|
||||
callSign: 'HLNS', cls: '군용', cargo: '군사작전',
|
||||
color: '#6b7280', markerType: 'military',
|
||||
},
|
||||
{
|
||||
mmsi: 440023456, imo: '—', name: '군산예인3호', typS: 'Tug', flag: '🇰🇷',
|
||||
status: '대기중', speed: 5.5, heading: 140, lat: 35.90, lng: 126.90,
|
||||
draft: 2.8, depart: '군산항', arrive: '군산항', etd: '—', eta: '—',
|
||||
gt: '180', dwt: '—', loa: '28.0m', beam: '8.2m', built: '2019', yard: '통영조선',
|
||||
callSign: 'HLGS', cls: '한국선급', cargo: '—',
|
||||
color: '#06b6d4', markerType: 'tug',
|
||||
},
|
||||
{
|
||||
mmsi: 440034567, imo: '—', name: 'JEJU WIND', typS: 'Sailing', flag: '🇰🇷',
|
||||
status: '항해중', speed: 6.8, heading: 290, lat: 33.35, lng: 126.65,
|
||||
draft: 2.5, depart: '제주항', arrive: '제주항', etd: '2026-02-25 10:00', eta: '2026-02-25 16:00',
|
||||
gt: '45', dwt: '—', loa: '18.0m', beam: '5.0m', built: '2022', yard: '제주요트',
|
||||
callSign: '—', cls: '—', cargo: '—',
|
||||
color: '#fbbf24', markerType: 'sail',
|
||||
},
|
||||
{
|
||||
mmsi: 440045678, imo: '—', name: '제33 삼양호', typS: 'Fishing', flag: '🇰🇷',
|
||||
status: '조업중', speed: 2.4, heading: 55, lat: 35.10, lng: 127.40,
|
||||
draft: 1.6, depart: '통영항', arrive: '통영항', etd: '2026-02-25 05:00', eta: '2026-02-25 19:00',
|
||||
gt: '52', dwt: '—', loa: '20.0m', beam: '5.4m', built: '2006', yard: '거제조선',
|
||||
callSign: '—', cls: '한국선급', cargo: '어획물',
|
||||
color: '#f97316', markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 255012345, imo: '9703291', name: 'MSC OSCAR', typS: 'Container', flag: '🇨🇭',
|
||||
status: '항해중', speed: 17.3, heading: 355, lat: 34.10, lng: 128.10,
|
||||
draft: 14.0, depart: '카오슝', arrive: '부산항', etd: '2026-02-23 08:00', eta: '2026-02-25 22:00',
|
||||
gt: '197,362', dwt: '199,272', loa: '395.4m', beam: '59.0m', built: '2015', yard: '대우조선해양',
|
||||
callSign: '9HA4713', cls: 'DNV', cargo: '컨테이너 · 18,200 TEU',
|
||||
color: '#3b82f6', markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 440056789, imo: '9890567', name: 'SAEHAN PIONEER', typS: 'Tanker', flag: '🇰🇷',
|
||||
status: '항해중', speed: 7.9, heading: 310, lat: 34.90, lng: 127.10,
|
||||
draft: 5.2, depart: '여수항', arrive: '대산항', etd: '2026-02-25 11:00', eta: '2026-02-26 08:00',
|
||||
gt: '8,900', dwt: '14,200', loa: '120.0m', beam: '18.0m', built: '2017', yard: '현대미포조선',
|
||||
callSign: 'HLSP', cls: '한국선급(KR)', cargo: '경유 · 10,000kL',
|
||||
color: '#ef4444', markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440067890, imo: '9891678', name: 'DONGHAE STAR', typS: 'Cargo', flag: '🇰🇷',
|
||||
status: '항해중', speed: 11.0, heading: 155, lat: 37.55, lng: 129.30,
|
||||
draft: 6.0, depart: '속초항', arrive: '동해항', etd: '2026-02-25 12:00', eta: '2026-02-25 16:30',
|
||||
gt: '6,200', dwt: '8,500', loa: '105.0m', beam: '16.5m', built: '2013', yard: '대한조선',
|
||||
callSign: 'HLDS', cls: '한국선급', cargo: '일반화물 · 목재',
|
||||
color: '#22c55e', markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440078901, imo: '—', name: '제18 한라호', typS: 'Fishing', flag: '🇰🇷',
|
||||
status: '귀항중', speed: 3.2, heading: 70, lat: 33.30, lng: 126.30,
|
||||
draft: 1.9, depart: '서귀포항', arrive: '서귀포항', etd: '2026-02-25 04:00', eta: '2026-02-25 15:00',
|
||||
gt: '58', dwt: '—', loa: '21.0m', beam: '5.6m', built: '2011', yard: '제주조선',
|
||||
callSign: '—', cls: '한국선급', cargo: '어획물 · 갈치/고등어',
|
||||
color: '#f97316', markerType: 'fishing',
|
||||
},
|
||||
]
|
||||
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
||||
// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다.
|
||||
// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
||||
export {};
|
||||
|
||||
@ -1,68 +1,68 @@
|
||||
import axios from 'axios'
|
||||
import axios from 'axios';
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // JWT 쿠키 자동 포함
|
||||
timeout: 30000, // 30초 타임아웃
|
||||
withCredentials: true, // JWT 쿠키 자동 포함
|
||||
timeout: 30000, // 30초 타임아웃
|
||||
maxContentLength: 10 * 1024 * 1024, // 응답 최대 10MB
|
||||
maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB
|
||||
})
|
||||
maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB
|
||||
});
|
||||
|
||||
// 응답 인터셉터: 민감한 에러 정보 노출 최소화 + 401 세션 만료 처리
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const { status, data } = error.response;
|
||||
|
||||
// 401: 인증 만료 → 로그아웃 처리 (로그인 요청 제외)
|
||||
if (status === 401 && !error.config?.url?.includes('/auth/login')) {
|
||||
import('../store/authStore').then(({ useAuthStore }) => {
|
||||
const { isAuthenticated } = useAuthStore.getState()
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (isAuthenticated) {
|
||||
useAuthStore.getState().logout()
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status,
|
||||
message: data?.error || data?.message || '요청 처리 중 오류가 발생했습니다.',
|
||||
})
|
||||
});
|
||||
}
|
||||
return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' })
|
||||
}
|
||||
)
|
||||
return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' });
|
||||
},
|
||||
);
|
||||
|
||||
export interface LayerDTO {
|
||||
cmn_cd: string
|
||||
up_cmn_cd: string | null
|
||||
cmn_cd_full_nm: string
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
data_tbl_nm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: LayerDTO[]
|
||||
cmn_cd: string;
|
||||
up_cmn_cd: string | null;
|
||||
cmn_cd_full_nm: string;
|
||||
cmn_cd_nm: string;
|
||||
cmn_cd_level: number;
|
||||
clnm: string | null;
|
||||
data_tbl_nm?: string | null;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
children?: LayerDTO[];
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
id: string
|
||||
parentId: string | null
|
||||
name: string
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
fullName: string;
|
||||
level: number;
|
||||
wmsLayer: string | null;
|
||||
dataTblNm?: string | null;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
children?: Layer[];
|
||||
}
|
||||
|
||||
// DTO를 Layer로 변환 (재귀적으로 children도 변환)
|
||||
@ -78,41 +78,41 @@ function convertToLayer(dto: LayerDTO): Layer {
|
||||
icon: dto.icon,
|
||||
count: dto.count,
|
||||
children: dto.children ? dto.children.map(convertToLayer) : undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 모든 레이어 조회
|
||||
export async function fetchAllLayers(): Promise<Layer[]> {
|
||||
const response = await api.get<LayerDTO[]>('/layers')
|
||||
return response.data.map(convertToLayer)
|
||||
const response = await api.get<LayerDTO[]>('/layers');
|
||||
return response.data.map(convertToLayer);
|
||||
}
|
||||
|
||||
// 계층 구조 레이어 트리 조회
|
||||
export async function fetchLayerTree(): Promise<Layer[]> {
|
||||
const response = await api.get<LayerDTO[]>('/layers/tree/all')
|
||||
return response.data.map(convertToLayer)
|
||||
const response = await api.get<LayerDTO[]>('/layers/tree/all');
|
||||
return response.data.map(convertToLayer);
|
||||
}
|
||||
|
||||
// WMS 레이어만 조회
|
||||
export async function fetchWMSLayers(): Promise<Layer[]> {
|
||||
const response = await api.get<LayerDTO[]>('/layers/wms/all')
|
||||
return response.data.map(convertToLayer)
|
||||
const response = await api.get<LayerDTO[]>('/layers/wms/all');
|
||||
return response.data.map(convertToLayer);
|
||||
}
|
||||
|
||||
// 특정 레이어 조회
|
||||
export async function fetchLayer(id: string): Promise<Layer> {
|
||||
const response = await api.get<LayerDTO>(`/layers/${id}`)
|
||||
return convertToLayer(response.data)
|
||||
const response = await api.get<LayerDTO>(`/layers/${id}`);
|
||||
return convertToLayer(response.data);
|
||||
}
|
||||
|
||||
// 특정 레벨의 레이어 조회
|
||||
export async function fetchLayersByLevel(level: number): Promise<Layer[]> {
|
||||
const response = await api.get<LayerDTO[]>(`/layers/level/${level}`)
|
||||
return response.data.map(convertToLayer)
|
||||
const response = await api.get<LayerDTO[]>(`/layers/level/${level}`);
|
||||
return response.data.map(convertToLayer);
|
||||
}
|
||||
|
||||
// 특정 부모의 자식 레이어 조회
|
||||
export async function fetchChildrenLayers(parentId: string): Promise<Layer[]> {
|
||||
const response = await api.get<LayerDTO[]>(`/layers/children/${parentId}`)
|
||||
return response.data.map(convertToLayer)
|
||||
const response = await api.get<LayerDTO[]>(`/layers/children/${parentId}`);
|
||||
return response.data.map(convertToLayer);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user