Compare commits

..

2 커밋

3123개의 변경된 파일193360개의 추가작업 그리고 369785개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

@ -3,7 +3,6 @@
# 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")

파일 보기

@ -1,17 +0,0 @@
# 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))

파일 보기

@ -1,27 +0,0 @@
#!/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
파일 보기

@ -79,9 +79,6 @@ 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
@ -102,10 +99,3 @@ backend/scripts/hns-import/out/
# 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 레이어 트리)
│ └── components/ 탭 단위 패키지 (@components/ alias)
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
│ ├── rescue/ 구조 시나리오
@ -96,7 +96,7 @@ wing/
### Path Alias
- `@common/*` -> `src/common/*` (공통 모듈)
- `@components/*` -> `src/components/*` (탭 패키지)
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
## 팀 컨벤션
@ -107,8 +107,6 @@ 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/*`, `@components/*` -> `src/components/*`
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
```
wing/
@ -95,7 +95,7 @@ wing/
│ │ ├── types/ backtrack, boomLine, hns, navigation
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
│ │ └── data/ layerData.ts (UI 레이어 트리)
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
│ ├── rescue/ 구조 시나리오

파일 보기

@ -22,7 +22,6 @@
"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",
@ -35,37 +34,6 @@
"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",
@ -1721,20 +1689,6 @@
"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",
@ -2664,13 +2618,6 @@
"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,7 +23,6 @@
"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",

파일 보기

@ -1,140 +0,0 @@
# 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에 보존.

파일 보기

@ -1,236 +0,0 @@
"""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()

파일 보기

@ -1,170 +0,0 @@
"""물질별 시트에서 메인 카드 이미지(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()

파일 보기

@ -1,707 +0,0 @@
"""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'[이oO]', '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이이아오-]{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이이아oO-\-—-"\'\. ]{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이이아oO-\-—-"\'\. ]{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()

파일 보기

@ -1,23 +0,0 @@
"""배치 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)}')

파일 보기

@ -1,362 +0,0 @@
/**
* 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();

파일 보기

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

파일 보기

@ -1,324 +0,0 @@
"""로컬 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()

파일 보기

@ -1,29 +0,0 @@
# 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,8 +1,5 @@
import express from 'express';
import { mkdirSync, existsSync } from 'fs';
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
import {
listMedia,
createMedia,
@ -16,10 +13,6 @@ import {
requestOilInference,
checkInferenceHealth,
stitchImages,
listDroneStreams,
startDroneStream,
stopDroneStream,
getHlsDirectory,
} from './aerialService.js';
import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
@ -27,29 +20,6 @@ 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 라우트
// ============================================================
@ -98,96 +68,6 @@ 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 {
@ -241,92 +121,6 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
}
});
// ============================================================
// KBS 재난안전포탈 CCTV HLS 리졸버
// ============================================================
/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */
const kbsHlsCache = new Map<string, { url: string; ts: number }>();
const KBS_CACHE_TTL = 5 * 60 * 1000;
// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시
router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
try {
const cctvId = req.params.cctvId as string;
if (!/^\d+$/.test(cctvId)) {
res.status(400).json({ error: '유효하지 않은 cctvId' });
return;
}
let m3u8Url: string | null = null;
// 캐시 확인
const cached = kbsHlsCache.get(cctvId);
if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) {
m3u8Url = cached.url;
} else {
// 1단계: KBS 팝업 API에서 loomex API URL 추출
const popupRes = await fetch(
`https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`,
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } },
);
if (!popupRes.ok) {
res.status(502).json({ error: 'KBS 팝업 API 응답 실패' });
return;
}
const popupHtml = await popupRes.text();
const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/);
if (!urlMatch) {
res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' });
return;
}
// 2단계: loomex API에서 실제 m3u8 URL 획득
const loomexRes = await fetch(urlMatch[1], {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!loomexRes.ok) {
res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' });
return;
}
m3u8Url = (await loomexRes.text()).trim();
kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() });
}
// 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성
const upstream = await fetch(m3u8Url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!upstream.ok) {
// 캐시 무효화 후 재시도 유도
kbsHlsCache.delete(cctvId);
res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' });
return;
}
const text = await upstream.text();
const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
if (line.startsWith('http://') || line.startsWith('https://')) {
return `${proxyBase}${encodeURIComponent(line)}`;
}
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
});
res.set({
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.send(rewritten);
} catch (err) {
console.error('[aerial] KBS HLS 리졸버 오류:', err);
res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' });
}
});
// ============================================================
// CCTV HLS 스트림 프록시 (CORS 우회)
// ============================================================
@ -335,7 +129,6 @@ router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
const ALLOWED_STREAM_HOSTS = [
'www.khoa.go.kr',
'kbsapi.loomex.net',
'kbscctv-cache.loomex.net',
];
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
@ -408,87 +201,6 @@ router.get('/cctv/stream-proxy', async (req, res) => {
}
});
// ============================================================
// DRONE STREAM 라우트 (RTSP → HLS)
// ============================================================
// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태
router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
const streams = listDroneStreams();
res.json(streams);
} catch (err) {
console.error('[aerial] 드론 스트림 목록 오류:', err);
res.status(500).json({ error: '드론 스트림 목록 조회 실패' });
}
});
// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환)
router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = startDroneStream(req.params.id as string);
if (!result.success) {
res.status(400).json({ error: result.error });
return;
}
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 시작 오류:', err);
res.status(500).json({ error: '드론 스트림 시작 실패' });
}
});
// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지
router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = stopDroneStream(req.params.id as string);
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 중지 오류:', err);
res.status(500).json({ error: '드론 스트림 중지 실패' });
}
});
// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts)
router.get('/drone/hls/:id/*', async (req, res) => {
try {
const id = req.params.id as string;
const hlsDir = getHlsDirectory(id);
if (!hlsDir) {
res.status(404).json({ error: '스트림을 찾을 수 없습니다' });
return;
}
// wildcard: req.params[0] contains the rest of the path
// Cast through unknown because @types/express v5 types the wildcard key as string[]
const rawParams = req.params as unknown as Record<string, string | string[]>;
const wildcardRaw = rawParams['0'] ?? '';
const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw;
const filePath = path.join(hlsDir, wildcardParam);
// Security: prevent path traversal
if (!filePath.startsWith(hlsDir)) {
res.status(403).json({ error: '접근 거부' });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl'
: ext === '.ts' ? 'video/mp2t'
: 'application/octet-stream';
res.set({
'Content-Type': contentType,
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.sendFile(filePath);
} catch (err) {
console.error('[aerial] HLS 파일 서빙 오류:', err);
res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' });
}
});
// ============================================================
// SAT_REQUEST 라우트
// ============================================================
@ -550,103 +262,6 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
}
});
// ============================================================
// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도)
// ============================================================
/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */
function generateKoreaSatellitePasses() {
const now = new Date();
const passes = [
{
id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical',
resolution: '0.5m', color: '#a855f7',
startTime: new Date(now.getTime() + 2 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(),
maxElevation: 72, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 },
{ lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 },
{ lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 },
],
},
{
id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical',
resolution: '0.3m', color: '#06b6d4',
startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(),
maxElevation: 65, direction: 'ascending',
orbit: [
{ lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 },
{ lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 },
{ lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 },
],
},
{
id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar',
resolution: '20m', color: '#f59e0b',
startTime: new Date(now.getTime() + 5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(),
maxElevation: 58, direction: 'descending',
orbit: [
{ lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 },
{ lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 },
{ lat: 31.0, lon: 128.5 },
],
},
{
id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical',
resolution: '0.31m', color: '#3b82f6',
startTime: new Date(now.getTime() + 8 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(),
maxElevation: 80, direction: 'descending',
orbit: [
{ lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 },
{ lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 },
{ lat: 32.0, lon: 126.5 },
],
},
{
id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical',
resolution: '0.5m', color: '#22c55e',
startTime: new Date(now.getTime() + 12 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(),
maxElevation: 55, direction: 'ascending',
orbit: [
{ lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 },
{ lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 },
{ lat: 40.0, lon: 124.0 },
],
},
{
id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical',
resolution: '10m', color: '#ec4899',
startTime: new Date(now.getTime() + 18 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(),
maxElevation: 62, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 },
{ lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 },
],
},
];
return passes;
}
// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비)
router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
// TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체
// const token = await getUp42Token()
// const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] })
const passes = generateKoreaSatellitePasses();
res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' });
} catch (err) {
console.error('[aerial] 위성 패스 조회 오류:', err);
res.status(500).json({ error: '위성 패스 조회 실패' });
}
});
// ============================================================
// OIL INFERENCE 라우트
// ============================================================

파일 보기

@ -1,8 +1,4 @@
import { wingPool } from '../db/wingDb.js';
import { spawn, type ChildProcess } from 'child_process';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
// ============================================================
// AERIAL_MEDIA
@ -368,8 +364,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
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 IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion {
@ -413,9 +408,8 @@ export async function stitchImages(
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
try {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
const response = await fetch(`${IMAGE_API_URL}/inference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }),
@ -436,7 +430,7 @@ export async function requestOilInference(imageBase64: string): Promise<OilInfer
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
const response = await fetch(`${IMAGE_API_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`status ${response.status}`);
@ -445,175 +439,3 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?:
return { status: 'unavailable' };
}
}
// ============================================================
// DRONE STREAM (RTSP → HLS via FFmpeg)
// ============================================================
export interface DroneStreamConfig {
id: string;
name: string;
shipName: string;
droneModel: string;
ip: string;
rtspUrl: string;
region: string;
}
export interface DroneStreamStatus extends DroneStreamConfig {
status: 'idle' | 'starting' | 'streaming' | 'error';
hlsUrl: string | null;
error: string | null;
}
const DRONE_STREAMS: DroneStreamConfig[] = [
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' },
];
const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls';
const activeProcesses = new Map<string, { process: ChildProcess; status: 'starting' | 'streaming' | 'error'; error: string | null }>();
function getHlsDir(id: string): string {
return path.join(HLS_OUTPUT_DIR, id);
}
function checkFfmpeg(): boolean {
try {
execSync('which ffmpeg', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
export function listDroneStreams(): DroneStreamStatus[] {
return DRONE_STREAMS.map(ds => {
const active = activeProcesses.get(ds.id);
return {
...ds,
status: active?.status ?? 'idle',
hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null,
error: active?.error ?? null,
};
});
}
export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } {
const config = DRONE_STREAMS.find(d => d.id === id);
if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' };
if (activeProcesses.has(id)) {
const existing = activeProcesses.get(id)!;
if (existing.status === 'streaming' || existing.status === 'starting') {
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
}
}
if (!checkFfmpeg()) {
return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' };
}
const hlsDir = getHlsDir(id);
if (!existsSync(hlsDir)) {
mkdirSync(hlsDir, { recursive: true });
}
const outputPath = path.join(hlsDir, 'stream.m3u8');
const ffmpeg = spawn('ffmpeg', [
'-rtsp_transport', 'tcp',
'-i', config.rtspUrl,
'-c:v', 'copy',
'-c:a', 'aac',
'-f', 'hls',
'-hls_time', '2',
'-hls_list_size', '5',
'-hls_flags', 'delete_segments',
'-y',
outputPath,
], { stdio: ['ignore', 'pipe', 'pipe'] });
const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null };
activeProcesses.set(id, entry);
// Monitor for m3u8 file creation to confirm streaming
const checkInterval = setInterval(() => {
if (existsSync(outputPath)) {
const e = activeProcesses.get(id);
if (e && e.status === 'starting') {
e.status = 'streaming';
}
clearInterval(checkInterval);
}
}, 500);
// Timeout after 15 seconds
setTimeout(() => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e && e.status === 'starting') {
e.status = 'error';
e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.';
ffmpeg.kill('SIGTERM');
}
}, 15000);
let stderrBuf = '';
ffmpeg.stderr?.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString();
});
ffmpeg.on('close', (code) => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e) {
if (e.status !== 'error') {
e.status = 'error';
e.error = code !== 0
? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}`
: '스트림 종료';
}
}
console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`);
});
ffmpeg.on('error', (err) => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e) {
e.status = 'error';
e.error = `FFmpeg 실행 오류: ${err.message}`;
}
});
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
}
export function stopDroneStream(id: string): { success: boolean } {
const entry = activeProcesses.get(id);
if (!entry) return { success: true };
entry.process.kill('SIGTERM');
activeProcesses.delete(id);
// Cleanup HLS files
const hlsDir = getHlsDir(id);
try {
if (existsSync(hlsDir)) {
rmSync(hlsDir, { recursive: true, force: true });
}
} catch (err) {
console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err);
}
return { success: true };
}
export function getHlsDirectory(id: string): string | null {
const config = DRONE_STREAMS.find(d => d.id === id);
if (!config) return null;
const dir = getHlsDir(id);
return existsSync(dir) ? dir : null;
}

파일 보기

@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listOrganizations, getOrganization, listUploadLogs, listInsurance, listNearbyOrganizations } from './assetsService.js';
import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js';
const router = Router();
@ -22,26 +22,6 @@ router.get('/orgs', requireAuth, async (req, res) => {
}
});
// ============================================================
// GET /api/assets/orgs/nearby — 근처 기관 목록 (PostGIS 반경 검색)
// ============================================================
router.get('/orgs/nearby', requireAuth, async (req, res) => {
try {
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
const radius = parseFloat(req.query.radius as string);
if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius <= 0) {
res.status(400).json({ error: '유효하지 않은 좌표 또는 반경입니다.' });
return;
}
const orgs = await listNearbyOrganizations(lat, lng, radius);
res.json(orgs);
} catch (err) {
console.error('[assets] 근처 기관 조회 오류:', err);
res.status(500).json({ error: '근처 기관 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/assets/orgs/:sn — 기관 상세 (장비 + 담당자)
// ============================================================

파일 보기

@ -162,54 +162,6 @@ export async function getOrganization(orgSn: number): Promise<OrgDetail | null>
};
}
// ============================================================
// 근처 기관 조회 (PostGIS ST_DWithin)
// ============================================================
export interface NearbyOrgItem extends OrgListItem {
distanceNm: number;
}
export async function listNearbyOrganizations(
lat: number,
lng: number,
radiusNm: number,
): Promise<NearbyOrgItem[]> {
const radiusMeters = radiusNm * 1852;
const sql = `
SELECT ORG_SN, ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL,
LAT, LNG, PIN_SIZE,
VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS,
ST_Distance(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) / 1852.0 AS distance_nm
FROM wing.ASSET_ORG
WHERE USE_YN = 'Y'
AND GEOM IS NOT NULL
AND ST_DWithin(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3)
ORDER BY distance_nm
`;
const { rows } = await wingPool.query(sql, [lat, lng, radiusMeters]);
return rows.map((r: Record<string, unknown>) => ({
orgSn: r.org_sn as number,
orgTp: r.org_tp as string,
jrsdNm: r.jrsd_nm as string,
areaNm: r.area_nm as string,
orgNm: r.org_nm as string,
addr: r.addr as string,
tel: r.tel as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
pinSize: r.pin_size as string,
vesselCnt: r.vessel_cnt as number,
skimmerCnt: r.skimmer_cnt as number,
pumpCnt: r.pump_cnt as number,
vehicleCnt: r.vehicle_cnt as number,
sprayerCnt: r.sprayer_cnt as number,
totalAssets: r.total_assets as number,
distanceNm: parseFloat(r.distance_nm as string),
}));
}
// ============================================================
// 선박보험(유류오염보장계약) 조회
// ============================================================

파일 보기

@ -24,14 +24,8 @@ 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(
@ -45,16 +39,7 @@ async function seedHnsSubstances() {
CAS_NO = EXCLUDED.CAS_NO,
SEBC = EXCLUDED.SEBC,
DATA = EXCLUDED.DATA`,
[
s.id,
firstToken(abbreviation, 50),
firstToken(nameKr, 200) ?? '',
firstToken(nameEn, 200),
firstToken(unNumber, 10),
firstToken(casNumber, 20),
firstToken(sebc, 50),
JSON.stringify(detailData),
]
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
)
inserted++

파일 보기

@ -1,20 +0,0 @@
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;

파일 보기

@ -1,44 +0,0 @@
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,13 +12,11 @@ const router = express.Router()
// GET /api/hns/analyses — 분석 목록
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
try {
const { status, substance, search, acdntSn } = req.query
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
const { status, substance, search } = req.query
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) {
@ -50,15 +48,13 @@ 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, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
const { anlysNm, 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, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
})
res.status(201).json(result)
} catch (err) {

파일 보기

@ -94,7 +94,6 @@ export async function searchSubstances(params: HnsSearchParams) {
interface HnsAnalysisItem {
hnsAnlysSn: number
acdntSn: number | null
anlysNm: string
acdntDtm: string | null
locNm: string | null
@ -119,13 +118,11 @@ 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,
@ -149,7 +146,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 | number)[] = []
const params: string[] = []
let idx = 1
if (input.status) {
@ -165,13 +162,9 @@ 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, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
`SELECT HNS_ANLYS_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
@ -186,7 +179,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, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
`SELECT HNS_ANLYS_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,
@ -201,7 +194,6 @@ export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
export async function createAnalysis(input: {
anlysNm: string
acdntSn?: number
acdntDtm?: string
locNm?: string
lon?: number
@ -221,21 +213,21 @@ export async function createAnalysis(input: {
}): Promise<{ hnsAnlysSn: number }> {
const { rows } = await wingPool.query(
`INSERT INTO HNS_ANALYSIS (
ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
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::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'
$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'
) RETURNING HNS_ANLYS_SN`,
[
input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || 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,

파일 보기

@ -5,9 +5,7 @@ import {
getIncident,
listIncidentPredictions,
getIncidentWeather,
saveIncidentWeather,
getIncidentMedia,
getIncidentImageAnalysis,
} from './incidentsService.js';
const router = Router();
@ -94,24 +92,6 @@ router.get('/:sn/weather', requireAuth, async (req, res) => {
}
});
// ============================================================
// POST /api/incidents/:sn/weather — 기상정보 저장
// ============================================================
router.post('/:sn/weather', requireAuth, async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
return;
}
const weatherSn = await saveIncidentWeather(sn, req.body as Record<string, unknown>);
res.json({ weatherSn });
} catch (err) {
console.error('[incidents] 기상정보 저장 오류:', err);
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/incidents/:sn/media — 미디어 정보
// ============================================================
@ -134,26 +114,4 @@ 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,11 +24,7 @@ interface IncidentListItem {
spilQty: number | null;
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}
interface PredExecItem {
@ -115,29 +111,11 @@ 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,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
@ -170,11 +148,7 @@ 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,
}));
}
@ -188,29 +162,11 @@ 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,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
@ -249,11 +205,7 @@ 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,
@ -302,143 +254,24 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
const r = rows[0] as Record<string, unknown>;
return {
locNm: (r.loc_nm as string | null) ?? '-',
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
icon: (r.icon as string | null) ?? '',
temp: (r.temp as string | null) ?? '-',
weatherDc: (r.weather_dc as string | null) ?? '-',
wind: (r.wind as string | null) ?? '-',
wave: (r.wave as string | null) ?? '-',
humid: (r.humid as string | null) ?? '-',
vis: (r.vis as string | null) ?? '-',
sst: (r.sst as string | null) ?? '-',
tide: (r.tide as string | null) ?? '-',
highTide: (r.high_tide as string | null) ?? '-',
lowTide: (r.low_tide as string | null) ?? '-',
locNm: r.loc_nm as string,
obsDtm: (r.obs_dtm as Date).toISOString(),
icon: r.icon as string,
temp: r.temp as string,
weatherDc: r.weather_dc as string,
wind: r.wind as string,
wave: r.wave as string,
humid: r.humid as string,
vis: r.vis as string,
sst: r.sst as string,
tide: r.tide as string,
highTide: r.high_tide as string,
lowTide: r.low_tide as string,
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
impactDc: (r.impact_dc as string | null) ?? '-',
impactDc: r.impact_dc as string,
};
}
// ============================================================
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
// ============================================================
interface WeatherSnapshotPayload {
stationName?: string;
capturedAt?: string;
wind?: {
speed?: number;
direction?: number;
directionLabel?: string;
speed_1k?: number;
speed_3k?: number;
};
wave?: {
height?: number;
maxHeight?: number;
period?: number;
direction?: string;
};
temperature?: {
current?: number;
feelsLike?: number;
};
pressure?: number;
visibility?: number;
salinity?: number;
astronomy?: {
sunrise?: string;
sunset?: string;
moonrise?: string;
moonset?: string;
moonPhase?: string;
tidalRange?: number;
} | null;
alert?: string | null;
forecast?: unknown[] | null;
}
export async function saveIncidentWeather(
acdntSn: number,
snapshot: WeatherSnapshotPayload,
): Promise<number> {
// 팝업 표시용 포맷 문자열
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
const highTideStr = snapshot.astronomy?.tidalRange != null
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
type ForecastItem = { time?: string; icon?: string; temperature?: number };
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
hour: f.time ?? '',
icon: f.icon ?? '⛅',
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
})) ?? null;
const sql = `
INSERT INTO wing.ACDNT_WEATHER (
ACDNT_SN, LOC_NM, OBS_DTM,
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
PRESSURE, VIS,
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
SST, AIR_TEMP, SALINITY,
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
WEATHER_ALERT, FORECAST,
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
) VALUES (
$1, $2, NOW(),
$3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12, $13,
$14, $15, $16,
$17, $18, $19, $20, $21, $22,
$23, $24,
$25, $26, $27, $28, $29, $30
)
RETURNING WEATHER_SN
`;
const { rows } = await wingPool.query(sql, [
acdntSn,
snapshot.stationName ?? null,
snapshot.wind?.speed ?? null,
snapshot.wind?.direction ?? null,
snapshot.wind?.directionLabel ?? null,
snapshot.wind?.speed_1k ?? null,
snapshot.wind?.speed_3k ?? null,
snapshot.pressure ?? null,
vis,
snapshot.wave?.height ?? null,
snapshot.wave?.maxHeight ?? null,
snapshot.wave?.period ?? null,
snapshot.wave?.direction ?? null,
sst,
snapshot.temperature?.feelsLike ?? null,
snapshot.salinity ?? null,
snapshot.astronomy?.sunrise ?? null,
snapshot.astronomy?.sunset ?? null,
snapshot.astronomy?.moonrise ?? null,
snapshot.astronomy?.moonset ?? null,
snapshot.astronomy?.moonPhase ?? null,
snapshot.astronomy?.tidalRange ?? null,
snapshot.alert ?? null,
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
tempStr,
windStr,
waveStr,
'🌊',
highTideStr,
snapshot.alert ?? null,
]);
return (rows[0] as Record<string, unknown>).weather_sn as number;
}
// ============================================================
// 미디어 정보 조회
// ============================================================
@ -467,21 +300,3 @@ 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>;
}

파일 보기

@ -1,117 +0,0 @@
import { Router } from 'express'
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
import {
listMapBase,
getActiveMapTypes,
createMapBase,
updateMapBase,
deleteMapBase,
} from './mapBaseService.js'
const router = Router()
// GET /api/map-base/active — 활성 지도 목록 (전체 사용자)
router.get('/active', requireAuth, async (_req, res) => {
try {
const types = await getActiveMapTypes()
res.json(types)
} catch (err) {
console.error('[map-base] 활성 목록 조회 오류:', err)
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
}
})
// GET /api/map-base — 전체 목록 (관리자)
router.get('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const page = parseInt(req.query.page as string, 10) || 1
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100)
const result = await listMapBase(page, limit)
res.json(result)
} catch (err) {
console.error('[map-base] 목록 조회 오류:', err)
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
}
})
// POST /api/map-base — 등록 (관리자)
router.post('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
mapKey?: string;
mapNm?: string;
mapLevelCd?: string;
mapSrc?: string;
mapDc?: string;
useYn?: string;
}
if (!mapKey || !mapNm) {
res.status(400).json({ error: '지도 키와 이름은 필수입니다.' })
return
}
const created = await createMapBase({
mapKey,
mapNm,
mapLevelCd,
mapSrc,
mapDc,
useYn,
regId: req.user?.sub,
regNm: req.user?.name,
})
res.json(created)
} catch (err) {
console.error('[map-base] 등록 오류:', err)
res.status(500).json({ error: '지도 등록 중 오류가 발생했습니다.' })
}
})
// POST /api/map-base/update — 수정 (관리자)
router.post('/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { mapSn, mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
mapSn?: number;
mapKey?: string;
mapNm?: string;
mapLevelCd?: string | null;
mapSrc?: string | null;
mapDc?: string | null;
useYn?: string;
}
if (!mapSn) {
res.status(400).json({ error: '지도 번호가 필요합니다.' })
return
}
const updated = await updateMapBase(Number(mapSn), { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn })
if (!updated) {
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
return
}
res.json(updated)
} catch (err) {
console.error('[map-base] 수정 오류:', err)
res.status(500).json({ error: '지도 수정 중 오류가 발생했습니다.' })
}
})
// POST /api/map-base/delete — 삭제 (관리자, soft-delete)
router.post('/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { mapSn } = req.body as { mapSn?: number }
if (!mapSn) {
res.status(400).json({ error: '지도 번호가 필요합니다.' })
return
}
const ok = await deleteMapBase(Number(mapSn))
if (!ok) {
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
return
}
res.json({ success: true })
} catch (err) {
console.error('[map-base] 삭제 오류:', err)
res.status(500).json({ error: '지도 삭제 중 오류가 발생했습니다.' })
}
})
export default router

파일 보기

@ -1,140 +0,0 @@
import { wingPool } from '../db/wingDb.js'
export interface MapBaseItem {
mapSn: number;
mapKey: string;
mapNm: string;
mapLevelCd: string | null;
mapSrc: string | null;
mapDc: string | null;
useYn: string;
regId: string | null;
regNm: string | null;
regDtm: string | null;
}
export interface MapTypeItem {
mapKey: string;
mapNm: string;
mapLevelCd: string | null;
}
function rowToItem(r: Record<string, unknown>): MapBaseItem {
return {
mapSn: r.map_sn as number,
mapKey: r.map_key as string,
mapNm: r.map_nm as string,
mapLevelCd: (r.map_level_cd as string) ?? null,
mapSrc: (r.map_src as string) ?? null,
mapDc: (r.map_dc as string) ?? null,
useYn: r.use_yn as string,
regId: (r.reg_id as string) ?? null,
regNm: (r.reg_nm as string) ?? null,
regDtm: r.reg_dtm ? new Date(r.reg_dtm as string).toISOString().slice(0, 10) : null,
}
}
export async function listMapBase(
page = 1,
limit = 20
): Promise<{ rows: MapBaseItem[]; total: number }> {
const offset = (page - 1) * limit
const countResult = await wingPool.query(`SELECT COUNT(*) AS cnt FROM wing.MAP_BASE_DATA WHERE DEL_YN = 'N'`)
const total = parseInt(countResult.rows[0].cnt as string, 10)
const { rows } = await wingPool.query(
`SELECT MAP_SN, MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC,
USE_YN, REG_ID, REG_NM, REG_DTM
FROM wing.MAP_BASE_DATA
WHERE DEL_YN = 'N'
ORDER BY MAP_SN
LIMIT $1 OFFSET $2`,
[limit, offset]
)
return { rows: rows.map(rowToItem), total }
}
export async function getActiveMapTypes(): Promise<MapTypeItem[]> {
const { rows } = await wingPool.query(
`SELECT MAP_KEY, MAP_NM, MAP_LEVEL_CD
FROM wing.MAP_BASE_DATA
WHERE USE_YN = 'Y' AND DEL_YN = 'N'
ORDER BY MAP_SN`
)
return rows.map((r: Record<string, unknown>) => ({
mapKey: r.map_key as string,
mapNm: r.map_nm as string,
mapLevelCd: (r.map_level_cd as string) ?? null,
}))
}
export async function createMapBase(data: {
mapKey: string;
mapNm: string;
mapLevelCd?: string;
mapSrc?: string;
mapDc?: string;
useYn?: string;
regId?: string;
regNm?: string;
}): Promise<MapBaseItem> {
const { rows } = await wingPool.query(
`INSERT INTO wing.MAP_BASE_DATA
(MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC, USE_YN, REG_ID, REG_NM)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.mapKey,
data.mapNm,
data.mapLevelCd ?? null,
data.mapSrc ?? null,
data.mapDc ?? null,
data.useYn ?? 'Y',
data.regId ?? null,
data.regNm ?? null,
]
)
return rowToItem(rows[0])
}
export async function updateMapBase(
mapSn: number,
data: {
mapKey?: string;
mapNm?: string;
mapLevelCd?: string | null;
mapSrc?: string | null;
mapDc?: string | null;
useYn?: string;
}
): Promise<MapBaseItem | null> {
const fields: string[] = []
const params: unknown[] = []
let idx = 1
if (data.mapKey !== undefined) { fields.push(`MAP_KEY = $${idx++}`); params.push(data.mapKey) }
if (data.mapNm !== undefined) { fields.push(`MAP_NM = $${idx++}`); params.push(data.mapNm) }
if (data.mapLevelCd !== undefined) { fields.push(`MAP_LEVEL_CD = $${idx++}`); params.push(data.mapLevelCd) }
if (data.mapSrc !== undefined) { fields.push(`MAP_SRC = $${idx++}`); params.push(data.mapSrc) }
if (data.mapDc !== undefined) { fields.push(`MAP_DC = $${idx++}`); params.push(data.mapDc) }
if (data.useYn !== undefined) { fields.push(`USE_YN = $${idx++}`); params.push(data.useYn) }
if (fields.length === 0) return null
fields.push(`MDFCN_DTM = NOW()`)
params.push(mapSn)
const { rows } = await wingPool.query(
`UPDATE wing.MAP_BASE_DATA SET ${fields.join(', ')} WHERE MAP_SN = $${idx} RETURNING *`,
params
)
if (rows.length === 0) return null
return rowToItem(rows[0])
}
export async function deleteMapBase(mapSn: number): Promise<boolean> {
const { rowCount } = await wingPool.query(
`UPDATE wing.MAP_BASE_DATA SET DEL_YN = 'Y', MDFCN_DTM = NOW() WHERE MAP_SN = $1`,
[mapSn]
)
return (rowCount ?? 0) > 0
}

파일 보기

@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction):
}
/**
* JSON ( 대응: 5mb)
* JSON ( 100kb)
*/
export const BODY_SIZE_LIMIT = '5mb'
export const BODY_SIZE_LIMIT = '100kb'
/**
*

파일 보기

@ -1,20 +0,0 @@
import { Router } from 'express'
import { requireAuth } from '../auth/authMiddleware.js'
import { getNumericalDataStatus } from './monitorService.js'
const router = Router()
router.use(requireAuth)
// GET /api/monitor/numerical — 수치예측자료 다운로드 상태 조회
router.get('/numerical', async (_req, res) => {
try {
const data = await getNumericalDataStatus()
res.json(data)
} catch (err) {
console.error('[monitor] 수치예측자료 상태 조회 오류:', err)
res.status(500).json({ error: '수치예측자료 상태를 조회할 수 없습니다.' })
}
})
export default router

파일 보기

@ -1,121 +0,0 @@
export interface NumericalDataStatus {
modelName: string;
jobName: string;
lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN';
lastDataDate: string | null; // 데이터 기준일 (YYYY-MM-DD)
lastDownloadedAt: string | null; // 마지막 실행 완료 시각 (ISO)
nextScheduledAt: string | null; // Quartz 다음 예정 시각 (ISO)
durationSec: number | null; // 소요 시간 (초)
consecutiveFailures: number; // 연속 실패 횟수
}
// ============================================================
// Mock 데이터 (Spring Batch/Quartz DB 연동 전)
// DB 연동 준비 완료 후 getMockNumericalDataStatus → getActualNumericalDataStatus 교체
// ============================================================
const MOCK_DATA: NumericalDataStatus[] = [
{
modelName: 'HYCOM',
jobName: 'downloadHycomJob',
lastStatus: 'COMPLETED',
lastDataDate: '2026-03-25',
lastDownloadedAt: '2026-03-25T06:12:34',
nextScheduledAt: '2026-03-25T12:00:00',
durationSec: 342,
consecutiveFailures: 0,
},
{
modelName: 'GFS',
jobName: 'downloadGfsJob',
lastStatus: 'COMPLETED',
lastDataDate: '2026-03-25',
lastDownloadedAt: '2026-03-25T06:48:11',
nextScheduledAt: '2026-03-25T12:00:00',
durationSec: 518,
consecutiveFailures: 0,
},
{
modelName: 'WW3',
jobName: 'downloadWw3Job',
lastStatus: 'FAILED',
lastDataDate: '2026-03-24',
lastDownloadedAt: '2026-03-25T07:03:55',
nextScheduledAt: '2026-03-25T13:00:00',
durationSec: null,
consecutiveFailures: 2,
},
{
modelName: 'KOAST POS_WIND',
jobName: 'downloadKoastWindJob',
lastStatus: 'COMPLETED',
lastDataDate: '2026-03-25',
lastDownloadedAt: '2026-03-25T07:21:05',
nextScheduledAt: '2026-03-25T13:00:00',
durationSec: 127,
consecutiveFailures: 0,
},
{
modelName: 'KOAST POS_HYDR',
jobName: 'downloadKoastHydrJob',
lastStatus: 'COMPLETED',
lastDataDate: '2026-03-25',
lastDownloadedAt: '2026-03-25T07:35:48',
nextScheduledAt: '2026-03-25T13:00:00',
durationSec: 183,
consecutiveFailures: 0,
},
{
modelName: 'KOAST POS_WAVE',
jobName: 'downloadKoastWaveJob',
lastStatus: 'COMPLETED',
lastDataDate: '2026-03-25',
lastDownloadedAt: '2026-03-25T07:52:19',
nextScheduledAt: '2026-03-25T13:00:00',
durationSec: 156,
consecutiveFailures: 0,
},
];
export async function getNumericalDataStatus(): Promise<NumericalDataStatus[]> {
// TODO: Spring Batch + Quartz DB 테이블 생성 후 아래 실제 쿼리로 교체
//
// import { wingDb } from '../db/wingDb.js'
//
// -- 각 Job의 최신 실행 결과 조회 (BATCH_JOB_EXECUTION)
// SELECT
// ji.JOB_NAME,
// je.START_TIME, je.END_TIME,
// je.STATUS, je.EXIT_CODE, je.EXIT_MESSAGE,
// jep.STRING_VAL AS data_date,
// EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))::INT AS duration_sec
// FROM BATCH_JOB_EXECUTION je
// JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
// LEFT JOIN BATCH_JOB_EXECUTION_PARAMS jep
// ON je.JOB_EXECUTION_ID = jep.JOB_EXECUTION_ID
// AND jep.KEY_NAME = 'data_date'
// WHERE je.JOB_EXECUTION_ID IN (
// SELECT MAX(je2.JOB_EXECUTION_ID)
// FROM BATCH_JOB_EXECUTION je2
// GROUP BY je2.JOB_INSTANCE_ID
// )
// ORDER BY je.START_TIME DESC;
//
// -- Quartz 다음 실행 예정 시각 (NEXT_FIRE_TIME은 epoch milliseconds)
// SELECT JOB_NAME, to_timestamp(NEXT_FIRE_TIME / 1000) AS next_fire_time
// FROM QRTZ_TRIGGERS;
//
// -- 연속 실패 횟수 집계 (최근 실행부터 COMPLETED 전까지 카운트)
// SELECT ji.JOB_NAME, COUNT(*) AS consecutive_failures
// FROM BATCH_JOB_EXECUTION je
// JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
// WHERE je.STATUS = 'FAILED'
// AND je.JOB_EXECUTION_ID > (
// SELECT COALESCE(MAX(je2.JOB_EXECUTION_ID), 0)
// FROM BATCH_JOB_EXECUTION je2
// WHERE je2.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID
// AND je2.STATUS = 'COMPLETED'
// )
// GROUP BY ji.JOB_NAME;
return MOCK_DATA;
}

파일 보기

@ -1,563 +0,0 @@
import { wingPool } from '../db/wingDb.js';
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> = {
'BUNKER_C': 'GENERIC BUNKER C',
'DIESEL': 'GENERIC DIESEL',
'CRUDE_OIL': 'WEST TEXAS INTERMEDIATE (WTI)',
'HEAVY_FUEL_OIL': 'GENERIC HEAVY FUEL OIL',
'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)',
'GASOLINE': 'GENERIC GASOLINE',
};
// AIS 선박유형 코드 → 위험도 점수 매핑
// AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선
const VESSEL_TYPE_SCORES: Array<[number, number, number]> = [
[80, 89, 1.0], // 유조선 계열
[70, 79, 0.5], // 화물선 계열
[30, 39, 0.3], // 어선
];
const RANK_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6',
'#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b', '#6366f1'];
interface PythonParticle { lat: number; lon: number }
interface PythonTimeStep {
particles: PythonParticle[];
center_lat?: number;
center_lon?: number;
remaining_volume_m3: number;
weathered_volume_m3: number;
pollution_area_km2: number;
beached_volume_m3: number;
pollution_coast_length_m: number;
}
// ============================================================
// 선박 항적 API 타입
// ============================================================
interface VesselTrackApiRequest {
startTime: string;
endTime: string;
mode: 'SEQUENTIAL';
polygons: Array<{
id: string;
name: string;
coordinates: [number, number][];
}>;
}
interface ChnPrmShipInfo {
imo: number;
name: string;
callsign: string;
vesselType: string;
lat: number;
lon: number;
sog: number;
cog: number;
heading: number;
length: number;
width: number;
draught: number;
destination: string;
status: string;
messageTimestamp: string;
}
interface VesselTrack {
vesselId: string;
nationalCode: string;
geometry: [number, number][]; // [lon, lat][]
speeds: number[]; // knots
totalDistance: number;
avgSpeed: number;
maxSpeed: number;
pointCount: number;
shipName: string;
shipType: string; // vessel type code string (e.g. "74")
shipKindCode: string;
chnPrmShipInfo: ChnPrmShipInfo | null;
timestamps: string[]; // Unix timestamp strings
}
interface HitDetail {
polygonId: string;
polygonName: string;
entryTimestamp: number;
exitTimestamp: number;
hitPointCount: number;
visitIndex: number;
}
interface VesselTrackApiResponse {
tracks: VesselTrack[];
hitDetails: Record<string, HitDetail[]>;
summary: {
totalVessels: number;
totalPoints: number;
mode: string;
polygonIds: string[];
processingTimeMs: number;
cachedDates: string[];
totalCachedVessels: number;
};
}
// haversine 거리 계산 (NM)
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 3440.065; // NM
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));
}
// anlysRange 파싱: '12', '±12시간', '12h' 등 → 숫자
function parseAnalysisHours(anlysRange: string): number {
const m = anlysRange.match(/(\d+)/);
return m ? parseInt(m[1], 10) : 12;
}
// 시간 포맷: Date → 'HH:MM' 형식 (KST)
function toTimeLabel(d: Date): string {
const kst = new Date(d.getTime() + 9 * 3600000);
return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`;
}
// 파티클 스텝별 탐색 영역 계산
function computeParticleSteps(
rawResult: PythonTimeStep[],
spillTime: Date,
anlysHours: number,
): Array<{ stepIdx: number; atTime: Date; centroid: { lat: number; lon: number }; radiusNm: number }> {
const totalSteps = rawResult.length;
const msPerStep = anlysHours * 3600000 / Math.max(totalSteps - 1, 1);
return rawResult.map((step, idx) => {
const atTime = new Date(spillTime.getTime() - idx * msPerStep);
const particles = step.particles.filter(p => p.lat != null && p.lon != null);
let centroid: { lat: number; lon: number };
let radiusNm: number;
if (particles.length === 0) {
centroid = { lat: step.center_lat ?? 0, lon: step.center_lon ?? 0 };
radiusNm = 5;
} else {
centroid = {
lat: particles.reduce((s, p) => s + p.lat, 0) / particles.length,
lon: particles.reduce((s, p) => s + p.lon, 0) / particles.length,
};
const maxDist = Math.max(...particles.map(p => haversineNm(centroid.lat, centroid.lon, p.lat, p.lon)));
radiusNm = Math.max(maxDist * 1.2, 2); // 최소 2 NM
}
return { stepIdx: idx, atTime, centroid, radiusNm };
});
}
// 선박유형 점수
function getVesselTypeScore(vesselTp: number | null): number {
if (vesselTp == null) return 0.3;
for (const [min, max, score] of VESSEL_TYPE_SCORES) {
if (vesselTp >= min && vesselTp <= max) return score;
}
return 0.2;
}
// 급감속 감지: 속도 배열에서 50% 이상 감소 여부 → 0~1
function detectSpeedDrop(speeds: number[]): number {
const valid = speeds.filter(s => s > 0);
if (valid.length < 2) return 0;
let maxDrop = 0;
for (let i = 1; i < valid.length; i++) {
const drop = (valid[i - 1] - valid[i]) / valid[i - 1];
if (drop > maxDrop) maxDrop = drop;
}
return maxDrop > 0.5 ? Math.min(1, maxDrop) : 0;
}
// AIS 단절 감지: 타임스탬프 배열에서 평균 간격의 3배 이상 gap 존재 여부
function detectAisGapFromTimestamps(timestamps: string[]): boolean {
if (timestamps.length < 3) return false;
const sorted = timestamps.map(Number).sort((a, b) => a - b);
const avgInterval = (sorted[sorted.length - 1] - sorted[0]) / (sorted.length - 1);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] - sorted[i - 1] > avgInterval * 3) return true;
}
return false;
}
function vesselTypeToLabel(tp: number | null): string {
if (tp == null) return '미분류';
if (tp >= 80 && tp <= 89) return '유조선';
if (tp >= 70 && tp <= 79) return '화물선';
if (tp >= 30 && tp <= 39) return '어선';
if (tp >= 60 && tp <= 69) return '여객선';
if (tp >= 90 && tp <= 99) return '특수선';
return `선박(${tp})`;
}
interface RankedVessel {
rank: number;
name: string;
imo: string;
type: string;
flag: string;
flagCountry: string;
probability: number;
closestTime: string;
closestDistance: number;
speedChange: string;
aisStatus: string;
description: string;
color: string;
mmsi: string;
_rawScore: number;
_track: VesselTrack;
_minDistIdx: number;
}
// 탐색 폴리곤 빌드 (사고위치 + 탐색반경 → 바운딩 박스)
function buildSearchPolygon(
lat: number,
lon: number,
radiusNm: number,
): { id: string; name: string; coordinates: [number, number][] } {
const latDelta = radiusNm / 60;
const lonDelta = radiusNm / (60 * Math.cos(lat * Math.PI / 180));
return {
id: 'zone_0',
name: '역추적 탐색구역',
coordinates: [
[lon - lonDelta, lat - latDelta],
[lon + lonDelta, lat - latDelta],
[lon + lonDelta, lat + latDelta],
[lon - lonDelta, lat + latDelta],
[lon - lonDelta, lat - latDelta],
],
};
}
// 선박 항적 API 호출
async function fetchVesselTracks(
spillLat: number,
spillLon: number,
srchRadiusNm: number,
startTime: Date,
endTime: Date,
): Promise<VesselTrackApiResponse> {
const polygon = buildSearchPolygon(spillLat, spillLon, srchRadiusNm);
const toApiDatetime = (d: Date) => d.toISOString().substring(0, 19);
const body: VesselTrackApiRequest = {
startTime: toApiDatetime(startTime),
endTime: toApiDatetime(endTime),
mode: 'SEQUENTIAL',
polygons: [polygon],
};
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',
...(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}`);
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 응답 기반)
function scoreAndRankVesselsFromApi(
tracks: VesselTrack[],
hitDetails: Record<string, HitDetail[]>,
spillLat: number,
spillLon: number,
srchRadiusNm: number,
anlysHours: number,
): RankedVessel[] {
const anlysWindowSec = anlysHours * 3600;
const scored = tracks.map(track => {
// 1. spatialScore (40%): 사고 지점과의 최근접 거리
let minDist = Infinity;
let minDistIdx = 0;
track.geometry.forEach(([lon, lat], idx) => {
const d = haversineNm(lat, lon, spillLat, spillLon);
if (d < minDist) { minDist = d; minDistIdx = idx; }
});
const spatialScore = Math.max(0, 1 - minDist / srchRadiusNm);
// 2. temporalScore (25%): 탐색구역 체류시간 / 분석 윈도우
const hits = hitDetails[track.vesselId] ?? [];
const totalTimeInZoneSec = hits.reduce((sum, h) => sum + (h.exitTimestamp - h.entryTimestamp), 0);
const temporalScore = Math.min(1, totalTimeInZoneSec / anlysWindowSec);
// 3. behaviorScore (20%): 급감속 + AIS 단절
const speedDrop = detectSpeedDrop(track.speeds);
const aisGap = detectAisGapFromTimestamps(track.timestamps);
const behaviorScore = Math.min(1, speedDrop * 0.6 + (aisGap ? 0.4 : 0));
// 4. vesselTypeScore (15%): 선박 유형별 위험도
const vesselTpRaw = parseInt(track.shipType ?? '', 10);
const vesselTp = isNaN(vesselTpRaw) ? null : vesselTpRaw;
const vesselTypeScore = getVesselTypeScore(vesselTp);
const rawScore = 0.40 * spatialScore + 0.25 * temporalScore + 0.20 * behaviorScore + 0.15 * vesselTypeScore;
const speedChangeLabel = speedDrop > 0.5 ? '급감속' : speedDrop > 0.2 ? '감속' : '정상';
const aisStatusLabel = aisGap ? 'AIS단절' : '정상';
const closestTs = track.timestamps[minDistIdx];
const closestDate = closestTs ? new Date(Number(closestTs) * 1000) : new Date();
const descParts: string[] = [];
if (speedDrop > 0.5) descParts.push(`${toTimeLabel(closestDate)} 급감속 감지`);
if (aisGap) descParts.push('AIS 신호 단절 구간 존재');
if (minDist < 1) descParts.push(`최근접 ${minDist.toFixed(2)} NM 이내 통과`);
const imo = track.chnPrmShipInfo?.imo?.toString() ?? '';
const vesselName = track.shipName || track.chnPrmShipInfo?.name || `MMSI:${track.vesselId}`;
return {
mmsi: track.vesselId,
imo,
name: vesselName,
type: vesselTypeToLabel(vesselTp),
flag: track.nationalCode ?? '',
flagCountry: track.nationalCode ?? '',
closestTime: toTimeLabel(closestDate),
closestDistance: Math.round(minDist * 100) / 100,
speedChange: speedChangeLabel,
aisStatus: aisStatusLabel,
description: descParts.join(' · '),
probability: 0,
rank: 0,
color: '',
_rawScore: rawScore,
_track: track,
_minDistIdx: minDistIdx,
};
});
scored.sort((a, b) => b._rawScore - a._rawScore);
const top = scored.slice(0, 10);
const maxScore = top[0]?._rawScore ?? 1;
return top.map((v, i) => ({
...v,
rank: i + 1,
probability: Math.round((v._rawScore / maxScore) * 95 * 10) / 10,
color: RANK_COLORS[i] ?? '#888888',
}));
}
interface ReplayShip {
vesselName: string;
color: string;
path: Array<{ lat: number; lon: number }>;
speedLabels: string[];
}
// 리플레이용 선박 경로 빌드 (상위 5척, API geometry 직접 사용)
function buildReplayShipsFromApi(ranked: RankedVessel[]): ReplayShip[] {
return ranked.slice(0, 5).map(v => ({
vesselName: v.name,
color: v.color,
path: v._track.geometry.map(([lon, lat]) => ({ lat, lon })),
speedLabels: v._track.speeds.map(s => `${s.toFixed(1)} kts`),
}));
}
interface CollisionEvent {
position: { lat: number; lon: number };
timeLabel: string;
progressPercent: number;
}
// 최고 확률 선박의 최근접 지점 이벤트
function findCollisionEventFromApi(
ranked: RankedVessel[],
startTime: Date,
endTime: Date,
): CollisionEvent | null {
const top = ranked[0];
if (!top) return null;
const idx = top._minDistIdx;
const ts = top._track.timestamps[idx];
const pointDate = ts ? new Date(Number(ts) * 1000) : new Date();
const totalMs = endTime.getTime() - startTime.getTime();
const pointMs = pointDate.getTime() - startTime.getTime();
const progressPercent = totalMs > 0
? Math.max(0, Math.min(100, Math.round((pointMs / totalMs) * 100)))
: 0;
const point = top._track.geometry[idx];
const [lon, lat] = point ?? [0, 0];
return {
position: { lat, lon },
timeLabel: toTimeLabel(pointDate) + ' 최근접',
progressPercent,
};
}
// ============================================================
// 메인 분석 함수 (외부에서 호출)
// ============================================================
export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
await wingPool.query(
`UPDATE wing.BACKTRACK SET EXEC_STTS_CD='RUNNING' WHERE BACKTRACK_SN=$1`,
[backtrackSn],
);
try {
const bt = await getBacktrack(backtrackSn);
if (!bt || bt.lat == null || bt.lon == null || !bt.estSpilDtm) {
throw new Error('역추적 레코드 정보 불충분 (lat/lon/estSpilDtm 필요)');
}
const anlysHours = parseAnalysisHours(bt.anlysRange ?? '12');
const spillTime = new Date(bt.estSpilDtm);
const analysisStart = new Date(spillTime.getTime() - anlysHours * 3600000);
// SPIL_DATA에서 유출량 및 유종 조회
let matVol: number | null = null;
let matTy: string | undefined;
try {
const { rows: spillRows } = await wingPool.query(
`SELECT SPIL_QTY, OIL_TP_CD FROM wing.SPIL_DATA WHERE ACDNT_SN=$1 ORDER BY SPIL_DATA_SN ASC LIMIT 1`,
[bt.acdntSn],
);
if (spillRows.length > 0) {
const row = spillRows[0] as Record<string, unknown>;
matVol = row['spil_qty'] != null ? Number(row['spil_qty']) : null;
const oilTpCd = row['oil_tp_cd'] as string | null;
matTy = oilTpCd ? (OIL_TYPE_MAP[oilTpCd] ?? oilTpCd) : undefined;
}
} catch (spillErr) {
console.warn('[backtrack] SPIL_DATA 조회 실패, matVol 없이 진행:', spillErr);
}
// 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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat: bt.lat,
lon: bt.lon,
startTime: spillTime.toISOString(),
runTime: anlysHours,
matVol: matVol ?? 1,
matTy,
name: `BACKTRACK_${backtrackSn}`,
}),
signal: AbortSignal.timeout(30000),
});
if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`);
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);
rawResult = Array.from({ length: anlysHours + 1 }, () => ({
particles: [],
remaining_volume_m3: 0,
weathered_volume_m3: 0,
pollution_area_km2: 0,
beached_volume_m3: 0,
pollution_coast_length_m: 0,
}));
}
const steps = computeParticleSteps(rawResult, spillTime, anlysHours);
if (rawResult.every(s => s.particles.length === 0)) {
steps.forEach((s, i) => {
s.centroid = { lat: bt.lat!, lon: bt.lon! };
s.radiusNm = (bt.srchRadiusNm ?? 10) + i * 2;
});
}
// 선박 항적 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);
const timeRange = {
start: analysisStart.toISOString(),
end: spillTime.toISOString(),
};
// vessels에서 내부 필드 제거
const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v);
// 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
SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, TOTAL_VESSELS=$2
WHERE BACKTRACK_SN=$3`,
[JSON.stringify(rsltData), totalVessels, backtrackSn],
);
console.info(`[backtrack] 분석 완료 SN=${backtrackSn}, 후보선박=${ranked.length}/${totalVessels}`);
} catch (err) {
console.error('[backtrack] 분석 실패:', err);
const errMsg = err instanceof Error ? err.message : '알 수 없는 오류';
await wingPool.query(
`UPDATE wing.BACKTRACK SET EXEC_STTS_CD='FAILED', RSLT_DATA=$1 WHERE BACKTRACK_SN=$2`,
[JSON.stringify({ error: errMsg }), backtrackSn],
);
}
}

파일 보기

@ -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, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
export async function analyzeImageFile(imageBuffer: Buffer, originalName: 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 = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntNm = `이미지분석_${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', 6, $4, NOW())`,
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
[
acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',

파일 보기

@ -3,9 +3,6 @@ import multer from 'multer';
import {
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
getOilSpillSummary,
} from './predictionService.js';
import { analyzeImageFile } from './imageAnalyzeService.js';
import { isValidNumber } from '../middleware/security.js';
@ -18,11 +15,8 @@ const router = express.Router();
// GET /api/prediction/analyses — 분석 목록
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
const { search, acdntSn } = req.query;
const items = await listAnalyses({
search: search as string | undefined,
acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined,
});
const { search } = req.query;
const items = await listAnalyses({ search: search as string | undefined });
res.json(items);
} catch (err) {
console.error('[prediction] 분석 목록 오류:', err);
@ -50,7 +44,7 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R
}
});
// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능)
// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
const acdntSn = parseInt(req.params.acdntSn as string, 10);
@ -58,8 +52,7 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
res.status(400).json({ error: '유효하지 않은 사고 번호' });
return;
}
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
const result = await getAnalysisTrajectory(acdntSn, predRunSn);
const result = await getAnalysisTrajectory(acdntSn);
if (!result) {
res.json({ trajectory: null, summary: null });
return;
@ -71,91 +64,6 @@ 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 {
const acdntSn = parseInt(req.params.acdntSn as string, 10);
if (!isValidNumber(acdntSn, 1, 999999)) {
res.status(400).json({ error: '유효하지 않은 사고 번호' });
return;
}
const result = await getSensitiveResourcesByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 민감자원 조회 오류:', err);
res.status(500).json({ error: '민감자원 조회 실패' });
}
});
// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON
router.get('/analyses/:acdntSn/sensitive-resources/geojson', 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 result = await getSensitiveResourcesGeoJsonByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 민감자원 GeoJSON 조회 오류:', err);
res.status(500).json({ error: '민감자원 GeoJSON 조회 실패' });
}
});
// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
router.get('/analyses/:acdntSn/spread-particles', 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 result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
}
});
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
router.get('/analyses/:acdntSn/sensitivity-evaluation', 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 result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
}
});
// GET /api/prediction/backtrack — 사고별 역추적 목록
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
@ -252,8 +160,7 @@ router.post(
res.status(400).json({ error: '이미지 파일이 필요합니다' });
return;
}
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
res.json(result);
} catch (err: unknown) {
if (err instanceof Error) {

파일 보기

@ -1,15 +1,4 @@
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;
@ -30,8 +19,6 @@ interface PredictionAnalysis {
analyst: string;
officeName: string;
acdntSttsCd: string;
predRunSn: number | null;
runDtm: string | null;
}
interface PredictionDetail {
@ -126,18 +113,12 @@ interface BoomLineItem {
interface ListAnalysesInput {
search?: string;
acdntSn?: number;
}
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
const params: unknown[] = [];
const conditions: string[] = ["A.USE_YN = 'Y'"];
if (input.acdntSn) {
params.push(input.acdntSn);
conditions.push(`A.ACDNT_SN = $${params.length}`);
}
if (input.search) {
params.push(`%${input.search}%`);
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
@ -161,31 +142,21 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
S.SPIL_QTY,
S.SPIL_UNIT_CD,
S.FCST_HR,
P.PRED_RUN_SN,
P.RUN_DTM,
P.KOSPS_STATUS,
P.POSEIDON_STATUS,
P.OPENDRIFT_STATUS,
B.BACKTRACK_STATUS,
COALESCE(U.USER_NM, A.ANALYST_NM) AS RESOLVED_ANALYST,
COALESCE(O.ORG_NM, A.OFFICE_NM) AS RESOLVED_OFFICE
B.BACKTRACK_STATUS
FROM ACDNT A
INNER JOIN (
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
LEFT JOIN (
SELECT
ACDNT_SN,
PRED_RUN_SN,
MIN(BGNG_DTM) AS RUN_DTM,
MIN(SPIL_DATA_SN) AS SPIL_DATA_SN,
MIN(EXEC_USER_ID::TEXT)::UUID AS EXEC_USER_ID,
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
FROM PRED_EXEC
GROUP BY ACDNT_SN, PRED_RUN_SN
GROUP BY ACDNT_SN
) P ON P.ACDNT_SN = A.ACDNT_SN
LEFT JOIN SPIL_DATA S ON S.SPIL_DATA_SN = P.SPIL_DATA_SN
LEFT JOIN AUTH_USER U ON U.USER_ID = P.EXEC_USER_ID
LEFT JOIN AUTH_ORG O ON O.ORG_SN = U.ORG_SN
LEFT JOIN (
SELECT
ACDNT_SN,
@ -194,7 +165,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
GROUP BY ACDNT_SN
) B ON B.ACDNT_SN = A.ACDNT_SN
${whereClause}
ORDER BY P.RUN_DTM DESC NULLS LAST, A.OCCRN_DTM DESC
ORDER BY A.OCCRN_DTM DESC
`;
const { rows } = await wingPool.query(sql, params);
@ -215,11 +186,9 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['resolved_analyst'] ?? ''),
officeName: String(row['resolved_office'] ?? ''),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,
}));
}
@ -384,7 +353,7 @@ function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
return {
backtrackSn: Number(r['backtrack_sn']),
acdntSn: Number(r['acdnt_sn']),
estSpilDtm: r['est_spil_dtm'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : null,
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
@ -398,15 +367,15 @@ function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
export async function createBacktrack(
input: CreateBacktrackInput,
): Promise<BacktrackResult> {
): Promise<{ backtrackSn: number }> {
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
const sql = `
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
VALUES (
$1, $2::double precision, $3::double precision,
ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326),
$3::text || ' + ' || $2::text,
$1, $2, $3,
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
$3 || ' + ' || $2,
$4, $5, $6, 'PENDING'
)
RETURNING BACKTRACK_SN
@ -416,14 +385,8 @@ export async function createBacktrack(
acdntSn, lat, lon,
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
]);
const backtrackSn = Number((rows[0] as Record<string, unknown>)['backtrack_sn']);
// 동기 분석 (완료까지 대기 후 결과 반환)
await runBacktrackAnalysis(backtrackSn);
const result = await getBacktrack(backtrackSn);
if (!result) throw new Error('역추적 결과를 찾을 수 없습니다');
return result;
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
}
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
@ -469,8 +432,6 @@ interface TrajectoryTimeStep {
particles: TrajectoryParticle[];
remaining_volume_m3: number;
weathered_volume_m3: number;
evaporation_volume_m3?: number;
dispersion_volume_m3?: number;
pollution_area_km2: number;
beached_volume_m3: number;
pollution_coast_length_m: number;
@ -481,56 +442,21 @@ interface TrajectoryTimeStep {
hydr_grid?: TrajectoryHydrGrid;
}
// ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
};
interface SingleModelTrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
stepSummaries: Array<{
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
}>;
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
centerPoints: Array<{ lat: number; lon: number; time: number }>;
windData: TrajectoryWindPoint[][];
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
}
interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
}
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult {
const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({
lat: p.lat,
@ -538,15 +464,12 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
time: stepIdx,
particle: i,
stranded: p.stranded,
model,
}))
);
const lastStep = rawResult[rawResult.length - 1];
const summary = {
remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3,
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -554,249 +477,28 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
const centerPoints = rawResult
.map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
: null
)
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_volume_m3,
weatheredVolume: step.weathered_volume_m3,
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
pollutionArea: step.pollution_area_km2,
beachedVolume: step.beached_volume_m3,
pollutionCoastLength: step.pollution_coast_length_m,
}));
.filter((p): p is { lat: number; lon: number; time: number } => p !== null);
const windData = rawResult.map((step) => step.wind_data ?? []);
const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid
? { value: step.hydr_data, grid: step.hydr_grid }
: null
);
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData };
return { trajectory, summary, centerPoints, windData, hydrData };
}
export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise<TrajectoryResult | null> {
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
// predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과
const sql = predRunSn != null
? `
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND PRED_RUN_SN = $2
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC
`
: `
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC
`;
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
const { rows } = await wingPool.query(sql, params);
if (rows.length === 0) return null;
// 모든 모델의 파티클을 하나의 배열로 병합
let mergedTrajectory: TrajectoryResult['trajectory'] = [];
let allCenterPoints: TrajectoryResult['centerPoints'] = [];
// summary: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준
let baseResult: SingleModelTrajectoryResult | null = null;
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
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 baseRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
if (!row['rslt_data']) continue;
const algoCd = String(row['algo_cd'] ?? '');
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName);
mergedTrajectory = mergedTrajectory.concat(parsed.trajectory);
allCenterPoints = allCenterPoints.concat(parsed.centerPoints);
windDataByModel[modelName] = parsed.windData;
hydrDataByModel[modelName] = parsed.hydrData;
summaryByModel[modelName] = parsed.summary;
stepSummariesByModel[modelName] = parsed.stepSummaries;
if (row === baseRow) {
baseResult = parsed;
}
}
if (!baseResult) return null;
return {
trajectory: mergedTrajectory,
summary: baseResult.summary,
centerPoints: allCenterPoints,
windDataByModel,
hydrDataByModel,
summaryByModel,
stepSummariesByModel,
};
}
export async function getSensitiveResourcesByAcdntSn(
acdntSn: number,
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
const sql = `
WITH all_wkts AS (
SELECT step_data ->> 'wkt' AS wkt
FROM wing.PRED_EXEC,
jsonb_array_elements(RSLT_DATA) AS step_data
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
),
union_geom AS (
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
FROM all_wkts
WHERE wkt IS NOT NULL AND wkt <> ''
)
SELECT sr.CATEGORY,
COUNT(*)::int AS count,
CASE
WHEN bool_and(sr.PROPERTIES ? 'area')
THEN SUM((sr.PROPERTIES->>'area')::float)
ELSE NULL
END AS total_area
FROM wing.SENSITIVE_RESOURCE sr, union_geom
WHERE union_geom.geom IS NOT NULL
AND ST_Intersects(sr.GEOM, union_geom.geom)
GROUP BY sr.CATEGORY
ORDER BY sr.CATEGORY
SELECT RSLT_DATA FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
return rows.map((r: Record<string, unknown>) => ({
category: String(r['category'] ?? ''),
count: Number(r['count'] ?? 0),
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
}));
}
export async function getSensitiveResourcesGeoJsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
const sql = `
WITH all_wkts AS (
SELECT step_data ->> 'wkt' AS wkt
FROM wing.PRED_EXEC,
jsonb_array_elements(RSLT_DATA) AS step_data
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
),
union_geom AS (
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
FROM all_wkts
WHERE wkt IS NOT NULL AND wkt <> ''
)
SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES,
ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json
FROM wing.SENSITIVE_RESOURCE sr, union_geom
WHERE union_geom.geom IS NOT NULL
AND ST_Intersects(sr.GEOM, union_geom.geom)
ORDER BY sr.CATEGORY, sr.SR_ID
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
const features = rows.map((r: Record<string, unknown>) => ({
type: 'Feature',
geometry: r['geom_json'],
properties: {
srId: Number(r['sr_id']),
category: String(r['category'] ?? ''),
...(r['properties'] as Record<string, unknown> ?? {}),
},
}));
return { type: 'FeatureCollection', features };
}
export async function getSensitivityEvaluationGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
const lat = Number(acdntRows[0]['lat']);
const lng = Number(acdntRows[0]['lng']);
const sql = `
SELECT SR_ID, PROPERTIES,
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
FROM wing.SENSITIVE_EVALUATION
WHERE ST_DWithin(
GEOM::geography,
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
10000
)
ORDER BY SR_ID
`;
const { rows } = await wingPool.query(sql, [lat, lng]);
const features = rows.map((r: Record<string, unknown>) => ({
type: 'Feature',
geometry: r['geom_json'],
properties: {
srId: Number(r['sr_id']),
area_km2: Number(r['area_km2']),
...(r['properties'] as Record<string, unknown> ?? {}),
},
}));
return { type: 'FeatureCollection', features };
}
export async function getPredictionParticlesGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
const sql = `
SELECT ALGO_CD, RSLT_DATA
FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
const features: unknown[] = [];
let globalMaxStep = 0;
for (const row of rows) {
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
const steps = row['rslt_data'] as TrajectoryTimeStep[];
const maxStep = steps.length - 1;
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
steps.forEach((step, stepIdx) => {
step.particles.forEach(p => {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
model,
time: stepIdx,
stranded: p.stranded ?? 0,
isLastStep: stepIdx === maxStep,
},
});
});
});
}
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
if (rows.length === 0 || !rows[0].rslt_data) return null;
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
}
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
@ -822,116 +524,3 @@ 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,
};
}

파일 보기

@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
// ============================================================
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
try {
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
const result = await createReport({
tmplSn,
ctgrSn,
@ -101,8 +101,6 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
jrsdCd,
sttsCd,
authorId: req.user!.sub,
step3MapImg,
step6MapImg,
sections,
});
res.status(201).json(result);
@ -126,8 +124,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {

파일 보기

@ -60,10 +60,8 @@ interface ReportListItem {
sttsCd: string;
authorId: string;
authorName: string;
acdntSn: number | null;
regDtm: string;
mdfcnDtm: string | null;
hasMapCapture: boolean;
}
interface SectionData {
@ -76,8 +74,6 @@ interface SectionData {
interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
step3MapImg: string | null;
step6MapImg: string | null;
}
interface ListReportsInput {
@ -104,8 +100,6 @@ interface CreateReportInput {
jrsdCd?: string;
sttsCd?: string;
authorId: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -114,8 +108,6 @@ interface UpdateReportInput {
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -264,10 +256,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
THEN true ELSE false END AS HAS_MAP_CAPTURE
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -290,10 +279,8 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
sttsCd: r.stts_cd,
authorId: r.author_id,
authorName: r.author_name || '',
acdntSn: r.acdnt_sn,
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
hasMapCapture: r.has_map_capture,
})),
totalCount,
page,
@ -307,10 +294,7 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
THEN true ELSE false END AS HAS_MAP_CAPTURE
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -347,9 +331,6 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
step3MapImg: r.step3_map_img,
step6MapImg: r.step6_map_img,
hasMapCapture: r.has_map_capture,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
includeYn: s.include_yn,
@ -369,8 +350,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
@ -380,8 +361,6 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
input.jrsdCd || null,
input.sttsCd || 'DRAFT',
input.authorId,
input.step3MapImg || null,
input.step6MapImg || null,
]
);
const reportSn = res.rows[0].report_sn;
@ -453,14 +432,6 @@ export async function updateReport(
sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
if (input.step3MapImg !== undefined) {
sets.push(`STEP3_MAP_IMG = $${idx++}`);
params.push(input.step3MapImg);
}
if (input.step6MapImg !== undefined) {
sets.push(`STEP6_MAP_IMG = $${idx++}`);
params.push(input.step6MapImg);
}
params.push(reportSn);
await client.query(

파일 보기

@ -10,13 +10,11 @@ const router = express.Router();
// ============================================================
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
try {
const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
const { sttsCd, acdntTpCd, search } = req.query;
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,7 +59,6 @@ interface ListOpsInput {
sttsCd?: string;
acdntTpCd?: string;
search?: string;
acdntSn?: number;
}
// ============================================================
@ -83,10 +82,6 @@ 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 ');

파일 보기

@ -7,7 +7,6 @@ import {
isValidNumber,
isValidStringLength,
} from '../middleware/security.js'
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
const router = express.Router()
@ -18,7 +17,6 @@ interface Layer {
cmn_cd_nm: string
cmn_cd_level: number
clnm: string | null
data_tbl_nm: string | null
}
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
@ -28,21 +26,7 @@ const LAYER_COLUMNS = `
LAYER_FULL_NM AS cmn_cd_full_nm,
LAYER_NM AS cmn_cd_nm,
LAYER_LEVEL AS cmn_cd_level,
WMS_LAYER_NM AS clnm,
DATA_TBL_NM AS data_tbl_nm
`.trim()
// 조상 중 하나라도 USE_YN='N'이면 제외하는 재귀 CTE
// 부모가 비활성화되면 자식도 공개 API에서 제외됨 (상속 방식)
const ACTIVE_TREE_CTE = `
WITH RECURSIVE active_tree AS (
SELECT LAYER_CD FROM LAYER
WHERE UP_LAYER_CD IS NULL AND USE_YN = 'Y' AND DEL_YN = 'N'
UNION ALL
SELECT l.LAYER_CD FROM LAYER l
JOIN active_tree a ON l.UP_LAYER_CD = a.LAYER_CD
WHERE l.USE_YN = 'Y' AND l.DEL_YN = 'N'
)
WMS_LAYER_NM AS clnm
`.trim()
// 모든 라우트에 파라미터 살균 적용
@ -52,10 +36,7 @@ router.use(sanitizeParams)
router.get('/', async (_req, res) => {
try {
const { rows } = await wingPool.query<Layer>(
`${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
ORDER BY LAYER_CD`
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
@ -68,10 +49,7 @@ router.get('/', async (_req, res) => {
router.get('/tree/all', async (_req, res) => {
try {
const { rows } = await wingPool.query<Layer>(
`${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
ORDER BY LAYER_CD`
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
@ -103,10 +81,7 @@ router.get('/tree/all', async (_req, res) => {
router.get('/wms/all', async (_req, res) => {
try {
const { rows } = await wingPool.query<Layer>(
`${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL
ORDER BY LAYER_CD`
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD`
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
@ -128,10 +103,7 @@ router.get('/level/:level', async (req, res) => {
}
const { rows } = await wingPool.query<Layer>(
`${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1
ORDER BY LAYER_CD`,
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
[level]
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
@ -155,7 +127,7 @@ router.get('/children/:parentId', async (req, res) => {
const sanitizedId = sanitizeString(parentId)
const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
[sanitizedId]
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
@ -179,7 +151,7 @@ router.get('/:id', async (req, res) => {
const sanitizedId = sanitizeString(id)
const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`,
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
[sanitizedId]
)
if (rows.length === 0) {
@ -192,352 +164,4 @@ router.get('/:id', async (req, res) => {
}
})
// ── 관리자 전용 엔드포인트 ──────────────────────────────────────
// 전체 레이어 목록 (페이지네이션 + 검색/필터, USE_YN 무관)
router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1)
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '10'), 10) || 10))
const offset = (page - 1) * limit
const search = sanitizeString(String(req.query.search ?? '')).trim()
const useYnFilter = String(req.query.useYn ?? '')
// 동적 WHERE 절 구성 (DEL_YN = 'N' 기본 조건)
const conditions: string[] = ["DEL_YN = 'N'"]
const params: (string | number)[] = []
if (search) {
params.push(`%${search}%`)
const n = params.length
conditions.push(`(LAYER_CD ILIKE $${n} OR LAYER_NM ILIKE $${n} OR LAYER_FULL_NM ILIKE $${n})`)
}
if (useYnFilter === 'Y' || useYnFilter === 'N') {
params.push(useYnFilter)
conditions.push(`USE_YN = $${params.length}`)
}
const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim()
if (rootCd) {
if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) {
return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' })
}
params.push(`${rootCd}%`)
conditions.push(`LAYER_CD LIKE $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
const dataParams = [...params, limit, offset]
const limitIdx = dataParams.length - 1
const offsetIdx = dataParams.length
const [dataResult, countResult] = await Promise.all([
wingPool.query(
`SELECT
t.*,
p.USE_YN AS "parentUseYn"
FROM (
SELECT
LAYER_CD AS "layerCd",
UP_LAYER_CD AS "upLayerCd",
LAYER_FULL_NM AS "layerFullNm",
LAYER_NM AS "layerNm",
LAYER_LEVEL AS "layerLevel",
WMS_LAYER_NM AS "wmsLayerNm",
DATA_TBL_NM AS "dataTblNm",
USE_YN AS "useYn",
SORT_ORD AS "sortOrd",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
FROM LAYER
${whereClause}
ORDER BY LAYER_CD
LIMIT $${limitIdx} OFFSET $${offsetIdx}
) t
LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N'
ORDER BY t."layerCd"`,
dataParams
),
wingPool.query(
`SELECT COUNT(*)::int AS total FROM LAYER ${whereClause}`,
params
),
])
const total: number = countResult.rows[0].total
res.json({
items: dataResult.rows,
total,
page,
totalPages: Math.ceil(total / limit),
})
} catch {
res.status(500).json({ error: '레이어 목록 조회 실패' })
}
})
// 드롭다운용 레이어 옵션 목록
router.get('/admin/options', requireAuth, requireRole('ADMIN'), async (_req, res) => {
try {
const { rows } = await wingPool.query(
`SELECT LAYER_CD AS "layerCd", LAYER_NM AS "layerNm",
LAYER_FULL_NM AS "layerFullNm", LAYER_LEVEL AS "layerLevel"
FROM LAYER WHERE DEL_YN = 'N' ORDER BY LAYER_CD`
)
res.json(rows)
} catch {
res.status(500).json({ error: '레이어 옵션 조회 실패' })
}
})
// 상위코드 기반 다음 자식 코드 계산
router.get('/admin/next-code', requireAuth, requireRole('ADMIN'), async (req, res) => {
const upLayerCd = sanitizeString(String(req.query.upLayerCd ?? '')).trim()
if (!upLayerCd || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd) || !isValidStringLength(upLayerCd, 50)) {
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드' })
}
try {
const { rows } = await wingPool.query(
`SELECT LAYER_CD AS "layerCd" FROM LAYER
WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'
ORDER BY LAYER_CD DESC LIMIT 1`,
[upLayerCd]
)
let nextCode: string
if (rows.length === 0) {
nextCode = upLayerCd + '001'
} else {
const lastCd = rows[0].layerCd as string
const suffix = lastCd.substring(upLayerCd.length)
const num = parseInt(suffix, 10)
nextCode = isNaN(num)
? upLayerCd + '001'
: upLayerCd + String(num + 1).padStart(suffix.length, '0')
}
res.json({ nextCode })
} catch {
res.status(500).json({ error: '다음 레이어코드 계산 실패' })
}
})
// 레이어 생성
router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const body = req.body as {
layerCd?: string
upLayerCd?: string
layerFullNm?: string
layerNm?: string
layerLevel?: number
wmsLayerNm?: string
dataTblNm?: string
useYn?: string
sortOrd?: number
}
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
// 필수 필드 검증
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
}
if (!layerNm || !isValidStringLength(layerNm, 100)) {
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
}
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
}
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
}
// 선택 필드 검증
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
}
}
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
if (!isValidStringLength(wmsLayerNm, 100)) {
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
}
}
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
}
}
const sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query(
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
RETURNING LAYER_CD AS "layerCd"`,
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
)
res.json(rows[0])
} catch (err) {
const pgErr = err as { code?: string }
if (pgErr.code === '23505') {
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
}
res.status(500).json({ error: '레이어 생성 실패' })
}
})
// 레이어 수정
router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const body = req.body as {
layerCd?: string
upLayerCd?: string
layerFullNm?: string
layerNm?: string
layerLevel?: number
wmsLayerNm?: string
dataTblNm?: string
useYn?: string
sortOrd?: number
}
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
// 필수 필드 검증
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
}
if (!layerNm || !isValidStringLength(layerNm, 100)) {
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
}
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
}
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
}
// 선택 필드 검증
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
}
}
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
if (!isValidStringLength(wmsLayerNm, 100)) {
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
}
}
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
}
}
const sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query(
`UPDATE LAYER
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9
WHERE LAYER_CD = $1
RETURNING LAYER_CD AS "layerCd"`,
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
)
if (rows.length === 0) {
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
}
res.json(rows[0])
} catch (err) {
const pgErr = err as { code?: string }
if (pgErr.code === '23505') {
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
}
res.status(500).json({ error: '레이어 수정 실패' })
}
})
// 레이어 삭제
router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { layerCd } = req.body as { layerCd?: string }
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다.' })
}
const sanitizedCd = sanitizeString(layerCd)
// 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단)
const { rows: childRows } = await wingPool.query(
`SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`,
[sanitizedCd]
)
const childCount: number = childRows[0].cnt
if (childCount > 0) {
return res.status(400).json({
error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`,
})
}
const { rows } = await wingPool.query(
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
RETURNING LAYER_CD AS "layerCd"`,
[sanitizedCd]
)
if (rows.length === 0) {
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
}
res.json(rows[0])
} catch {
res.status(500).json({ error: '레이어 삭제 실패' })
}
})
// USE_YN 토글
router.post('/admin/toggle-use', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { layerCd } = req.body as { layerCd?: string }
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
return res.status(400).json({ error: '유효하지 않은 레이어코드' })
}
const sanitizedCd = sanitizeString(layerCd)
const { rows } = await wingPool.query(
`UPDATE LAYER
SET USE_YN = CASE WHEN USE_YN = 'Y' THEN 'N' ELSE 'Y' END
WHERE LAYER_CD = $1
RETURNING LAYER_CD AS "layerCd", USE_YN AS "useYn"`,
[sanitizedCd]
)
if (rows.length === 0) {
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
}
res.json(rows[0])
} catch {
res.status(500).json({ error: 'USE_YN 변경 실패' })
}
})
export default router

파일 보기

@ -11,7 +11,6 @@ import {
const router = Router()
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
const POSEIDON_API_URL = process.env.POSEIDON_API_URL ?? 'http://localhost:5004'
const POLL_INTERVAL_MS = 3000
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
@ -20,9 +19,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',
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
'중유': 'GENERIC HEAVY FUEL OIL',
'등유': 'FUEL OIL NO.1 (KEROSENE) ',
'등유': 'FUEL OIL NO.1 (KEROSENE)',
'휘발유': 'GENERIC GASOLINE',
}
@ -72,38 +71,20 @@ async function rollbackNewRecords(
}
}
// 모델명 → ALGO_CD 매핑
const MODEL_ALGO_CD_MAP: Record<string, string> = {
'OpenDrift': 'OPENDRIFT',
'POSEIDON': 'POSEIDON',
}
// 모델명 → API URL 매핑
const MODEL_API_URL_MAP: Record<string, string> = {
'OpenDrift': PYTHON_API_URL,
'POSEIDON': POSEIDON_API_URL,
}
// ============================================================
// POST /api/simulation/run
// 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON)
// 확산 시뮬레이션 실행 (OpenDrift)
// ============================================================
/**
* (OpenDrift, POSEIDON) .
* PRED_EXEC Python API에 .
* KOSPS PRED_EXEC INSERT(PENDING) API .
* execSns execSn으로 GET /status/:execSn을 .
* OpenDrift .
* Python FastAPI job_id를
* DB에 .
* execSn으로 GET /status/:execSn을 .
*/
router.post('/run', requireAuth, async (req: Request, res: Response) => {
try {
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
lat, lon, runTime, matTy, matVol, spillTime, startTime,
models: rawModels } = req.body
// 실행할 모델 목록 (기본값: OpenDrift)
const requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0
? (rawModels as string[])
: ['OpenDrift']
lat, lon, runTime, matTy, matVol, spillTime, startTime } = req.body
// 1. 필수 파라미터 검증
if (lat === undefined || lon === undefined || runTime === undefined) {
@ -136,8 +117,6 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
}
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
// OpenDrift 모델이 포함된 경우에만 check-nc 수행
if (requestedModels.includes('OpenDrift')) {
try {
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
method: 'POST',
@ -154,7 +133,6 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
} catch {
// Python 서버 미기동 — 5번에서 처리
}
}
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
@ -210,80 +188,41 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
if (resolvedAcdntSn && !resolvedSpilDataSn) {
try {
const spilRes = await wingPool.query(
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING SPIL_DATA_SN`,
[
resolvedAcdntSn,
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
matVol ?? 0,
UNIT_MAP[spillUnit as string] ?? 'KL',
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
runTime,
]
`SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`,
[resolvedAcdntSn]
)
if (spilRes.rows.length > 0) {
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
} catch (dbErr) {
console.error('[simulation] SPIL_DATA INSERT 실패:', dbErr)
}
} catch (dbErr) {
console.error('[simulation] SPIL_DATA 조회 실패:', dbErr)
}
}
// 4. PRED_EXEC INSERT (PENDING) — ACDNT_SN 포함 (NOT NULL FK)
const execNm = `EXPC_${Date.now()}`
let predExecSn: number
try {
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
VALUES ($1, $2, 'OPENDRIFT', 'PENDING', $3, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, execNm]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {
console.error('[simulation] PRED_EXEC INSERT 실패:', dbErr)
return res.status(500).json({ error: '분석 기록 생성 실패' })
}
// matTy 변환: 한국어 유종 → OpenDrift 유종 코드
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
// 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬)
// KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동)
const execNmBase = `EXPC_${Date.now()}`
const execSns: Array<{ model: string; execSn: number }> = []
// KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행
if (requestedModels.includes('KOSPS')) {
try {
const kospsExecNm = `${execNmBase}_KOSPS`
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, req.user!.sub]
)
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
} catch (dbErr) {
console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr)
}
}
// API 연동 모델 필터링 (KOSPS 제외)
const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
// 각 모델에 대해 PRED_EXEC INSERT → /run-model 호출
await Promise.all(
apiModels.map(async (model) => {
const algoCd = MODEL_ALGO_CD_MAP[model]
const apiUrl = MODEL_API_URL_MAP[model]
const execNm = `${execNmBase}_${algoCd}`
// PRED_EXEC INSERT (PENDING)
let predExecSn: number
try {
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, req.user!.sub]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {
console.error(`[simulation] ${model} PRED_EXEC INSERT 실패:`, dbErr)
return
}
execSns.push({ model, execSn: predExecSn })
// Python /run-model 호출
// 5. Python /run-model 호출
let jobId: string
try {
const pythonRes = await fetch(`${apiUrl}/run-model`, {
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -306,7 +245,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
[errData.error || '분석 서버 포화', predExecSn]
)
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
}
if (!pythonRes.ok) {
@ -320,39 +259,23 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
// 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행)
await rollbackNewRecords(predExecSn, null, null)
return
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
}
// RUNNING 업데이트
// 6. RUNNING 업데이트
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
// 백그라운드 폴링 시작
pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) =>
console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err)
)
})
)
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용)
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
// ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백
const hasRunning = execSns.some(({ model }) => model !== 'KOSPS')
if (!hasRunning && newlyCreatedAcdntSn !== null) {
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
}
// 즉시 응답 (하위 호환을 위해 execSn도 포함)
res.json({
success: true,
execSns,
execSn: execSns[0]?.execSn ?? 0,
acdntSn: resolvedAcdntSn,
status: 'RUNNING',
})
// 8. 백그라운드 폴링 시작
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
console.error('[simulation] pollAndSave 오류:', err)
)
} catch {
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
}
@ -374,7 +297,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
try {
const result = await wingPool.query(
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, pe.ALGO_CD, sd.FCST_HR,
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR,
(
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
FROM wing.PRED_EXEC hist
@ -405,9 +328,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
const status = statusMap[dbStatus] ?? dbStatus
if (status === 'DONE' && row.rslt_data) {
const algoCd = String(row.algo_cd ?? '')
const modelName = ALGO_CD_TO_MODEL_NAME[algoCd] ?? algoCd
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[], modelName)
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[])
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
}
@ -432,331 +353,17 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
}
})
// ============================================================
// POST /api/simulation/run-model (동기 방식)
// 예측 완료 후 결과를 직접 반환한다.
// ============================================================
/**
* .
* , .
*/
router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
try {
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
lat, lon, runTime, matTy, matVol, spillTime, startTime,
models: rawModels } = req.body
let requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0
? (rawModels as string[])
: ['OpenDrift']
// 1. 필수 파라미터 검증
if (lat === undefined || lon === undefined || runTime === undefined) {
return res.status(400).json({ error: '필수 파라미터 누락', required: ['lat', 'lon', 'runTime'] })
}
if (!isValidLatitude(lat)) {
return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' })
}
if (!isValidLongitude(lon)) {
return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' })
}
if (!isValidNumber(runTime, 1, 720)) {
return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' })
}
if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) {
return res.status(400).json({ error: '유효하지 않은 유출량' })
}
if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) {
return res.status(400).json({ error: '유효하지 않은 유종' })
}
if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) {
return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' })
}
if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) {
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
}
// 2. NC 파일 존재 여부 확인
if (requestedModels.includes('OpenDrift')) {
try {
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, startTime }),
signal: AbortSignal.timeout(5000),
})
if (!checkRes.ok) {
// NC 파일 없으면 OpenDrift만 제외, 나머지 모델(POSEIDON 등)은 계속 진행
requestedModels = requestedModels.filter(m => m !== 'OpenDrift')
if (requestedModels.length === 0) {
return res.status(409).json({
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
message: 'NC 파일이 준비되지 않았습니다.',
})
}
}
} catch {
// Python 서버 미기동 — 이후 단계에서 처리
}
}
// 3. ACDNT/SPIL_DATA 생성 또는 조회
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
let resolvedSpilDataSn: number | null = null
let newlyCreatedAcdntSn: number | null = null
let newlyCreatedSpilDataSn: number | null = null
if (!resolvedAcdntSn && acdntNm) {
try {
const occrn = startTime ?? new Date().toISOString()
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)
VALUES (
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
LPAD(
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
FROM wing.ACDNT
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
4, '0'
),
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
)
RETURNING ACDNT_SN`,
[acdntNm.trim(), occrn, lat, lon]
)
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
newlyCreatedAcdntSn = resolvedAcdntSn
const spilRes = await wingPool.query(
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING SPIL_DATA_SN`,
[
resolvedAcdntSn,
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
matVol ?? 0,
UNIT_MAP[spillUnit as string] ?? 'KL',
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
runTime,
]
)
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
newlyCreatedSpilDataSn = resolvedSpilDataSn
} catch (dbErr) {
console.error('[simulation/run-model] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
return res.status(500).json({ error: '사고 정보 생성 실패' })
}
}
if (resolvedAcdntSn && !resolvedSpilDataSn) {
try {
const spilRes = await wingPool.query(
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING SPIL_DATA_SN`,
[
resolvedAcdntSn,
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
matVol ?? 0,
UNIT_MAP[spillUnit as string] ?? 'KL',
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
runTime,
]
)
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
} catch (dbErr) {
console.error('[simulation/run-model] SPIL_DATA INSERT 실패:', dbErr)
}
}
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
const execNmBase = `EXPC_${Date.now()}`
// 이번 예측 실행을 식별하는 그룹 SN 생성
let predRunSn: number
try {
const runSnRes = await wingPool.query("SELECT nextval('wing.PRED_RUN_SN_SEQ') AS pred_run_sn")
predRunSn = runSnRes.rows[0].pred_run_sn as number
} catch (dbErr) {
console.error('[simulation/run-model] PRED_RUN_SN_SEQ 조회 실패:', dbErr)
return res.status(500).json({ error: '실행 SN 생성 실패' })
}
// KOSPS: PRED_EXEC INSERT(PENDING)만 수행
const execSns: Array<{ model: string; execSn: number }> = []
if (requestedModels.includes('KOSPS')) {
try {
const kospsExecNm = `${execNmBase}_KOSPS`
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, $5, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn, req.user!.sub]
)
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
} catch (dbErr) {
console.error('[simulation/run-model] KOSPS PRED_EXEC INSERT 실패:', dbErr)
}
}
// 4. API 연동 모델 시작 및 완료 대기 (병렬)
const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
interface SyncModelResult {
model: string
execSn: number
status: 'DONE' | 'ERROR'
trajectory?: ReturnType<typeof transformResult>['trajectory']
summary?: ReturnType<typeof transformResult>['summary']
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
windData?: ReturnType<typeof transformResult>['windData']
hydrData?: ReturnType<typeof transformResult>['hydrData']
error?: string
}
const modelResults = await Promise.all(
apiModels.map(async (model): Promise<SyncModelResult> => {
const algoCd = MODEL_ALGO_CD_MAP[model]
const apiUrl = MODEL_API_URL_MAP[model]
const execNm = `${execNmBase}_${algoCd}`
// PRED_EXEC INSERT
let predExecSn: number
try {
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn, req.user!.sub]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {
console.error(`[simulation/run-model] ${model} PRED_EXEC INSERT 실패:`, dbErr)
return { model, execSn: 0, status: 'ERROR', error: 'DB 오류' }
}
execSns.push({ model, execSn: predExecSn })
// Python /run-model 호출
let jobId: string | undefined
try {
const pythonRes = await fetch(`${apiUrl}/run-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, startTime, runTime, matTy: odMatTy, matVol, spillTime, name: execNm }),
signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
})
if (pythonRes.status === 503) {
const errData = await pythonRes.json() as { error?: string }
const errMsg = errData.error || '분석 서버 포화'
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[errMsg, predExecSn]
)
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
}
if (!pythonRes.ok) {
throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`)
}
const pythonData = await pythonRes.json() as {
success?: boolean;
result?: PythonTimeStep[];
job_id?: string;
error?: string;
message?: string;
error_code?: number;
}
// 동기 성공 응답 (OpenDrift & POSEIDON 공통)
if (Array.isArray(pythonData.result)) {
await wingPool.query(
`UPDATE wing.PRED_EXEC
SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1,
CMPL_DTM=NOW(), REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
WHERE PRED_EXEC_SN=$2`,
[JSON.stringify(pythonData.result), predExecSn]
)
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
transformResult(pythonData.result, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
}
// 비동기 응답 (하위 호환)
if (pythonData.job_id) {
jobId = pythonData.job_id
} else {
// 오류 응답 (success: false, HTTP 200)
const errMsg = pythonData.error || pythonData.message || '분석 오류'
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[errMsg, predExecSn]
)
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
}
} catch (fetchErr) {
const errMsg = 'Python 분석 서버에 연결할 수 없습니다.'
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[errMsg, predExecSn]
)
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
}
// RUNNING 업데이트 (비동기 폴링 경로)
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
// 결과 동기 대기
try {
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
} catch (syncErr) {
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
}
})
)
// 모든 모델이 실패하고 신규 생성한 ACDNT가 있으면 롤백
const hasSuccess = modelResults.some((r) => r.status === 'DONE')
if (!hasSuccess && newlyCreatedAcdntSn !== null) {
for (const r of modelResults) {
if (r.execSn) await rollbackNewRecords(r.execSn, null, null)
}
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
}
res.json({
success: true,
acdntSn: resolvedAcdntSn,
predRunSn,
execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))],
results: modelResults,
})
} catch {
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
}
})
// ============================================================
// 백그라운드 폴링
// ============================================================
async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, algoCode: string): Promise<void> {
async function pollAndSave(jobId: string, execSn: number): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
signal: AbortSignal.timeout(5000),
})
if (!pollRes.ok) continue
@ -795,57 +402,6 @@ async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, a
)
}
// ============================================================
// 동기 폴링: Python 결과 대기 후 반환
// ============================================================
async function runModelSync(jobId: string, execSn: number, apiUrl: string): Promise<PythonTimeStep[]> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
let data: PythonStatusResponse
try {
const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
signal: AbortSignal.timeout(5000),
})
if (!pollRes.ok) continue
data = await pollRes.json() as PythonStatusResponse
} catch {
// 네트워크 오류 — 재시도
continue
}
if (data.status === 'DONE' && data.result) {
await wingPool.query(
`UPDATE wing.PRED_EXEC
SET EXEC_STTS_CD='COMPLETED',
RSLT_DATA=$1,
CMPL_DTM=NOW(),
REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
WHERE PRED_EXEC_SN=$2`,
[JSON.stringify(data.result), execSn]
)
return data.result
}
if (data.status === 'ERROR') {
const errMsg = data.error ?? '분석 오류'
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[errMsg, execSn]
)
throw new Error(errMsg)
}
}
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[execSn]
)
throw new Error('분석 시간 초과 (30분)')
}
// ============================================================
// 타입 및 결과 변환
// ============================================================
@ -874,8 +430,6 @@ interface PythonTimeStep {
particles: PythonParticle[]
remaining_volume_m3: number
weathered_volume_m3: number
evaporation_m3?: number
dispersion_m3?: number
pollution_area_km2: number
beached_volume_m3: number
pollution_coast_length_m: number
@ -892,13 +446,7 @@ interface PythonStatusResponse {
error?: string
}
// ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL_NAME: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
}
function transformResult(rawResult: PythonTimeStep[], model: string) {
function transformResult(rawResult: PythonTimeStep[]) {
const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({
lat: p.lat,
@ -912,8 +460,6 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
const summary = {
remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3,
evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65,
dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35,
pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -921,26 +467,17 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
const centerPoints = rawResult
.map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
: null
)
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null)
.filter((p): p is { lat: number; lon: number; time: number } => p !== null)
const windData = rawResult.map((step) => step.wind_data ?? [])
const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid
? { value: step.hydr_data, grid: step.hydr_grid }
: null
)
const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_volume_m3,
weatheredVolume: step.weathered_volume_m3,
evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65,
dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35,
pollutionArea: step.pollution_area_km2,
beachedVolume: step.beached_volume_m3,
pollutionCoastLength: step.pollution_coast_length_m,
}))
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
return { trajectory, summary, centerPoints, windData, hydrData }
}
export default router

파일 보기

@ -1,128 +0,0 @@
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;

파일 보기

@ -1,43 +1,15 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listOffices, listJurisdictions, listZones, listSections, getSection } from './scatService.js';
import { listZones, listSections, getSection } from './scatService.js';
const router = Router();
// ============================================================
// GET /api/scat/offices — 관할청 목록
// ============================================================
router.get('/offices', requireAuth, async (_req, res) => {
try {
const offices = await listOffices();
res.json(offices);
} catch (err) {
console.error('[scat] 관할청 목록 조회 오류:', err);
res.status(500).json({ error: '관할청 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/jurisdictions — 관할서 목록
// ============================================================
router.get('/jurisdictions', requireAuth, async (req, res) => {
try {
const { officeCd } = req.query as { officeCd?: string };
const jurisdictions = await listJurisdictions(officeCd);
res.json(jurisdictions);
} catch (err) {
console.error('[scat] 관할서 목록 조회 오류:', err);
res.status(500).json({ error: '관할서 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/zones — 조사구역 목록
// ============================================================
router.get('/zones', requireAuth, async (req, res) => {
router.get('/zones', requireAuth, async (_req, res) => {
try {
const { jurisdiction, officeCd } = req.query as { jurisdiction?: string; officeCd?: string };
const zones = await listZones({ jurisdiction, officeCd });
const zones = await listZones();
res.json(zones);
} catch (err) {
console.error('[scat] 조사구역 목록 조회 오류:', err);
@ -50,15 +22,14 @@ router.get('/zones', requireAuth, async (req, res) => {
// ============================================================
router.get('/sections', requireAuth, async (req, res) => {
try {
const { zone, status, sensitivity, jurisdiction, search, officeCd } = req.query as {
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
};
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search, officeCd });
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
res.json(sections);
} catch (err) {
console.error('[scat] 해안구간 목록 조회 오류:', err);

파일 보기

@ -60,76 +60,22 @@ interface SectionDetail {
notes: string[];
}
// ============================================================
// 관할청 목록 조회
// ============================================================
export async function listOffices(): Promise<string[]> {
const sql = `
SELECT DISTINCT OFFICE_CD
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND OFFICE_CD IS NOT NULL
ORDER BY OFFICE_CD
`;
const { rows } = await wingPool.query(sql);
return rows.map((r: Record<string, unknown>) => r.office_cd as string);
}
// ============================================================
// 관할서 목록 조회
// ============================================================
export async function listJurisdictions(officeCd?: string): Promise<string[]> {
const conditions: string[] = ["USE_YN = 'Y'", 'JRSD_NM IS NOT NULL'];
const params: unknown[] = [];
let idx = 1;
if (officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(officeCd);
}
const sql = `
SELECT DISTINCT JRSD_NM
FROM wing.CST_SRVY_ZONE
WHERE ${conditions.join(' AND ')}
ORDER BY JRSD_NM
`;
const { rows } = await wingPool.query(sql, params);
return rows.map((r: Record<string, unknown>) => r.jrsd_nm as string);
}
// ============================================================
// 조사구역 목록 조회
// ============================================================
export async function listZones(filters?: {
jurisdiction?: string;
officeCd?: string;
}): Promise<ZoneItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: unknown[] = [];
let idx = 1;
if (filters?.jurisdiction) {
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.jurisdiction);
}
if (filters?.officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
const where = 'WHERE ' + conditions.join(' AND ');
export async function listZones(): Promise<ZoneItem[]> {
const sql = `
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
FROM wing.CST_SRVY_ZONE
${where}
WHERE USE_YN = 'Y'
ORDER BY CST_SRVY_ZONE_SN
`;
const { rows } = await wingPool.query(sql, params);
const { rows } = await wingPool.query(sql);
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
return rows.map((r: Record<string, unknown>) => ({
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
zoneCd: r.zone_cd as string,
@ -153,7 +99,6 @@ export async function listSections(filters: {
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
}): Promise<SectionListItem[]> {
const conditions: string[] = [];
const params: unknown[] = [];
@ -179,10 +124,6 @@ export async function listSections(filters: {
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.search);
}
if (filters.officeCd) {
conditions.push(`z.OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
conditions.push("s.USE_YN = 'Y'");
conditions.push("z.USE_YN = 'Y'");

파일 보기

@ -7,7 +7,6 @@ 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'
@ -19,15 +18,10 @@ 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,
@ -81,7 +75,6 @@ const allowedOrigins = [
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
'http://localhost:3000',
] : []),
].filter(Boolean) as string[]
@ -109,8 +102,7 @@ const generalLimiter = rateLimit({
legacyHeaders: false,
skip: (req) => {
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
return req.path.startsWith('/api/aerial/cctv/stream-proxy') ||
req.path.startsWith('/api/tiles/');
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
},
message: {
error: '요청 횟수 초과',
@ -171,15 +163,10 @@ 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) => {
@ -215,9 +202,6 @@ 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) {

파일 보기

@ -93,7 +93,6 @@ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
{ id: 'monitor', label: '실시간 상황관리', icon: '🛰', enabled: true, order: 11 },
]
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
@ -104,23 +103,18 @@ export async function getMenuConfig(): Promise<MenuConfigItem[]> {
try {
const parsed = JSON.parse(val) as MenuConfigItem[]
const dbMap = new Map(
parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => [item.id, item])
)
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
// DEFAULT 기준으로 머지 (DB에 없는 항목은 기본값 사용)
return DEFAULT_MENU_CONFIG
.map(defaultItem => {
const dbItem = dbMap.get(defaultItem.id)
if (!dbItem) return defaultItem
return parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => {
const defaults = defaultMap.get(item.id)!
return {
id: dbItem.id,
label: dbItem.label || defaultItem.label,
icon: dbItem.icon || defaultItem.icon,
enabled: dbItem.enabled,
order: dbItem.order,
id: item.id,
label: item.label || defaults.label,
icon: item.icon || defaults.icon,
enabled: item.enabled,
order: item.order,
}
})
.sort((a, b) => a.order - b.order)

파일 보기

@ -307,6 +307,7 @@ export async function listOrgs(): Promise<OrgItem[]> {
const { rows } = await authPool.query(
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
FROM AUTH_ORG
WHERE USE_YN = 'Y'
ORDER BY ORG_SN`
)
return rows.map((r: Record<string, unknown>) => ({

파일 보기

@ -1,33 +0,0 @@
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;

파일 보기

@ -1,96 +0,0 @@
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] 선박 신호 스케줄러 중지');
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -278,8 +278,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'),
(1, 'monitor', 'READ', 'Y');
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -293,8 +292,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
(2, 'admin', 'READ', 'N'),
(2, 'monitor', 'READ', 'Y');
(2, 'admin', 'READ', 'N');
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -308,8 +306,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
(3, 'admin', 'READ', 'N'),
(3, 'monitor', 'READ', 'Y');
(3, 'admin', 'READ', 'N');
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -323,8 +320,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
(4, 'weather', 'READ', 'Y'),
(4, 'admin', 'READ', 'N'),
(4, 'monitor', 'READ', 'Y');
(4, 'admin', 'READ', 'N');
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -338,8 +334,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(5, 'incidents', 'READ', 'Y'),
(5, 'board', 'READ', 'Y'),
(5, 'weather', 'READ', 'Y'),
(5, 'admin', 'READ', 'N'),
(5, 'monitor', 'READ', 'Y');
(5, 'admin', 'READ', 'N');
-- ============================================================

파일 보기

@ -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(14,10), -- 유출량
SPIL_QTY NUMERIC(12,2), -- 유출량
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리

파일 보기

@ -15,7 +15,6 @@ CREATE TABLE IF NOT EXISTS LAYER (
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
DEL_YN CHAR(1) DEFAULT 'N' NOT NULL,
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD),
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))

파일 보기

@ -77,8 +77,6 @@ CREATE TABLE IF NOT EXISTS REPORT (
USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ,
STEP3_MAP_IMG TEXT,
STEP6_MAP_IMG TEXT,
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
);

파일 보기

@ -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(14,10),
SPIL_QTY NUMERIC(12,2),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER,

파일 보기

@ -13,7 +13,6 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
ZONE_CD VARCHAR(10) NOT NULL UNIQUE,
ZONE_NM VARCHAR(100) NOT NULL,
JRSD_NM VARCHAR(20),
OFFICE_CD VARCHAR(20),
SECT_CNT INTEGER DEFAULT 0,
LAT_CENTER NUMERIC(9,6),
LNG_CENTER NUMERIC(9,6),
@ -30,9 +29,9 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
CREATE TABLE IF NOT EXISTS CST_SECT (
CST_SECT_SN SERIAL PRIMARY KEY,
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN),
SECT_CD VARCHAR(30) NOT NULL UNIQUE,
SECT_CD VARCHAR(20) NOT NULL UNIQUE,
SECT_NM VARCHAR(200),
CST_TP_CD VARCHAR(100),
CST_TP_CD VARCHAR(30),
ESI_CD VARCHAR(5),
ESI_NUM SMALLINT,
LEN_M NUMERIC(8,1),

파일 보기

@ -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(14,10),
SPIL_QTY NUMERIC(10,2),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER,

파일 보기

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

파일 보기

@ -1,39 +0,0 @@
-- KBS 재난안전포탈 CCTV 스트림 URL 추가 마이그레이션
-- 기존 KBS 카메라 6건 streamUrl + 좌표 업데이트 + 신규 15건 INSERT
-- URL: 백엔드 KBS HLS 리졸버 경유 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8)
-- 좌표: KBS API (d.kbs.co.kr/special/cctv/list) 정확 좌표 반영
SET search_path TO wing, public;
-- 기존 KBS 카메라 streamUrl + 좌표 업데이트 (KBS API 정확 좌표)
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9981/stream.m3u8', LON = 126.5986, LAT = 37.4541, GEOM = ST_SetSRID(ST_MakePoint(126.5986, 37.4541), 4326), CAMERA_NM = '인천 연안부두' WHERE CCTV_SN = 100;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9994/stream.m3u8', LON = 127.7557, LAT = 34.7410, GEOM = ST_SetSRID(ST_MakePoint(127.7557, 34.7410), 4326), CAMERA_NM = '여수 오동도 앞' WHERE CCTV_SN = 97;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9984/stream.m3u8', LON = 126.7489, LAT = 34.3209, GEOM = ST_SetSRID(ST_MakePoint(126.7489, 34.3209), 4326) WHERE CCTV_SN = 108;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9986/stream.m3u8', LON = 128.6001, LAT = 38.2134, GEOM = ST_SetSRID(ST_MakePoint(128.6001, 38.2134), 4326), CAMERA_NM = '속초 등대전망대' WHERE CCTV_SN = 113;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9957/stream.m3u8', LON = 131.8686, LAT = 37.2394, GEOM = ST_SetSRID(ST_MakePoint(131.8686, 37.2394), 4326) WHERE CCTV_SN = 115;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9982/stream.m3u8', LON = 126.2684, LAT = 33.1139, GEOM = ST_SetSRID(ST_MakePoint(126.2684, 33.1139), 4326) WHERE CCTV_SN = 116;
-- 신규 KBS 재난안전포탈 CCTV 추가 (15건) — KBS API 정확 좌표
INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES
-- 서해
(200, '연평도', '서해', 125.6945, 37.6620, ST_SetSRID(ST_MakePoint(125.6945, 37.6620), 4326), '인천 옹진군 연평면', '37.66°N 125.69°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9958/stream.m3u8'),
(201, '군산 비응항', '서해', 126.5265, 35.9353, ST_SetSRID(ST_MakePoint(126.5265, 35.9353), 4326), '전북 군산시 비응도동', '35.94°N 126.53°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9979/stream.m3u8'),
(202, '태안 신진항', '서해', 126.1365, 36.6779, ST_SetSRID(ST_MakePoint(126.1365, 36.6779), 4326), '충남 태안군 근흥면', '36.68°N 126.14°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9980/stream.m3u8'),
-- 남해
(203, '창원 마산항', '남해', 128.5760, 35.1979, ST_SetSRID(ST_MakePoint(128.5760, 35.1979), 4326), '경남 창원시 마산합포구', '35.20°N 128.58°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9985/stream.m3u8'),
(204, '부산 민락항', '남해', 129.1312, 35.1538, ST_SetSRID(ST_MakePoint(129.1312, 35.1538), 4326), '부산 수영구 민락동', '35.15°N 129.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9991/stream.m3u8'),
(205, '목포 북항', '남해', 126.3652, 34.8042, ST_SetSRID(ST_MakePoint(126.3652, 34.8042), 4326), '전남 목포시 죽교동', '34.80°N 126.37°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9992/stream.m3u8'),
(206, '신안 가거도', '남해', 125.1293, 34.0529, ST_SetSRID(ST_MakePoint(125.1293, 34.0529), 4326), '전남 신안군 흑산면', '34.05°N 125.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9983/stream.m3u8'),
(207, '여수 거문도', '남해', 127.3074, 34.0232, ST_SetSRID(ST_MakePoint(127.3074, 34.0232), 4326), '전남 여수시 삼산면', '34.02°N 127.31°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9993/stream.m3u8'),
-- 동해
(208, '강릉 용강동', '동해', 128.8912, 37.7521, ST_SetSRID(ST_MakePoint(128.8912, 37.7521), 4326), '강원 강릉시 용강동', '37.75°N 128.89°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9952/stream.m3u8'),
(209, '강릉 주문진방파제', '동해', 128.8335, 37.8934, ST_SetSRID(ST_MakePoint(128.8335, 37.8934), 4326), '강원 강릉시 주문진읍', '37.89°N 128.83°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9995/stream.m3u8'),
(210, '대관령', '동해', 128.7553, 37.6980, ST_SetSRID(ST_MakePoint(128.7553, 37.6980), 4326), '강원 평창군 대관령면', '37.70°N 128.76°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9989/stream.m3u8'),
(211, '울릉 저동항', '동해', 130.9122, 37.4913, ST_SetSRID(ST_MakePoint(130.9122, 37.4913), 4326), '경북 울릉군 울릉읍', '37.49°N 130.91°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9987/stream.m3u8'),
(212, '포항 두호동 해안로', '동해', 129.3896, 36.0627, ST_SetSRID(ST_MakePoint(129.3896, 36.0627), 4326), '경북 포항시 북구 두호동', '36.06°N 129.39°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9988/stream.m3u8'),
(213, '울산 달동', '동해', 129.3265, 35.5442, ST_SetSRID(ST_MakePoint(129.3265, 35.5442), 4326), '울산 남구 달동', '35.54°N 129.33°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9955/stream.m3u8'),
-- 제주
(214, '제주 도남동', '제주', 126.5195, 33.4891, ST_SetSRID(ST_MakePoint(126.5195, 33.4891), 4326), '제주시 도남동', '33.49°N 126.52°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9954/stream.m3u8');
-- 시퀀스 리셋
SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera));

파일 보기

@ -1,19 +0,0 @@
-- aerial:spectral (AI 탐지/분석) 서브탭 권한 추가
-- 기존 aerial 서브탭(satellite) 뒤, cctv 앞에 배치 (SORT_ORD = 6)
-- 기존 cctv, theory 순서 밀기
UPDATE AUTH_PERM_TREE SET SORT_ORD = 7 WHERE RSRC_CD = 'aerial:cctv';
UPDATE AUTH_PERM_TREE SET SORT_ORD = 8 WHERE RSRC_CD = 'aerial:theory';
-- spectral 리소스 추가
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
VALUES ('aerial:spectral', 'aerial', 'AI 탐지/분석', 1, 6)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- 기존 역할에 spectral READ 권한 부여 (aerial READ 권한이 있는 역할)
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, USE_YN)
SELECT ap.ROLE_SN, 'aerial:spectral', ap.OPER_CD, ap.USE_YN
FROM AUTH_PERM ap
WHERE ap.RSRC_CD = 'aerial'
AND ap.USE_YN = 'Y'
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;

파일 보기

@ -1,43 +0,0 @@
-- ============================================================
-- 022_map_base.sql — 지도 백데이터 관리 테이블
-- ============================================================
-- 관리자가 등록한 지도 유형(S-57, S-101, 3D 등)을 DB로 관리하며,
-- USE_YN 으로 활성/비활성을 제어해 TopBar 햄버거 메뉴 노출을 조정한다.
-- ============================================================
SET search_path TO wing;
-- ============================================================
-- 1. 지도 백데이터 마스터
-- ============================================================
CREATE TABLE IF NOT EXISTS MAP_BASE_DATA (
MAP_SN SERIAL PRIMARY KEY,
MAP_KEY VARCHAR(30) NOT NULL UNIQUE, -- 지도 식별 키 (s57, s101, threeD, satellite 등)
MAP_NM VARCHAR(100) NOT NULL, -- 지도 표시명 (예: S-57 전자해도)
MAP_LEVEL_CD VARCHAR(20), -- 지도 레벨 코드: S-52 | S-57 | S-101 | 3D | SAT | 기타
MAP_SRC TEXT, -- 타일 URL 또는 파일 경로
MAP_DC TEXT, -- 상세 설명
USE_YN CHAR(1) DEFAULT 'Y', -- 사용 여부: Y(사용중) / N(미사용)
DEL_YN CHAR(1) DEFAULT 'N', -- 삭제 여부: Y(삭제됨) / N(정상)
REG_ID VARCHAR(50), -- 등록자 ID
REG_NM VARCHAR(50), -- 등록자 이름
REG_DTM TIMESTAMPTZ DEFAULT NOW(), -- 등록 일시
MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() -- 수정 일시
);
-- ============================================================
-- 2. 인덱스
-- ============================================================
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_USE ON MAP_BASE_DATA(USE_YN);
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_KEY ON MAP_BASE_DATA(MAP_KEY);
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_DEL ON MAP_BASE_DATA(DEL_YN);
-- ============================================================
-- 3. 초기 데이터 — 기존 하드코딩 4개 지도 유형
-- ============================================================
INSERT INTO MAP_BASE_DATA (MAP_KEY, MAP_NM, MAP_LEVEL_CD, USE_YN) VALUES
('s57', 'S-57 전자해도', 'S-57', 'Y'),
('s101', 'S-101 전자해도', 'S-101', 'Y'),
('threeD', '3D 지도', '3D', 'Y'),
('satellite', '위성 영상', 'SAT', 'Y')
ON CONFLICT (MAP_KEY) DO NOTHING;

파일 보기

@ -1,10 +0,0 @@
-- 023_layer_del_yn.sql
-- LAYER 테이블에 소프트 삭제 플래그 추가
ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DEL_YN CHAR(1) DEFAULT 'N' NOT NULL;
-- 기존 데이터 초기화
UPDATE LAYER SET DEL_YN = 'N' WHERE DEL_YN IS NULL;
-- 인덱스
CREATE INDEX IF NOT EXISTS IDX_LAYER_DEL ON LAYER (DEL_YN);

파일 보기

@ -1,57 +0,0 @@
-- 관리자 권한 트리 확장: 게시판관리, 기준정보, 연계관리 섹션 추가
-- AdminView.tsx의 adminMenuConfig.ts에 정의된 전체 메뉴 구조를 AUTH_PERM_TREE에 반영
-- Level 1 섹션 노드 (3개)
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:board-mgmt', 'admin', '게시판관리', 1, 5),
('admin:reference', 'admin', '기준정보', 1, 6),
('admin:external', 'admin', '연계관리', 1, 7)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- Level 2 그룹/리프 노드
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:notice', 'admin:board-mgmt', '공지사항', 2, 1),
('admin:board', 'admin:board-mgmt', '게시판', 2, 2),
('admin:qna', 'admin:board-mgmt', 'QNA', 2, 3),
('admin:map-mgmt', 'admin:reference', '지도관리', 2, 1),
('admin:sensitive-map', 'admin:reference', '민감자원지도', 2, 2),
('admin:coast-guard-assets', 'admin:reference', '해경자산', 2, 3),
('admin:collection', 'admin:external', '수집자료', 2, 1),
('admin:monitoring', 'admin:external', '연계모니터링', 2, 2)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- Level 3 리프 노드
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:map-base', 'admin:map-mgmt', '지도백데이터', 3, 1),
('admin:map-layer', 'admin:map-mgmt', '레이어', 3, 2),
('admin:env-ecology', 'admin:sensitive-map', '환경/생태', 3, 1),
('admin:social-economy', 'admin:sensitive-map', '사회/경제', 3, 2),
('admin:cleanup-equip', 'admin:coast-guard-assets', '방제장비', 3, 1),
('admin:asset-upload', 'admin:coast-guard-assets', '자산현행화', 3, 2),
('admin:dispersant-zone', 'admin:coast-guard-assets', '유처리제 제한구역', 3, 3),
('admin:vessel-materials', 'admin:coast-guard-assets', '방제선 보유자재', 3, 4),
('admin:collect-vessel-signal', 'admin:collection', '선박신호', 3, 1),
('admin:collect-hr', 'admin:collection', '인사정보', 3, 2),
('admin:monitor-realtime', 'admin:monitoring', '실시간 관측자료', 3, 1),
('admin:monitor-forecast', 'admin:monitoring', '수치예측자료', 3, 2),
('admin:monitor-vessel', 'admin:monitoring', '선박위치정보', 3, 3),
('admin:monitor-hr', 'admin:monitoring', '인사', 3, 4)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- AUTH_PERM: 신규 섹션/그룹 노드에 권한 복사
-- admin 권한이 있는 역할에 동일하게 부여 (permResolver의 parent READ gate 충족)
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
SELECT ap.ROLE_SN, nc.RSRC_CD, ap.OPER_CD, ap.GRANT_YN
FROM AUTH_PERM ap
CROSS JOIN (VALUES
('admin:board-mgmt'),
('admin:reference'),
('admin:external'),
('admin:map-mgmt'),
('admin:sensitive-map'),
('admin:coast-guard-assets'),
('admin:collection'),
('admin:monitoring')
) AS nc(RSRC_CD)
WHERE ap.RSRC_CD = 'admin'
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;

파일 보기

@ -1,44 +0,0 @@
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
ALTER TABLE wing.ACDNT_WEATHER
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';

파일 보기

@ -1,41 +0,0 @@
-- ============================================================
-- 027: 민감자원 테이블 생성
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
-- properties는 JSONB로 유연하게 저장
-- ============================================================
SET search_path TO wing, public;
CREATE EXTENSION IF NOT EXISTS postgis;
-- ============================================================
-- 민감자원 테이블
-- ============================================================
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
SR_ID BIGSERIAL PRIMARY KEY,
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
MOD_DT TIMESTAMP
);
-- 공간 인덱스
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
-- 카테고리 인덱스 (유형별 필터링)
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
-- JSONB 인덱스 (properties 내부 검색용)
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
-- 카테고리 + 공간 복합 조회 최적화
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';

파일 보기

@ -1,27 +0,0 @@
-- ============================================================
-- 027: 통합민감도 평가 테이블 생성
-- 계절별 민감도 평가 그리드 데이터 저장
-- properties 구조: { ID, FA_G, SM_G, SP_G, WT_G, MAX_G, GRID_LEVEL }
-- ============================================================
SET search_path TO wing, public;
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS SENSITIVE_EVALUATION (
SR_ID BIGSERIAL PRIMARY KEY,
CATEGORY VARCHAR(50) NOT NULL DEFAULT '민감도평가',
GEOM public.geometry(Geometry, 4326) NOT NULL,
PROPERTIES JSONB NOT NULL DEFAULT '{}',
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
MOD_DT TIMESTAMP
);
CREATE INDEX IF NOT EXISTS IDX_SE_GEOM ON SENSITIVE_EVALUATION USING GIST(GEOM);
CREATE INDEX IF NOT EXISTS IDX_SE_PROPERTIES ON SENSITIVE_EVALUATION USING GIN(PROPERTIES);
COMMENT ON TABLE SENSITIVE_EVALUATION IS '통합민감도 평가 그리드 테이블';
COMMENT ON COLUMN SENSITIVE_EVALUATION.SR_ID IS '민감도 평가 ID';
COMMENT ON COLUMN SENSITIVE_EVALUATION.CATEGORY IS '카테고리 (기본값: 민감도평가)';
COMMENT ON COLUMN SENSITIVE_EVALUATION.GEOM IS '공간 데이터 (EPSG:4326)';
COMMENT ON COLUMN SENSITIVE_EVALUATION.PROPERTIES IS '계절별 민감도 값 { SP_G, SM_G, FA_G, WT_G, MAX_G, GRID_LEVEL }';

파일 보기

@ -1,25 +0,0 @@
-- Migration 028: PRED_EXEC에 실행 그룹 식별자(PRED_RUN_SN) 추가
-- 같은 시점에 여러 모델로 실행된 PRED_EXEC 레코드를 하나의 "예측 실행"으로 묶는다.
-- 목록 화면에서 사고당 예측 실행 횟수만큼 행을 표시하기 위한 기반 구조.
-- 1. 컬럼 추가
ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS PRED_RUN_SN INTEGER;
-- 2. 기존 데이터 마이그레이션
-- 같은 ACDNT_SN + 시작 시각 60초 이내의 레코드를 동일 실행 그룹으로 묶는다.
-- MIN(PRED_EXEC_SN)을 그룹 대표 키로 사용한다.
UPDATE wing.PRED_EXEC pe1
SET PRED_RUN_SN = (
SELECT MIN(pe2.PRED_EXEC_SN)
FROM wing.PRED_EXEC pe2
WHERE pe2.ACDNT_SN = pe1.ACDNT_SN
AND ABS(EXTRACT(EPOCH FROM (
COALESCE(pe2.BGNG_DTM, NOW()) - COALESCE(pe1.BGNG_DTM, NOW())
))) < 60
)
WHERE pe1.PRED_RUN_SN IS NULL;
-- 3. 시퀀스 생성 (신규 실행용 — 기존 최대값보다 충분히 높은 값에서 시작)
CREATE SEQUENCE IF NOT EXISTS wing.PRED_RUN_SN_SEQ
START WITH 10000
INCREMENT BY 1;

파일 보기

@ -1,3 +0,0 @@
-- 029_pred_exec_user.sql
-- PRED_EXEC 테이블에 예측 실행자 ID 컬럼 추가
ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS EXEC_USER_ID UUID;

파일 보기

@ -1,19 +0,0 @@
-- AIS 선박 위치 이력 테이블
CREATE TABLE IF NOT EXISTS wing.AIS_TRACK (
AIS_TRACK_SN SERIAL PRIMARY KEY,
MMSI VARCHAR(12) NOT NULL,
IMO VARCHAR(12),
VESSEL_NM VARCHAR(100),
VESSEL_TP SMALLINT,
LAT NUMERIC(9,6),
LON NUMERIC(10,6),
SPEED NUMERIC(5,1),
COURSE NUMERIC(5,1),
NAV_STATUS SMALLINT,
OBS_DTM TIMESTAMPTZ NOT NULL,
GEOM GEOMETRY(Point, 4326),
SRC_CD VARCHAR(20) DEFAULT 'API'
);
CREATE INDEX IF NOT EXISTS idx_ais_track_mmsi ON wing.AIS_TRACK(MMSI);
CREATE INDEX IF NOT EXISTS idx_ais_track_obs_dtm ON wing.AIS_TRACK(OBS_DTM);
CREATE INDEX IF NOT EXISTS idx_ais_track_geom ON wing.AIS_TRACK USING GIST(GEOM);

파일 보기

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

파일 보기

@ -1,118 +0,0 @@
-- ============================================================
-- 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/components/board/services/boardApi.ts
// frontend/src/tabs/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/components/{탭}/store/newStore.ts (탭 전용)
// frontend/src/tabs/{탭}/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 '@components/board/services/boardApi';
import { fetchBoardPosts, createBoardPost } from '@tabs/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/components/{탭명}/services/{탭명}Api.ts
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
```
### 작성 패턴
```typescript
// frontend/src/components/{탭명}/services/{탭명}Api.ts
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
import { api } from '@common/services/api';
// ============================================================

파일 보기

@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
### 파일 위치
```
frontend/src/components/{탭명}/services/{tabName}Api.ts
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
```
### 기본 구조
```ts
// frontend/src/components/{탭명}/services/{tabName}Api.ts
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
import { api } from '@common/services/api';
@ -1376,7 +1376,7 @@ export default router;
### 4단계: 프론트엔드 API 서비스
```ts
// frontend/src/components/assets/services/equipmentApi.ts
// frontend/src/tabs/assets/services/equipmentApi.ts
import { api } from '@common/services/api';

파일 보기

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

파일 보기

@ -1,565 +1,169 @@
# WING-OPS 디자인 시스템
# WING 디자인 시스템
## 개요
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
시맨틱 토큰 기반 다크/라이트 테마 전환을 지원한다.
해양 환경 위기대응 통합 솔루션 **WING**의 디자인 시스템.
---
## 버전 히스토리
## 브랜드 (Brand)
### v1.1 — 시맨틱 토큰 & 테마 시스템 (2026-03-28~)
### 로고
> 브랜치: `feature/predict-develop`, `feature/design-system-font`
해양에서 발생하는 상황을 종합적으로 지원하는 솔루션 WING의 로고.
v1.0의 축약형 토큰 시스템을 시맨틱 네이밍으로 전면 전환하고, 다크/라이트 테마 전환 기능을 도입한 구조적 리팩토링.
- **파일**: `frontend/public/wing_logo_white.svg`
- **네이티브 크기**: 280 × 20px (비율 14:1)
- **색상**: 단색 흰색 (다크 배경 전용)
#### 변경된 점
#### 로고 규격
| 항목 | 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)`) |
| 용도 | 높이 | Tailwind | 비고 |
|------|------|----------|------|
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
#### 토큰 마이그레이션 매핑
#### 여백 규칙 (Clear Space)
| 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 문서 전면 재작성
- 최소 여백: 로고 높이의 **50%** (상하좌우)
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
---
### v1.0 — 초기 디자인 시스템 (2026-03-24~25)
## 파운데이션 (Foundations)
> 브랜치: `feature/stitch-mcp` | 도구: Google Stitch MCP
### 브랜드 컬러 (Brand Color)
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()` 호출로 전환.
---
## Foundations
### 색상 (Color Palette)
#### 토큰 아키텍처
```
Primitive Tokens (정적) Semantic Tokens (테마 반응형)
────────────────────── ────────────────────────────
--gray-100 ~ --gray-1000 --bg-base, --bg-surface, ...
--blue-100 ~ --blue-1000 --fg-default, --fg-sub, ...
--red-100 ~ --red-1000 --stroke-default, --stroke-light
--green-100 ~ --green-1000 --hover-overlay, --dropdown-bg
--yellow-100 ~ --yellow-1000
--purple-100 ~ --purple-1000
Accent Tokens (테마 불변)
─────────────────────────
--color-accent, --color-info, ...
```
#### Semantic Colors — Background
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--bg-base` | `bg-bg-base` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
| `--bg-surface` | `bg-bg-surface` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
| `--bg-elevated` | `bg-bg-elevated` | `#121929` | `#f1f5f9` | 테이블 헤더, 상위 요소 |
| `--bg-card` | `bg-bg-card` | `#1a2236` | `#ffffff` | 카드 배경 |
| `--bg-surface-hover` | `bg-bg-surface-hover` | `#1e2844` | `#e2e8f0` | 호버 상태 |
#### Semantic Colors — Foreground (Text)
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--fg-default` | `text-fg` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 |
| `--fg-sub` | `text-fg-sub` | `#c0c8dc` | `#475569` | 보조 텍스트 |
| `--fg-disabled` | `text-fg-disabled` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
#### Semantic Colors — Border (Stroke)
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|----------|----------------|------|-------|------|
| `--stroke-default` | `border-stroke` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
| `--stroke-light` | `border-stroke-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
#### Semantic Colors — Overlay
#### 배경 (Background)
| 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)` | 드롭다운 배경 |
| `--bg0` | `#0a0e1a` | `#ffffff` | 최하위 배경 (body, input) |
| `--bg1` | `#0f1524` | `#f8f9fb` | 패널, 모달, 푸터 배경 |
| `--bg2` | `#121929` | `#f0f2f5` | 테이블 헤더, elevated 영역 |
| `--bg3` | `#1a2236` | `#e8ebf0` | 카드, 보조 버튼, 비활성 요소 |
| `--bgH` | `#1e2844` | `#dde1e8` | 행 hover |
#### Accent Colors (테마 불변)
#### 텍스트 (Text)
| 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 호버 |
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--t1` | `#edf0f7` | `#1a1d26` | 주 텍스트 (제목, 본문) |
| `--t2` | `#b0b8cc` | `#4a5568` | 보조 텍스트 (라벨, 설명) |
| `--t3` | `#8690a6` | `#8690a6` | 비활성/메타 — 양 테마 공유 |
#### Static Colors
#### Accent Color
| CSS 변수 | Hex | 용도 |
|----------|-----|------|
| `--static-black` | `#131415` | 테마 무관 고정 검정 |
| `--static-white` | `#ffffff` | 테마 무관 고정 흰색 |
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--cyan` | `#06b6d4` | `#0891b2` | 주 강조색 — 활성 상태, 링크, CTA |
| `--blue` | `#3b82f6` | `#2563eb` | 보조 강조색 — 그라데이션 끝점 |
| **Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | `linear-gradient(135deg, #0891b2, #2563eb)` | Primary 버튼 |
#### Primitive Colors
> 라이트 테마에서 강조색을 한 단계 진하게 적용하여 흰 배경 위 가독성을 확보한다.
UI 전반에서 직접 참조하거나 시맨틱 토큰의 원천으로 사용하는 기본 팔레트. 100~1000 (10단계).
**Gray**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#f1f5f9` | `#e2e8f0` | `#cbd5e1` | `#94a3b8` | `#64748b` | `#475569` | `#334155` | `#1e293b` | `#0f172a` | `#020617` |
**Blue**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
**Green**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
**Yellow**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
**Red**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
**Purple**
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
| `#f3e8ff` | `#e9d5ff` | `#d8b4fe` | `#c084fc` | `#a855f7` | `#9333ea` | `#7e22ce` | `#6b21a8` | `#581c87` | `#3b0764` |
> **보조 컬러 검토**: 필요에 따라 시맨틱 컬러(red `#ef4444`, orange `#f97316`, yellow `#eab308`, green `#22c55e`, purple `#a855f7`)를 보조 컬러로 추가 검토 예정.
---
### 타이포그래피 (Typography)
#### Font Family
#### 폰트 패밀리
| CSS 변수 | Tailwind 클래스 | 용도 |
|----------|----------------|------|
| `--font-korean` | `font-korean` | 기본 UI 텍스트, 한국어/영문 콘텐츠 |
| `--font-mono` | `font-mono` | 좌표, 수치, 데이터 값 |
| — | `font-sans` | body 기본 (PretendardGOV) |
| 토큰 | CSS 변수 | 폰트 | 용도 |
|------|----------|------|------|
| `font-sans` | — | Outfit, Noto Sans KR, sans-serif | 기본 UI (body) |
| `font-korean` | `--fK` | Noto Sans KR, sans-serif | 한글 강조 |
| `font-mono` | `--fM` | JetBrains Mono, monospace | 수치, 코드 |
> 모든 폰트 패밀리가 `PretendardGOV` 우선 스택으로 통일.
> `@font-face`: Regular(400), Medium(500), SemiBold(600), Bold(700) — `/fonts/PretendardGOV-*.otf`
#### 폰트 스케일
#### Typography Categories
| 클래스 | 크기 | Line Height | Weight | 용도 |
|--------|------|-------------|--------|------|
| `text-wing-meta` | 9px | 1.4 | 400 | 메타 텍스트, 날짜, 부가 정보 |
| `text-wing-caption` | 10px | 1.4 | 400 | 캡션, 설명, 라벨 부연 |
| `text-wing-body` | 11px | 1.5 | 400 | 본문, 라벨, 값 (가장 많이 사용) |
| `text-wing-heading` | 13px | 1.4 | 700 | 섹션 헤더 |
| `text-wing-title` | 15px | 1.3 | 700 | 페이지/모달 타이틀 |
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-*` 클래스)
| 클래스 | Size | Font | Weight | 용도 | 샘플 |
|--------|------|------|--------|------|------|
| `.wing-title` | 15px | font-korean | Bold (700) | 패널 제목 | 확산 예측 시뮬레이션 |
| `.wing-section-header` | 13px | font-korean | Bold (700) | 섹션 헤더 | 기본 정보 입력 |
| `.wing-label` | 11px | font-korean | Semibold (600) | 필드 레이블 | 유출량 (kL) |
| `.wing-btn` | 11px | font-korean | Semibold (600) | 버튼 텍스트 | 시뮬레이션 실행 |
| `.wing-value` | 11px | font-mono | Semibold (600) | 수치 / 데이터 값 | 35.1284° N, 129.0598° E |
| `.wing-input` | 11px | font-korean | Normal (400) | 입력 필드 | 서해 대산항 인근 해역 |
| `.wing-section-desc` | 10px | font-korean | Normal (400) | 섹션 설명 | 예측 결과는 기상 조건에 따라... |
| `.wing-subtitle` | 10px | font-korean | Normal (400) | 보조 설명 | 최근 업데이트: 2026-03-24 09:00 KST |
| `.wing-meta` | 9px | font-korean | Normal (400) | 메타 정보 | v2.1 \| 해양환경공단 |
| `.wing-badge` | 9px | font-korean | Bold (700) | 뱃지 / 태그 | 진행중 |
> 11px(`text-wing-body`)이 기본 본문 크기. 정보 밀도가 높은 운영 시스템 특성에 맞춘 compact 설계.
---
### Border Radius
### 아이콘 (Icons)
#### Radius Tokens
- **라이브러리**: `lucide-react` (^0.564.0)
- **스타일**: Stroke 기반, 일관된 2px 선 두께
| Tailwind 클래스 | 값 | 비고 |
|-----------------|-----|------|
| `rounded-sm` | 6px | **Custom** (Tailwind 기본값 오버라이드) |
| `rounded` | 4px (0.25rem) | Tailwind 기본 |
| `rounded-md` | 10px | **Custom** (Tailwind 기본값 오버라이드) |
| `rounded-lg` | 8px (0.5rem) | Tailwind 기본 |
| `rounded-xl` | 12px (0.75rem) | Tailwind 기본 |
| `rounded-2xl` | 16px (1rem) | Tailwind 기본 |
| `rounded-full` | 9999px | Tailwind 기본 |
| 크기 | px | 용도 |
|------|-----|------|
| Small | 16px | 인라인 텍스트, 버튼 내부 |
| Default | 18px | 일반 UI 아이콘 |
| Large | 20px | 강조, 헤더 영역 |
#### 컴포넌트 매핑
```tsx
import { Search, ChevronDown, X } from 'lucide-react';
| Radius | 값 | 적용 컴포넌트 |
|--------|-----|-------------|
| `rounded-sm` | 6px | `.wing-btn`, `.wing-input`, `.wing-card-sm` |
| `rounded` | 4px | `.wing-badge` |
| `rounded-md` | 10px | `.wing-card`, `.wing-section`, `.wing-tab` |
| `rounded-lg` | 8px | `.wing-tab-bar` |
| `rounded-xl` | 12px | `.wing-modal` |
---
### 레이아웃 (Layout)
#### Breakpoints
| Name | Prefix | Min Width | 사용 | 비고 |
|------|--------|-----------|------|------|
| sm | `sm:` | 640px | - | |
| md | `md:` | 768px | - | |
| lg | `lg:` | 1024px | - | |
| xl | `xl:` | 1280px | **사용 중** | TopBar 탭 레이블/아이콘 토글 |
| 2xl | `2xl:` | 1536px | - | |
> Desktop(≥ 1280px)만 지원. Tablet/Mobile 미지원.
| Device | Width | Columns | Gutter | Margin |
|--------|-------|---------|--------|--------|
| Desktop | ≥ 1280px | flex 기반 가변 | gap-2 ~ gap-6 | px-5 ~ px-8 |
| Tablet | 768px ~ 1279px | - | - | - |
| Mobile | < 768px | - | - | - |
#### Spacing Scale
| Scale | rem | px | 용도 |
|-------|-----|----|------|
| 0.5 | 0.125rem | 2px | 미세 간격 |
| 1 | 0.25rem | 4px | 최소 간격 (gap-1) |
| 1.5 | 0.375rem | 6px | 컴팩트 간격 (gap-1.5) |
| 2 | 0.5rem | 8px | 기본 간격 (gap-2, p-2) |
| 2.5 | 0.625rem | 10px | 중간 간격 |
| 3 | 0.75rem | 12px | 표준 간격 (gap-3, p-3) |
| 4 | 1rem | 16px | 넓은 간격 (p-4, gap-4) |
| 5 | 1.25rem | 20px | 패널 패딩 (px-5, py-5) |
| 6 | 1.5rem | 24px | 섹션 간격 (gap-6, p-6) |
| 8 | 2rem | 32px | 큰 간격 (px-8, gap-8) |
| 16 | 4rem | 64px | 최대 간격 |
#### Z-Index Layers
| Layer | z-index | Color | 설명 |
|-------|---------|-------|------|
| Tooltip | 60 | `#a855f7` | 툴팁, 드롭다운 메뉴 |
| Popup | 50 | `#f97316` | 팝업, 지도 오버레이 |
| Modal | 40 | `#ef4444` | 모달 다이얼로그, 백드롭 |
| TopBar | 30 | `#3b82f6` | 상단 네비게이션 바 |
| Sidebar | 20 | `#06b6d4` | 사이드바, 패널 |
| Content | 10 | `#22c55e` | 메인 콘텐츠 영역 |
| Base | 0 | `#8690a6` | 기본 레이어, 배경 |
#### App Shell Classes
| 클래스 | 역할 | Tailwind 스타일 |
|--------|------|----------------|
| `.wing-panel` | 탭 콘텐츠 패널 | `flex flex-col h-full overflow-hidden` |
| `.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
<Search size={16} /> {/* 인라인 */}
<ChevronDown size={18} /> {/* 일반 */}
<X size={20} /> {/* 강조 */}
```
---
## Tailwind 시맨틱 토큰 매핑 요약
### 간격 (Spacing)
| 카테고리 | 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` |
4px 그리드 기반. Tailwind 유틸리티 클래스 사용.
| 토큰 | 값 | Tailwind | 주요 사용처 |
|------|-----|----------|------------|
| `spacing-1` | 4px | `p-1`, `gap-1` | 최소 간격, 아이콘-텍스트 |
| `spacing-1.5` | 6px | `py-1.5`, `gap-1.5` | 인풋 수직 패딩, 버튼 수직 패딩 |
| `spacing-2` | 8px | `p-2`, `gap-2` | 테이블 셀, 버튼 그룹 간격 |
| `spacing-3` | 12px | `p-3`, `gap-3` | 소형 카드 패딩 |
| `spacing-4` | 16px | `p-4`, `gap-4` | 카드/섹션 패딩 |
| `spacing-5` | 20px | `px-5` | 헤더/모달 수평 패딩 |
---
### 테두리 (Border)
#### 색상
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--bd` | `#1e2a42` | `#d0d5dd` | 기본 구분선 |
| `--bdL` | `#2a3a5c` | `#e0e4ea` | 밝은 테두리 |
#### Radius
| 토큰 | 값 | Tailwind | 용도 |
|------|-----|----------|------|
| `radius-xs` | 4px | `rounded` | 배지, 최소 요소 |
| `radius-sm` | 6px | `rounded-sm` | 버튼, 인풋, 소형 카드 (가장 많이 사용) |
| `radius-md` | 10px | `rounded-md` | 카드, 섹션 |
| `radius-lg` | 12px | `rounded-xl` | 모달 |
> `rounded-sm`(6px)이 프로젝트 전반에서 지배적인 기본 반경값.
---
### 단위 체계 (Units)
> **현재 상태**: px 기반 단일 체계
> **향후 방향**: rem 전환 검토 (접근성/반응성 고려)
| 영역 | 현재 단위 | 비고 |
|------|-----------|------|
| font-size | px | `9px`~`15px`, Tailwind arbitrary `text-[11px]` |
| spacing/padding | px | raw CSS `6px 10px` 등 |
| layout 치수 | px | 패널 280~340px, 모달 400~720px |
| border-radius | px | `6px`, `10px`, `12px` |
| html font-size | 미설정 | rem 기준점 없음 (브라우저 기본 16px) |
- Tailwind 기본 유틸리티(`px-5`, `text-sm` 등)는 내부적으로 rem을 사용하나, 이는 의도적 설계가 아닌 Tailwind 부산물
- rem 전환 시 로드맵: `html { font-size }` 기준 설정 → Tailwind config rem 토큰 → wing.css 점진적 전환

파일 보기

@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
| Alias | 실제 경로 | 용도 |
|-------|----------|------|
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
```tsx
import { useAuth } from '@common/hooks/useAuth';
import OilSpillView from '@components/prediction/components/OilSpillView';
import OilSpillView from '@tabs/prediction/components/OilSpillView';
```
---
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
git status
# 스테이징 (파일 지정)
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
git add frontend/src/tabs/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/components/incidents/components/IncidentDetailView.tsx` (신규)
- `frontend/src/tabs/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/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
#### Step 2. 브랜치 생성
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
**Frontend - API:**
```typescript
// frontend/src/components/incidents/services/incidentsApi.ts
// frontend/src/tabs/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/components/incidents/components/IncidentDetailView.tsx
// frontend/src/tabs/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/components/incidents/
git add backend/src/incidents/ frontend/src/tabs/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/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
| | `frontend/src/components/{탭명}/index.ts` | re-export |
| **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 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/components/{탭명}/
frontend/src/tabs/{탭명}/
components/
{TabName}View.tsx # 메인 뷰 컴포넌트
services/
@ -65,7 +65,7 @@ frontend/src/components/{탭명}/
서브탭이 **없는** 간단한 탭:
```tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
export function MonitoringView() {
return (
@ -91,7 +91,7 @@ export function MonitoringView() {
서브탭이 **있는** 탭 (board 패턴):
```tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
import { useSubMenu } from '@common/hooks/useSubMenu';
@ -122,7 +122,7 @@ export function MonitoringView() {
### 1-3. API 서비스 (보일러플레이트)
```ts
// frontend/src/components/monitoring/services/monitoringApi.ts
// frontend/src/tabs/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/components/monitoring/index.ts
// frontend/src/tabs/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 '@components/monitoring';
import { MonitoringView } from '@tabs/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/components/monitoring/components
mkdir -p frontend/src/components/monitoring/services
mkdir -p frontend/src/tabs/monitoring/components
mkdir -p frontend/src/tabs/monitoring/services
```
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/components/monitoring/index.ts` 생성
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/tabs/monitoring/index.ts` 생성
### 2단계: 프론트엔드 기존 파일 수정
@ -592,7 +592,7 @@ mkdir -p frontend/src/components/monitoring/services
+ export type MainTab = '...' | 'monitoring' | 'admin';
--- frontend/src/App.tsx
+ import { MonitoringView } from '@components/monitoring';
+ import { MonitoringView } from '@tabs/monitoring';
// renderView switch 내:
+ case 'monitoring':
+ return <MonitoringView />;
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
## 체크리스트
### 프론트엔드
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/tabs/{탭명}/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/components/{탭명}/
frontend/src/tabs/{탭명}/
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
**1) API 서비스 파일 생성:**
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
파일 위치: `frontend/src/tabs/{탭명}/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/components/{탭명}/
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
# 공통 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/components/{탭명}/
git add frontend/src/tabs/{탭명}/
# 커밋 (Conventional Commits, 한국어)
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
```bash
# 불충분 -- 탭 디렉토리만 검색
grep -rn "mock" frontend/src/components/{탭명}/
grep -rn "mock" frontend/src/tabs/{탭명}/
# 반드시 공통 디렉토리도 검색
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/components/{탭명}/` (UI 상수 제외)
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
- [ ] 라우터 등록 확인: `server.ts``app.use('/api/{탭명}', ...)` 추가됨
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
- [ ] 커밋 + 푸시 + MR 생성

191
docs/PREDICTION-GUIDE.md Normal file
파일 보기

@ -0,0 +1,191 @@
# 확산 예측 기능 가이드
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
---
## 1. 아키텍처 개요
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
```
[프론트] 실행 버튼
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
→ "분석 중..." UI 표시
→ 3초마다 GET /api/simulation/status/:execSn 폴링
[Express 백엔드]
→ PRED_EXEC INSERT (PENDING)
→ POST Python /run-model 즉시 { job_id } 수신
→ 응답 즉시 반환 (프론트 블록 없음)
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
[Python FastAPI :5003]
→ 동시 처리 초과 시 503 즉시 반환
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
→ NC 결과 → JSON 변환 → 상태 DONE
```
---
## 2. DB 스키마 (PRED_EXEC)
```sql
PRED_EXEC_SN SERIAL PRIMARY KEY
ACDNT_SN INTEGER NOT NULL -- 사고 FK
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
-- PENDING | RUNNING | COMPLETED | FAILED
BGNG_DTM TIMESTAMPTZ
CMPL_DTM TIMESTAMPTZ
REQD_SEC INTEGER
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
ERR_MSG TEXT
```
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
---
## 3. Python FastAPI 엔드포인트 (포트 5003)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
### POST /run-model 입력 파라미터
```json
{
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
"runTime": 72, // 예측 시간 (시간)
"matTy": "CRUDE OIL", // OpenDrift 유류명
"matVol": 100.0, // 시간당 유출량 (m³/hr)
"lon": 126.1,
"lat": 36.6,
"spillTime": 12, // 유출 지속 시간 (0=순간)
"name": "EXPC_1710000000000"
}
```
### 유류 코드 매핑 (DB → OpenDrift)
| DB SPIL_MAT_CD | OpenDrift 이름 |
|---------------|---------------|
| CRUD | CRUDE OIL |
| DSEL | DIESEL |
| BNKR | BUNKER |
| HEFO | IFO 180 |
---
## 4. Express 백엔드 주요 엔드포인트
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
- `fetchPredictionList()` — PRED_EXEC 목록 조회
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
---
## 5. 프론트엔드 주요 파일
| 파일 | 역할 |
|------|------|
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
### 핵심 타입 (predictionApi.ts)
```typescript
interface HydrGrid {
lonInterval: number[];
latInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number; cols: number;
}
interface HydrDataStep {
value: [number[][], number[][]]; // [u_2d, v_2d]
grid: HydrGrid;
}
```
### 폴링 훅 패턴
```typescript
useQuery({
queryKey: ['simulationStatus', execSn],
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
enabled: execSn !== null,
refetchInterval: (data) =>
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
});
```
---
## 6. Python 코드 위치 (prediction/)
```
prediction/opendrift/
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
├── config.py 경로 설정 (수정 필요: 환경변수화)
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
├── coastline/ TN_SHORLINE.shp (한국 해안선)
├── startup.sh / shutdown.sh
├── .env.example 환경변수 샘플
└── environment-opendrift.yml conda 환경 재현용
```
---
## 7. 환경변수
### backend/.env
```bash
PYTHON_API_URL=http://localhost:5003
```
### prediction/opendrift/.env
```bash
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
```
---
## 8. 위험 요소
| 위험 | 내용 |
|------|------|
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
---
## 9. 관련 문서
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직

파일 보기

@ -66,7 +66,7 @@ wing/
│ │ ├── utils/ cn, coordinates, geo, sanitize
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
│ └── tabs/ @components/ alias (11개 탭)
│ └── tabs/ @tabs/ alias (11개 탭)
│ ├── prediction/ 유류 확산 예측
│ ├── hns/ HNS 분석
│ ├── rescue/ 구조 시나리오
@ -103,7 +103,7 @@ wing/
| Alias | 경로 |
|-------|------|
| `@common/*` | `src/common/*` |
| `@components/*` | `src/components/*` |
| `@tabs/*` | `src/tabs/*` |
---

파일 보기

@ -4,271 +4,6 @@
## [Unreleased]
## [2026-04-17]
### 추가
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
### 변경
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
### 수정
- 빌드 에러 수정 - 타입 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]
### 추가
- 역추적: 사용자가 유출 추정 시각/분석 범위/탐색 반경을 직접 입력하는 분석 파라미터 UI 구현
- 역추적: AIS 기반 선박 항적 API 연동 및 가중치 위험도 점수 산정 엔진 (backtrackAnalysisService)
- 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성
- 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시
- DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스)
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
### 수정
- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search)
### 변경
- 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요)
## [2026-03-26]
### 추가
- 보고서: 조위/기상(oil-tide) 섹션에 실데이터 렌더링 추가 (풍향/풍속·파고·수온·유향 등)
### 수정
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
- 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선)
## [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]
### 추가
- Stitch MCP 기반 디자인 시스템 카탈로그 페이지 (/design)
- react-router-dom 도입, BrowserRouter 래핑
- SVG 아이콘 에셋 19종 추가
- @/ path alias 추가
- 디자인: Components 탭 추가 (Button, TextField, Overview 페이지)
- 관리자: 수거인력 패널 및 선박모니터링 패널 추가
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel)
- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가
### 변경
- 디자인: 색상 팔레트 컨텐츠 개선 + base.css 확장
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능)
### 문서
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
## [2026-03-20]
### 추가
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가
- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시
- 항공 방제: 위성 요청 취소 기능 추가
- 항공 방제: 위성 요청 목록/히스토리 지도 탭 분리
- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
- Pre-SCAT 해안조사 UI 개선
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
### 수정
- 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정
- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
### 변경
- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
- WeatherRightPanel 중복 코드 정리
### 문서
- PREDICTION-GUIDE.md 삭제
## [2026-03-18]
### 추가
- 관리자: 방제장비 현황 패널 (CleanupEquipPanel) — 관할청·유형별 필터, 자산 수량 조회
- 관리자: 자산 현행화 업로드 패널 (AssetUploadPanel) — 엑셀/CSV 드래그 드롭 업로드
### 변경
- trajectory API 모델별 windData/hydrData 분리 반환
- 예측 서비스(predictionService) 개선
- 보고서: 유출유 확산 지도 패널 및 보고서 생성기 개선
- 관리자: 권한/메뉴 구성 업데이트, AdminView 패널 등록
- prediction/image 이미지 분석 서버 분리 (디렉토리 제거)
### 기타
- DB: monitor 권한 트리 마이그레이션(022) 추가, auth_init 갱신
## [2026-03-17]
### 추가
- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합)
## [2026-03-16]
### 추가
- 보고서 확산예측 지도 캡처 기능 (OilSpreadMapPanel, MAP_CAPTURE_IMG DB 컬럼)
- 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결
- CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI
- KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선
- 사용자 매뉴얼 팝업 기능 추가
- 확산예측 지도 밝은 해도 스타일 적용 (육지 회색 + 바다 파랑)
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
- 오일펜스 배치 가이드 UI 개선
- 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
### 수정
- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정
### 변경
- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일
- 오염분석 UI 개선 — HTML 디자인 참고 반영
- 범례 UI 개선 — HTML 참고 디자인 반영
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
### 기타
- 프론트엔드 포트 변경(5174) + CORS 허용
## [2026-03-13]
### 추가

파일 보기

@ -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,22 +1,13 @@
<!doctype html>
<html lang="ko">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>해양환경 위기대응 통합지원 시스템</title>
<script>
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('wing-theme') || 'dark',
);
</script>
<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>
</head>
<body>
<div id="root"></div>

파일 보기

@ -32,8 +32,6 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -43,7 +41,6 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
@ -51,7 +48,6 @@
"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",
@ -1945,9 +1941,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"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==",
"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==",
"cpu": [
"arm"
],
@ -1959,9 +1955,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -1973,9 +1969,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -1987,9 +1983,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"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==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [
"x64"
],
@ -2001,9 +1997,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"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==",
"cpu": [
"arm64"
],
@ -2015,9 +2011,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2029,9 +2025,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@ -2043,9 +2039,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@ -2057,9 +2053,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -2071,9 +2067,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -2085,9 +2081,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"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==",
"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==",
"cpu": [
"loong64"
],
@ -2099,9 +2095,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"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==",
"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==",
"cpu": [
"loong64"
],
@ -2113,9 +2109,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"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==",
"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==",
"cpu": [
"ppc64"
],
@ -2127,9 +2123,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"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==",
"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==",
"cpu": [
"ppc64"
],
@ -2141,9 +2137,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"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==",
"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==",
"cpu": [
"riscv64"
],
@ -2155,9 +2151,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"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==",
"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==",
"cpu": [
"riscv64"
],
@ -2169,9 +2165,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"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==",
"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==",
"cpu": [
"s390x"
],
@ -2183,9 +2179,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2197,9 +2193,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2211,9 +2207,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2225,9 +2221,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"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==",
"cpu": [
"arm64"
],
@ -2239,9 +2235,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -2253,9 +2249,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"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==",
"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==",
"cpu": [
"ia32"
],
@ -2267,9 +2263,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2281,9 +2277,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -2503,16 +2499,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -2711,9 +2697,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2721,13 +2707,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -2873,9 +2859,9 @@
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2927,9 +2913,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@ -3015,14 +3001,14 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@ -3077,9 +3063,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3368,19 +3354,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-assert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
@ -3946,9 +3919,9 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
"funding": [
{
"type": "github",
@ -4055,16 +4028,16 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"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==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@ -4904,9 +4877,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@ -4938,9 +4911,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
@ -5169,9 +5142,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@ -5380,22 +5353,6 @@
"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",
@ -5409,13 +5366,10 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"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"
}
"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"
},
"node_modules/punycode": {
"version": "2.3.1",
@ -5485,54 +5439,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-window": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -5572,9 +5478,9 @@
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@ -5636,9 +5542,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5652,31 +5558,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@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",
"@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",
"fsevents": "~2.3.2"
}
},
@ -5732,12 +5638,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
@ -5804,9 +5704,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"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==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@ -6288,9 +6188,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {

파일 보기

@ -34,8 +34,6 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -45,7 +43,6 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
@ -53,7 +50,6 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",

파일 보기

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

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

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

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

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

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

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

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

파일 보기

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  크기: 320 B

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