release: 2026-04-17 (320건 커밋) #190

병합
dnlee develop 에서 main 로 21 commits 를 머지했습니다 2026-04-17 13:42:37 +09:00
57개의 변경된 파일16956개의 추가작업 그리고 58485개의 파일을 삭제

파일 보기

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

파일 보기

@ -3,6 +3,7 @@
# commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원)
#==============================================================================
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

3
.gitignore vendored
파일 보기

@ -79,6 +79,9 @@ prediction/image/**/*.pth
frontend/public/hns-manual/pages/
frontend/public/hns-manual/images/
# HNS import pipeline outputs (local, 1회성 생성물)
backend/scripts/hns-import/out/
# Claude Code (team workflow tracked, override global gitignore)
!.claude/
.claude/settings.local.json

파일 보기

@ -2,31 +2,6 @@
해양 오염 사고 대응 방제 운영 지원 시스템. 유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다.
## 🚨 절대 지침 (Absolute Rules)
### 1. 신규 기능 설계/구현 전 develop 최신화 필수
신규 기능 설계나 구현을 시작하기 전, **반드시** 다음 절차를 사용자에게 권유하고 확인을 받을 것:
1. `git fetch origin` 으로 원격 `develop` 최신 상태 확인
2. `origin/develop`이 로컬 `develop`보다 앞서 있으면 → 로컬 `develop`을 최신화 (`git pull --ff-only` 또는 `git checkout -B develop origin/develop`)
3. 최신화된 `develop`에서 신규 브랜치 생성 (`git checkout -b <type>/<name>`)
4. 해당 브랜치에서 설계·구현 진행
> **이유**: 구 버전 develop 기반으로 작업 시 머지 충돌·중복 구현·사라진 코드 복원 등 위험. 최신 base에서 시작해야 MR 리뷰·릴리즈 흐름이 안전.
### 2. 프론트엔드 구성 시 디자인 시스템 준수 필수
모든 프론트엔드 UI 구현은 **반드시** [docs/DESIGN-SYSTEM.md](docs/DESIGN-SYSTEM.md) 규칙을 준수할 것:
- 시맨틱 토큰(`--bg-base`, `--fg-default`, `--color-accent` 등) 사용 — 축약형/하드코딩 금지
- 폰트: `PretendardGOV` 단일 폰트 (4웨이트). `var(--font-korean)`, `var(--font-mono)` 경유
- 다크/라이트 테마 전환 지원 (`data-theme` 속성 기반)
- Tailwind 컬러 키는 CSS 변수 참조 (`bg.base`, `fg.DEFAULT`, `color.accent`)
- 폰트 크기 토큰: `text-caption/body-2/body-1/title-4...` — 인라인 `fontSize` 금지
> **이유**: 디자인 일관성·테마 전환·접근성(대비) 확보. 토큰 외 값은 리팩토링 비용을 증가시킴.
- **타입**: react-ts 모노레포 (frontend + backend)
- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3
- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript
@ -152,47 +127,6 @@ wing/
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
## 진행 중 작업 (완료 후 삭제)
### 폰트 크기 업스케일 작업 (진행 중)
반드시 `memory/font-upscale-plan.md`를 읽고 Phase 진행 상황을 확인할 것.
**토큰 변경 매핑 (이름 유지, 값만 변경):**
- `caption`/`label-2`/`title-6`: 11px → **12px** (0.75rem)
- `label-1`/`title-5`: 12px → **13px** (0.8125rem)
- `body-2`/`title-4`: 13px → **14px** (0.875rem)
- `body-1`/`title-3`: 14px → **16px** (1rem)
**네비게이션 클래스 교체:**
- TopBar 메인탭: `text-title-4``text-title-2` (16px)
- SubMenuBar 서브탭: `text-title-5``text-title-4` (14px)
**작업 범위:**
- Phase 1: tailwind.config.js + base.css 토큰 값 수정
- Phase 2: components.css 하드코딩(27곳) + wing.css `.wing-tab` text-xs→text-caption
- Phase 3: TopBar.tsx + SubMenuBar.tsx 클래스 교체
- Phase 4: text-xs→text-caption, text-sm→text-body-2 스크립트 교체 (design 페이지 제외, 608건)
- Phase 5: prediction 탭 인라인 fontSize 수정
- Phase 6 (보류): wing-header-bar 패딩 — 폰트 변경 후 유저 확인 후 진행
### 디자인 시스템 폰트+색상 통일 작업
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
**색상 규칙:**
- 하드코딩 색상(`#ef4444`, `#a855f7` 등) → CSS 변수 전환
- `rgba(59,130,246,...)` 등 비-accent 계열 → `rgba(6,182,212,...)` (accent cyan)
- 시맨틱 컬러(`color-accent`, `color-info`, `color-caution` 등)는 다양하게 사용 가능하되, 강조 색상은 **최대 2가지**로 제한
- `linear-gradient` → 단색으로 단순화
- 장식용 `border-top`, `border-left` → 제거 여부를 유저에게 확인 후 진행
**폰트 규칙:**
- 하드코딩 `fontSize`/`fontWeight` → Tailwind 토큰 (`text-title-2`, `text-caption` 등)
- `fontFamily: monospace``var(--font-mono)`
- `fontFamily: sans-serif` / `'Noto Sans KR'``var(--font-korean)`
- 인라인 `style={{ fontSize, padding }}` → Tailwind 클래스 전환 (가능한 범위)
## 환경 설정
- Node.js 20 (`.node-version`, fnm 사용)

파일 보기

@ -22,6 +22,7 @@
"pg": "^8.19.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.89.0",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
@ -34,6 +35,37 @@
"typescript": "^5.7.3"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.89.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@ -1689,6 +1721,20 @@
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -2618,6 +2664,13 @@
"node": ">=0.6"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"dev": true,
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",

파일 보기

@ -23,6 +23,7 @@
"pg": "^8.19.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.89.0",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",

파일 보기

@ -0,0 +1,140 @@
# HNS 물질 Import 파이프라인
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
## 파이프라인 구조
```
[Excel xlsm] [PDF 물질정보집 (193종)]
├─ (1) extract-excel.py (2b) extract-pdf.py
│ → out/base.json → out/pdf-data.json
└─ (2a) extract-images.py ──────────────────────────┐
→ out/images/*.png │
↓ │
(3) ocr-images.ts │
→ out/ocr.json │
↓ ↓
(4) merge-data.ts ←──────────────┘
→ frontend/src/data/hnsSubstanceData.json
(5) tsx src/db/seedHns.ts
→ HNS_SUBSTANCE 테이블
```
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
## 전제 조건
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
- Node.js 20
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
## 실행 순서
### 1) Excel 메타 시트 파싱
```bash
cd backend
python scripts/hns-import/extract-excel.py
```
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
- 출력: `scripts/hns-import/out/base.json`
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
```bash
python scripts/hns-import/extract-images.py
```
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
### 2b) PDF 물질정보집 파싱 ★ 권장
```bash
python scripts/hns-import/extract-pdf.py
```
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
- 출력: `scripts/hns-import/out/pdf-data.json`
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
cd backend
npx tsx scripts/hns-import/ocr-images.ts
```
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
### 4) 최종 JSON 병합
```bash
cd backend
npx tsx scripts/hns-import/merge-data.ts
```
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
### 5) DB 재시드
```bash
cd backend
npx tsx src/db/seedHns.ts
```
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
```bash
cd backend
python scripts/hns-import/extract-pdf.py && \
npx tsx scripts/hns-import/merge-data.ts && \
npx tsx src/db/seedHns.ts
```
## 현재 데이터 현황 (2024-04 기준)
| 항목 | 이전 (OCR) | 현재 (PDF) |
|------|-----------|-----------|
| 총 물질 수 | 514종 | 514종 |
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
| NFPA 코드 있음 | ~150종 | 201종 |
| CAS 번호 있음 | ~380종 | 504종 |
| 해양거동 있음 | ~0종 | 206종 |
| 한국어 유사명 있음 | ~200종 | 449종 |
## 재실행 안내
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
## 알려진 이슈
### 1) PDF 매칭 실패 35종
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
**원인:**
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin``디이알 383`)
- 동일 CAS 충돌 (예: `컨덴세이트``나프타`가 같은 CAS)
- 표기 차이 (예: `아이소파-G``아이소파 G`)
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.

파일 보기

@ -0,0 +1,236 @@
"""Excel 메타 시트 → base.json 변환.
처리 시트:
- 화물적부도 화물코드: 1,345 기본 레코드
- 동의어: 215 / 유사명
- IBC CODE: IMO IBC 분류
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts) 맞춘 JSON 배열.
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
import openpyxl
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OUT_DIR.mkdir(exist_ok=True)
SOURCE_XLSX = Path(os.environ.get(
'HNS_SOURCE_XLSX',
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
))
def norm_key(s: str | None) -> str:
if not s:
return ''
return re.sub(r'\s+', '', str(s)).strip().lower()
def split_synonyms(raw: str | None) -> str:
if not raw:
return ''
# 원본은 "·" 또는 "/" 구분, 개행 포함
parts = re.split(r'[·/\n]+', str(raw))
cleaned = [p.strip() for p in parts if p and p.strip()]
return ' / '.join(cleaned)
def clean_text(v) -> str:
if v is None:
return ''
return str(v).strip()
def main() -> None:
print(f'[읽기] {SOURCE_XLSX}')
if not SOURCE_XLSX.exists():
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
# ────────── 화물적부도 화물코드 ──────────
ws = wb['화물적부도 화물코드']
rows = list(ws.iter_rows(values_only=True))
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}')
# ────────── 동의어 시트 ──────────
ws_syn = wb['동의어']
syn_rows = list(ws_syn.iter_rows(values_only=True))
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
syn_map: dict[str, dict] = {}
for r in syn_rows[2:]:
if not r or r[0] is None:
continue
name_kr = clean_text(r[1])
cas = clean_text(r[3])
if not name_kr and not cas:
continue
key = norm_key(name_kr) or norm_key(cas)
syn_map[key] = {
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
}
print(f'[동의어] {len(syn_map)}')
# ────────── IBC CODE 시트 ──────────
ws_ibc = wb['IBC CODE']
ibc_map: dict[str, dict] = {}
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
if i < 2:
continue # header 2 rows
if not r or not r[0]:
continue
name_en = clean_text(r[0])
key = norm_key(name_en)
if not key:
continue
ibc_map[key] = {
'ibcHazard': clean_text(r[2]), # 위험성 S/P
'ibcShipType': clean_text(r[3]), # 선박형식
'ibcTankType': clean_text(r[4]), # 탱크형식
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
}
print(f'[IBC CODE] {len(ibc_map)}')
wb.close()
# ────────── 통합 레코드 생성 ──────────
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
groups: dict[str, list] = defaultdict(list)
for r in cargo_rows:
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
# 그룹 키: CAS 우선, 없으면 국문명
cas_s = clean_text(cas)
group_key = cas_s if cas_s else norm_key(name_kr)
groups[group_key].append({
'abbreviation': clean_text(abbr),
'nameKr': clean_text(name_kr),
'nameEn': clean_text(name_en),
'synonymsKr': split_synonyms(syn_kr),
'synonymsEn': split_synonyms(syn_en),
'usage': clean_text(usage),
'unNumber': clean_text(un),
'casNumber': cas_s,
})
records: list[dict] = []
next_id = 1
for group_key, entries in groups.items():
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
name_kr_key = norm_key(primary['nameKr'])
name_en_key = norm_key(primary['nameEn'])
# 동의어 병합
syn_extra = syn_map.get(name_kr_key, {})
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
# IBC 병합 (영문명 기준)
ibc = ibc_map.get(name_en_key, {})
# cargoCodes 집계
cargo_codes = [
{
'code': e['abbreviation'],
'name': e['nameEn'] or e['nameKr'],
'company': '국제공통',
'source': '적부도',
}
for e in entries
if e['abbreviation']
]
record = {
'id': next_id,
'abbreviation': primary['abbreviation'],
'nameKr': primary['nameKr'],
'nameEn': primary['nameEn'],
'synonymsKr': synonyms_kr,
'synonymsEn': synonyms_en,
'unNumber': primary['unNumber'],
'casNumber': primary['casNumber'],
'transportMethod': '',
'sebc': '',
# 물리·화학 (OCR 단계에서 채움)
'usage': primary['usage'],
'state': '',
'color': '',
'odor': '',
'flashPoint': '',
'autoIgnition': '',
'boilingPoint': '',
'density': '',
'solubility': '',
'vaporPressure': '',
'vaporDensity': '',
'explosionRange': '',
# 위험도
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
'hazardClass': '',
'ergNumber': '',
'idlh': '',
'aegl2': '',
'erpg2': '',
# 방제
'responseDistanceFire': '',
'responseDistanceSpillDay': '',
'responseDistanceSpillNight': '',
'marineResponse': '',
'ppeClose': '',
'ppeFar': '',
# MSDS
'msds': {
'hazard': '',
'firstAid': '',
'fireFighting': '',
'spillResponse': '',
'exposure': '',
'regulation': '',
},
# IBC
'ibcHazard': ibc.get('ibcHazard', ''),
'ibcShipType': ibc.get('ibcShipType', ''),
'ibcTankType': ibc.get('ibcTankType', ''),
'ibcDetection': ibc.get('ibcDetection', ''),
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
# EmS (OCR에서 채움)
'emsCode': '',
'emsFire': '',
'emsSpill': '',
'emsFirstAid': '',
# cargoCodes / portFrequency
'cargoCodes': cargo_codes,
'portFrequency': [],
}
records.append(record)
next_id += 1
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
# 저장
out_path = OUT_DIR / 'base.json'
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(records, f, ensure_ascii=False, indent=2)
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,170 @@
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
엑셀 워크시트 drawing image 관계 체인을 추적해
물질 시트의 핵심 이미지만 out/images/{nameKr}.png 저장.
동시에 out/image-map.json 생성 (파일명 시트명/국문명 매핑).
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
IMG_DIR = OUT_DIR / 'images'
IMG_DIR.mkdir(parents=True, exist_ok=True)
SOURCE_XLSX = Path(os.environ.get(
'HNS_SOURCE_XLSX',
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
))
NS = {
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
}
# 메타 시트(데이터 시트)는 스킵
SKIP_SHEETS = {
'화물적부도 화물코드',
'항구별 코드',
'동의어',
'IBC CODE',
'경계선',
}
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
SKIP_PATTERN = re.compile(r'^\d{3}$')
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
MIN_IMAGE_SIZE = 50_000 # 50 KB
def safe_filename(name: str) -> str:
name = name.strip().rstrip(',').strip()
name = re.sub(r'[<>:"/\\|?*]', '_', name)
return name
def norm_path(p: str) -> str:
return os.path.normpath(p).replace(os.sep, '/')
def main() -> None:
print(f'[읽기] {SOURCE_XLSX}')
if not SOURCE_XLSX.exists():
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
image_map: dict[str, dict] = {}
saved = 0
skipped = 0
missing = 0
with zipfile.ZipFile(SOURCE_XLSX) as z:
# 1) workbook.xml → sheet 목록
with z.open('xl/workbook.xml') as f:
wb_root = ET.parse(f).getroot()
sheets = []
for s in wb_root.findall('m:sheets/m:sheet', NS):
sheets.append({
'name': s.get('name'),
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
})
with z.open('xl/_rels/workbook.xml.rels') as f:
rels_root = ET.parse(f).getroot()
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
for s in sheets:
s['target'] = rid_target.get(s['rid'])
print(f'[시트] 총 {len(sheets)}')
for s in sheets:
name = s['name']
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
skipped += 1
continue
sheet_file = 'xl/' + s['target']
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
try:
with z.open(rels_file) as f:
srels = ET.parse(f).getroot()
except KeyError:
missing += 1
continue
# 시트 → drawing
drawing_rel = None
for r in srels.findall('pr:Relationship', NS):
t = r.get('Target') or ''
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
drawing_rel = t
break
if not drawing_rel:
missing += 1
continue
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
try:
with z.open(drawing_rels_path) as f:
drels = ET.parse(f).getroot()
except KeyError:
missing += 1
continue
# drawing → images
image_paths: list[str] = []
for r in drels.findall('pr:Relationship', NS):
t = r.get('Target') or ''
if 'image' in t.lower():
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
image_paths.append(img_path)
if not image_paths:
missing += 1
continue
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
sized.sort(reverse=True)
largest_size, largest_path = sized[0]
if largest_size < MIN_IMAGE_SIZE:
missing += 1
continue
# 저장
safe = safe_filename(name)
ext = os.path.splitext(largest_path)[1].lower() or '.png'
out_name = f'{safe}{ext}'
out_path = IMG_DIR / out_name
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
fout.write(fin.read())
image_map[out_name] = {
'sheetName': name,
'nameKr': safe,
'source': largest_path,
'sizeBytes': largest_size,
}
saved += 1
if saved % 25 == 0:
print(f' {saved}개 저장 완료')
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
map_path = OUT_DIR / 'image-map.json'
with open(map_path, 'w', encoding='utf-8') as f:
json.dump(image_map, f, ensure_ascii=False, indent=2)
print(f'[완료] 매핑 파일: {map_path}')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,707 @@
"""PDF 물질정보집 → pdf-data.json 변환.
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
해양경찰청 발행 193 물질 정보
PDF 구조:
- 페이지 1-21: 표지/머리말/목차
- 페이지 22-407: 193 × 2페이지 물질 카드
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
- 물질 NO(1-193) 0-인덱스 시작 페이지: 21 + (NO-1) * 2
출력: out/pdf-data.json
{ [nameKr]: OcrResult } merge-data.ts 동일한 구조
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
from pathlib import Path
import fitz # PyMuPDF
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OUT_DIR.mkdir(exist_ok=True)
PDF_PATH = Path(os.environ.get(
'HNS_PDF_PATH',
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
))
# 전각 문자 → 반각 변환 테이블
_FULLWIDTH = str.maketrans(
'()tC°℃ ,',
'()tC℃℃ ,',
)
def clean(s: str) -> str:
"""텍스트 정리."""
if not s:
return ''
s = s.translate(_FULLWIDTH)
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
s = re.sub(r'(?<=[0-9])\s*[仁七부사   ](?=\s|$|이|이하)', '', s)
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
def norm_key(s: str) -> str:
"""정규화 키: 공백/특수문자 제거 + 소문자."""
if not s:
return ''
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
def normalize_cas(raw: str) -> str:
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
if not raw:
return ''
# 혼합물
if '혼합물' in raw:
return ''
# 특수 대시 → -
s = raw.replace('', '-').replace('', '-').replace('', '-')
# OCR 오인식: 이,이, 공백 등 → 0
s = re.sub(r'[이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()

파일 보기

@ -0,0 +1,23 @@
"""배치 JSON을 ocr.json에 병합."""
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OCR_PATH = OUT_DIR / 'ocr.json'
BATCH_PATH = OUT_DIR / 'batch.json'
with open(OCR_PATH, encoding='utf-8') as f:
ocr = json.load(f)
with open(BATCH_PATH, encoding='utf-8') as f:
batch = json.load(f)
added = [k for k in batch if k not in ocr]
updated = [k for k in batch if k in ocr]
ocr.update(batch)
with open(OCR_PATH, 'w', encoding='utf-8') as f:
json.dump(ocr, f, ensure_ascii=False, indent=2)
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')

파일 보기

@ -0,0 +1,362 @@
/**
* base.json + pdf-data.json + ocr.json frontend/src/data/hnsSubstanceData.json
*
* 우선순위: pdf-data (PDF , ) > base.json > ocr.json ( OCR, )
* :
* 1. CAS ( )
* 2. (nameKr)
* 3. (synonymsKr)
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, 'out');
const BASE_PATH = resolve(OUT_DIR, 'base.json');
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
function normalizeName(s: string | undefined): string {
if (!s) return '';
return s
.replace(/\s+/g, '')
.replace(/[,.·/\-_()[\]]/g, '')
.toLowerCase();
}
function normalizeCas(s: string | undefined): string {
if (!s) return '';
// 앞자리 0 제거 후 정규화
return s
.replace(/[^0-9\-]/g, '')
.replace(/^0+/, '')
.trim();
}
interface NfpaBlock {
health: number;
fire: number;
reactivity: number;
special: string;
}
interface MsdsBlock {
hazard: string;
firstAid: string;
fireFighting: string;
spillResponse: string;
exposure: string;
regulation: string;
}
interface BaseRecord {
id: number;
abbreviation: string;
nameKr: string;
nameEn: string;
synonymsEn: string;
synonymsKr: string;
unNumber: string;
casNumber: string;
transportMethod: string;
sebc: string;
usage: string;
state: string;
color: string;
odor: string;
flashPoint: string;
autoIgnition: string;
boilingPoint: string;
density: string;
solubility: string;
vaporPressure: string;
vaporDensity: string;
explosionRange: string;
nfpa: NfpaBlock;
hazardClass: string;
ergNumber: string;
idlh: string;
aegl2: string;
erpg2: string;
responseDistanceFire: string;
responseDistanceSpillDay: string;
responseDistanceSpillNight: string;
marineResponse: string;
ppeClose: string;
ppeFar: string;
msds: MsdsBlock;
ibcHazard: string;
ibcShipType: string;
ibcTankType: string;
ibcDetection: string;
ibcFireFighting: string;
ibcMinRequirement: string;
emsCode: string;
emsFire: string;
emsSpill: string;
emsFirstAid: string;
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
}
interface PdfResult {
[key: string]: unknown;
casNumber?: string;
nameKr?: string;
nfpa?: Partial<NfpaBlock>;
msds?: Partial<MsdsBlock>;
}
interface OcrResult {
[key: string]: unknown;
}
function firstString(...values: Array<unknown>): string {
for (const v of values) {
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
}
return '';
}
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
const n = source.nfpa as Partial<NfpaBlock> | undefined;
if (!n || typeof n !== 'object') return null;
const h = Number(n.health);
const f = Number(n.fire);
const r = Number(n.reactivity);
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
return {
health: h,
fire: f,
reactivity: r,
special: typeof n.special === 'string' ? n.special : '',
};
}
function pickMsds(
pdf: PdfResult | undefined,
ocr: OcrResult | undefined,
base: MsdsBlock,
): MsdsBlock {
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
return {
hazard: firstString(base.hazard, p.hazard, o.hazard),
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
exposure: firstString(base.exposure, p.exposure, o.exposure),
regulation: firstString(base.regulation, p.regulation, o.regulation),
};
}
function merge(
base: BaseRecord,
pdf: PdfResult | undefined,
ocr: OcrResult | undefined,
): BaseRecord {
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
// pdf NFPA 우선, 없으면 ocr, 없으면 base
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
return {
...base,
// pdf > base > ocr 우선순위
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
state: firstString(pdf?.state, base.state, ocr?.state),
color: firstString(pdf?.color, base.color, ocr?.color),
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
density: firstString(pdf?.density, base.density, ocr?.density),
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
nfpa,
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
msds: pickMsds(pdf, ocr, base.msds),
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
};
}
function main() {
if (!existsSync(BASE_PATH)) {
console.error(`base.json 없음: ${BASE_PATH}`);
console.error('→ extract-excel.py 를 먼저 실행하세요.');
process.exit(1);
}
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
// PDF 데이터 로드
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
: {};
// OCR 데이터 로드
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
: {};
console.log(
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}`,
);
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
// 1) nameKr 정규화 인덱스
const pdfByName = new Map<string, PdfResult>();
// 2) CAS 번호 인덱스
const pdfByCas = new Map<string, PdfResult>();
for (const [key, value] of Object.entries(pdfRaw)) {
const normKey = normalizeName(key);
if (normKey) pdfByName.set(normKey, value);
const cas = normalizeCas(value.casNumber);
if (cas) {
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
}
}
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
const ocrByName = new Map<string, OcrResult>();
const ocrNormToOrig = new Map<string, string>();
for (const [key, value] of Object.entries(ocr)) {
const normKey = normalizeName(key);
if (normKey) {
ocrByName.set(normKey, value);
ocrNormToOrig.set(normKey, key);
}
}
// ── 병합 ──────────────────────────────────────────────────────────
let pdfMatchedByName = 0;
let pdfMatchedByCas = 0;
let pdfMatchedBySynonym = 0;
let ocrMatched = 0;
const pdfUnmatched = new Set(Object.keys(pdfRaw));
const ocrUnmatched = new Set(ocrByName.keys());
const merged = base.map((record) => {
let pdfResult: PdfResult | undefined;
let ocrResult: OcrResult | undefined;
// ── PDF 매칭 ────────────────────────────────────────────────────
// 1. CAS 번호 매칭 (가장 정확)
const baseCas = normalizeCas(record.casNumber);
if (baseCas) {
pdfResult = pdfByCas.get(baseCas);
if (pdfResult) {
pdfMatchedByCas++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
}
// 2. nameKr 정규화 매칭
if (!pdfResult) {
const normKr = normalizeName(record.nameKr);
pdfResult = pdfByName.get(normKr);
if (pdfResult) {
pdfMatchedByName++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
}
// 3. synonymsKr 동의어 매칭
if (!pdfResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) {
const normSyn = normalizeName(syn);
if (!normSyn) continue;
pdfResult = pdfByName.get(normSyn);
if (pdfResult) {
pdfMatchedBySynonym++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
break;
}
}
}
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
const normKr = normalizeName(record.nameKr);
const ocrByNameResult = ocrByName.get(normKr);
if (ocrByNameResult) {
ocrResult = ocrByNameResult;
ocrMatched++;
ocrUnmatched.delete(normKr);
}
if (!ocrResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) {
const normSyn = normalizeName(syn);
if (!normSyn) continue;
const synOcrResult = ocrByName.get(normSyn);
if (synOcrResult) {
ocrResult = synOcrResult;
ocrMatched++;
ocrUnmatched.delete(normSyn);
break;
}
}
}
return merge(record, pdfResult, ocrResult);
});
// ── 통계 출력 ──────────────────────────────────────────────────────
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
console.log(
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
);
console.log(`[OCR 매칭] ${ocrMatched}`);
if (pdfUnmatched.size > 0) {
const unmatchedList = Array.from(pdfUnmatched).sort();
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
writeFileSync(
unmatchedPath,
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
'utf-8',
);
console.warn(
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
);
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
}
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}`);
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}`);
}
main();

파일 보기

@ -0,0 +1,300 @@
/**
* Claude Vision API HNS JSON .
*
* 입력: out/images/*.png (222)
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
*
* 환경변수: ANTHROPIC_API_KEY
* 모델: claude-sonnet-4-5 (Vision + )
* 동시성: 5, 3
*
* ocr.json .
*/
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname, basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, 'out');
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
? resolve(process.env.HNS_OCR_IMG_DIR)
: resolve(OUT_DIR, 'images');
const OCR_PATH = process.env.HNS_OCR_OUT
? resolve(process.env.HNS_OCR_OUT)
: resolve(OUT_DIR, 'ocr.json');
const FAIL_PATH = process.env.HNS_OCR_FAIL
? resolve(process.env.HNS_OCR_FAIL)
: resolve(OUT_DIR, 'ocr-failures.json');
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
const OCR_ONLY = process.env.HNS_OCR_ONLY
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
const CONCURRENCY = 5;
const MAX_RETRIES = 3;
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
릿 :
- 상단: 국문명,
- 물질특성: CAS번호, UN번호, , , (///), , //, //, //, /, NFPA (//), GHS , ERG
- 대응방법: 주요 (PPE /), (EmS F-x), (EmS S-x), ,
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, /// ·
JSON **** . "" null.
(: "80℃", "2,410 mmHg (25℃)").
NFPA // 0~4 . special ( ).
** JSON ** ( ).
:
{
"transportMethod": "",
"state": "",
"color": "",
"odor": "",
"flashPoint": "",
"autoIgnition": "",
"boilingPoint": "",
"density": "",
"solubility": "",
"vaporPressure": "",
"vaporDensity": "",
"explosionRange": "",
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
"hazardClass": "",
"ergNumber": "",
"idlh": "",
"aegl2": "",
"erpg2": "",
"twa": "",
"stel": "",
"responseDistanceFire": "",
"responseDistanceSpillDay": "",
"responseDistanceSpillNight": "",
"marineResponse": "",
"ppeClose": "",
"ppeFar": "",
"msds": {
"hazard": "",
"firstAid": "",
"fireFighting": "",
"spillResponse": "",
"exposure": "",
"regulation": ""
},
"emsCode": "",
"emsFire": "",
"emsSpill": "",
"emsFirstAid": "",
"sebc": ""
}`;
interface OcrResult {
transportMethod?: string;
state?: string;
color?: string;
odor?: string;
flashPoint?: string;
autoIgnition?: string;
boilingPoint?: string;
density?: string;
solubility?: string;
vaporPressure?: string;
vaporDensity?: string;
explosionRange?: string;
nfpa?: { health: number; fire: number; reactivity: number; special: string };
hazardClass?: string;
ergNumber?: string;
idlh?: string;
aegl2?: string;
erpg2?: string;
twa?: string;
stel?: string;
responseDistanceFire?: string;
responseDistanceSpillDay?: string;
responseDistanceSpillNight?: string;
marineResponse?: string;
ppeClose?: string;
ppeFar?: string;
msds?: {
hazard?: string;
firstAid?: string;
fireFighting?: string;
spillResponse?: string;
exposure?: string;
regulation?: string;
};
emsCode?: string;
emsFire?: string;
emsSpill?: string;
emsFirstAid?: string;
sebc?: string;
}
function loadExisting<T>(path: string, fallback: T): T {
if (!existsSync(path)) return fallback;
try {
return JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return fallback;
}
}
function extractJson(text: string): OcrResult | null {
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
const firstBrace = cleaned.indexOf('{');
const lastBrace = cleaned.lastIndexOf('}');
if (firstBrace < 0 || lastBrace < 0) return null;
try {
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
} catch {
return null;
}
}
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
const imageData = readFileSync(imagePath).toString('base64');
const ext = extname(imagePath).slice(1).toLowerCase();
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
| 'image/png'
| 'image/jpeg';
const response = await client.messages.create({
model: MODEL,
max_tokens: 4096,
system: [
{
type: 'text',
text: SYSTEM_PROMPT,
cache_control: { type: 'ephemeral' },
},
],
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: { type: 'base64', media_type: mediaType, data: imageData },
},
{
type: 'text',
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
},
],
},
],
});
const textBlock = response.content.find((b) => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
throw new Error('응답에 텍스트 블록 없음');
}
const result = extractJson(textBlock.text);
if (!result) {
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
}
return result;
}
async function processWithRetry(
client: Anthropic,
imagePath: string,
nameKr: string,
): Promise<OcrResult> {
let lastErr: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await callVision(client, imagePath);
} catch (err) {
lastErr = err;
const wait = 1000 * Math.pow(2, attempt - 1);
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
let cursor = 0;
const workers = Array.from({ length: CONCURRENCY }, async () => {
while (cursor < items.length) {
const idx = cursor++;
await worker(items[idx], idx);
}
});
await Promise.all(workers);
}
async function main() {
if (!process.env.ANTHROPIC_API_KEY) {
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
process.exit(1);
}
const client = new Anthropic();
if (!existsSync(IMG_DIR)) {
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
process.exit(1);
}
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
const images = OCR_ONLY
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
: allImages;
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
let pending = images.filter((f) => {
const nameKr = basename(f, extname(f));
return !(nameKr in existing);
});
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
pending = pending.slice(0, OCR_LIMIT);
}
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}`);
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
console.log(`[출력] ${OCR_PATH}`);
let done = 0;
let failed = 0;
await runPool(pending, async (file, idx) => {
const nameKr = basename(file, extname(file));
const path = resolve(IMG_DIR, file);
try {
const result = await processWithRetry(client, path, nameKr);
existing[nameKr] = result;
delete failures[nameKr];
done++;
if (done % 10 === 0 || done === pending.length) {
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
}
} catch (err) {
failed++;
failures[nameKr] = String(err).slice(0, 500);
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
}
});
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
console.log(` OCR 결과: ${OCR_PATH}`);
if (Object.keys(failures).length > 0) {
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

파일 보기

@ -0,0 +1,324 @@
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
전용 venv(.venv) 설치된 easyocr을 사용한다.
1. 이미지 EasyOCR (bbox, text, conf) 리스트
2. y좌표로 그룹화 x좌표 정렬
3. 레이블 키워드 기반 필드 매핑 (정규식)
4. 결과를 out/ocr.json 누적 저장 (재실행 가능)
실행:
cd backend/scripts/hns-import
source .venv/Scripts/activate # Windows Git Bash
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
"""
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
IMG_DIR = OUT_DIR / 'images'
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
LABEL_CANDIDATES: dict[str, list[str]] = {
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
'unNumber': ['UN번호', 'UNNO', 'UN'],
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
'usage': ['용도'],
'state': ['성상', '상태', '형태'],
'color': ['색상', ''],
'odor': ['냄새'],
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
'autoIgnition': ['발화점', '발회점', '발하점'],
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
'density': ['비중'],
'solubility': ['용해도', '용해'],
'vaporPressure': ['증기압', '증기압력'],
'vaporDensity': ['증기밀도'],
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
'idlh': ['IDLH'],
'aegl2': ['AEGL-2', 'AEGL2'],
'erpg2': ['ERPG-2', 'ERPG2'],
'twa': ['TWA'],
'stel': ['STEL'],
'ergNumber': ['ERG번호', 'ERG'],
'hazardClass': ['위험분류', '위험', '분류'],
'synonymsKr': ['유사명'],
'responseDistanceFire': ['대피거리', '머피거리'],
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
'marineResponse': ['해상대응', '해상'],
}
def _norm_label(s: str) -> str:
"""공백/특수문자 제거 후 비교용 정규화."""
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
LABEL_INDEX: dict[str, str] = {}
for _field, _candidates in LABEL_CANDIDATES.items():
for _cand in _candidates:
LABEL_INDEX[_norm_label(_cand)] = _field
# NFPA 셀 값(한 자릿수 0~4) 추출용
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
if not items:
return []
heights = [it['y1'] - it['y0'] for it in items]
median_h = sorted(heights)[len(heights) // 2]
y_tol = max(8, median_h * y_tolerance_ratio)
sorted_items = sorted(items, key=lambda it: it['cy'])
rows: list[list[dict]] = []
for it in sorted_items:
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
rows[-1].append(it)
else:
rows.append([it])
for row in rows:
row.sort(key=lambda it: it['cx'])
return rows
def _match_label(text: str) -> str | None:
key = _norm_label(text)
if not key:
return None
# 정확 일치 우선
if key in LABEL_INDEX:
return LABEL_INDEX[key]
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
for cand_key, field in LABEL_INDEX.items():
if len(cand_key) >= 2 and key.startswith(cand_key):
return field
return None
def parse_card(items: list[dict]) -> dict[str, Any]:
"""OCR 결과 목록을 필드 dict로 변환."""
rows = group_rows(items)
result: dict[str, Any] = {}
# 1) 행 내 "레이블 → 값" 쌍 추출
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
for row in rows:
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
idx = 0
while idx < len(row):
field = _match_label(row[idx]['text'])
if field:
# 다음 non-label 조각을 값으로 취함
value_parts: list[str] = []
j = idx + 1
while j < len(row):
nxt = row[j]
if _match_label(nxt['text']):
break
value_parts.append(nxt['text'])
j += 1
if value_parts and field not in result:
value = ' '.join(value_parts).strip()
if value and value not in ('-', '', 'N/A'):
result[field] = value
idx = j
else:
idx += 1
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
nfpa_idx_row: int | None = None
for ri, row in enumerate(rows):
for cell in row:
if re.search(r'NFPA', cell['text']):
nfpa_idx_row = ri
break
if nfpa_idx_row is not None:
break
if nfpa_idx_row is not None:
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
candidates: list[int] = []
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
for cell in rows[ri]:
m = NFPA_VALUE_RE.match(cell['text'].strip())
if m:
candidates.append(int(cell['text'].strip()))
if len(candidates) >= 3:
break
if len(candidates) >= 3:
break
if len(candidates) >= 3:
result['nfpa'] = {
'health': candidates[0],
'fire': candidates[1],
'reactivity': candidates[2],
'special': '',
}
# 3) EmS 코드 (F-x / S-x 패턴)
all_text = ' '.join(cell['text'] for row in rows for cell in row)
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
if f_match or s_match:
parts = []
if f_match:
parts.append(f'F-{f_match.group(1)}')
if s_match:
parts.append(f'S-{s_match.group(1)}')
if parts:
result['emsCode'] = ', '.join(parts)
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
if erg_match:
result['ergNumber'] = erg_match.group(1)
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
return result
def _preprocess_image(pil_img, upscale: float = 2.5):
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
from PIL import Image, ImageEnhance, ImageFilter
import numpy as np
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
# 1) 업스케일 (LANCZOS)
w, h = pil_img.size
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
# 2) 대비 향상
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
# 3) 샤프닝
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
return np.array(pil_img)
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
from PIL import Image
with Image.open(image_path) as pil:
img = _preprocess_image(pil, upscale=upscale)
raw = reader.readtext(img, detail=1, paragraph=False)
items: list[dict] = []
for bbox, text, conf in raw:
if not text or not str(text).strip():
continue
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
items.append({
'text': str(text).strip(),
'cx': sum(xs) / 4.0,
'cy': sum(ys) / 4.0,
'x0': min(xs),
'x1': max(xs),
'y0': min(ys),
'y1': max(ys),
'conf': float(conf),
})
return items
def load_json(path: Path, fallback):
if not path.exists():
return fallback
try:
return json.loads(path.read_text(encoding='utf-8'))
except Exception:
return fallback
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('--limit', type=int, default=None)
parser.add_argument('--only', type=str, default=None,
help='파이프(|)로 구분된 물질명 리스트')
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
parser.add_argument('--debug', action='store_true',
help='파싱 중간 결과(row 단위) 함께 출력')
args = parser.parse_args()
import easyocr # noqa: WPS433
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
print('[로딩] 완료')
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
if args.only:
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
images = [p for p in images if p.stem in only_set]
existing: dict[str, Any] = load_json(args.out, {})
failures: dict[str, str] = load_json(args.fail, {})
pending = [p for p in images if p.stem not in existing]
if args.limit:
pending = pending[: args.limit]
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}')
ok = 0
fail = 0
for i, path in enumerate(pending, start=1):
name = path.stem
try:
items = run_ocr(path, reader)
parsed = parse_card(items)
if args.debug:
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
for row in group_rows(items):
print(' |', ''.join(f'{c["text"]}' for c in row))
print(f' → parsed: {parsed}')
existing[name] = parsed
if name in failures:
del failures[name]
ok += 1
except Exception as e: # noqa: BLE001
failures[name] = f'{type(e).__name__}: {e}'[:500]
fail += 1
print(f'[실패] {name}: {e}')
if i % 10 == 0 or i == len(pending):
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
print(f' 결과: {args.out}')
if failures:
print(f' 실패 목록: {args.fail}')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,29 @@
# extract-excel.py 용
openpyxl>=3.1.0
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
easyocr==1.7.2
filelock==3.19.1
fsspec==2025.10.0
ImageIO==2.37.2
Jinja2==3.1.6
lazy-loader==0.5
MarkupSafe==3.0.3
mpmath==1.3.0
networkx==3.2.1
ninja==1.13.0
numpy==2.0.2
opencv-python-headless==4.13.0.92
packaging==26.1
pillow==11.3.0
pyclipper==1.3.0.post6
python-bidi==0.6.7
PyYAML==6.0.3
scikit-image==0.24.0
scipy==1.13.1
shapely==2.0.7
sympy==1.14.0
tifffile==2024.8.30
torch==2.8.0
torchvision==0.23.0
typing_extensions==4.15.0

파일 보기

@ -24,8 +24,14 @@ async function seedHnsSubstances() {
let inserted = 0
// varchar 길이 제한에 맞춰 첫 번째 토큰만 검색 컬럼에 저장 (원본은 DATA JSONB에 보존)
const firstToken = (v: unknown, max: number): string | null => {
if (v == null) return null
const s = String(v).split(/[\n,;/]/)[0].trim()
return s ? s.slice(0, max) : null
}
for (const s of HNS_SEARCH_DB) {
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
await client.query(
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
CAS_NO = EXCLUDED.CAS_NO,
SEBC = EXCLUDED.SEBC,
DATA = EXCLUDED.DATA`,
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
[
s.id,
firstToken(abbreviation, 50),
firstToken(nameKr, 200) ?? '',
firstToken(nameEn, 200),
firstToken(unNumber, 10),
firstToken(casNumber, 20),
firstToken(sebc, 50),
JSON.stringify(detailData),
]
)
inserted++

파일 보기

@ -6,8 +6,14 @@
## [2026-04-17]
### 문서
- CLAUDE.md 절대 지침 추가 (develop 최신화, 디자인 시스템 준수)
### 추가
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
### 변경
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
### 수정
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
## [2026-04-16]

파일 보기

@ -31,25 +31,25 @@
@layer base {
:root {
/* bg — Background */
--bg-base: #0a0e1a;
--bg-surface: #0f1524;
--bg-elevated: #121929;
--bg-card: #1a2236;
--bg-surface-hover: #1e2844;
--bg-base: #121418;
--bg-surface: #1B1E23;
--bg-elevated: #24272D;
--bg-card: #24272D;
--bg-surface-hover: #3A3F49;
/* stroke — Border */
--stroke-default: #1e2a42;
--stroke-light: #2a3a5c;
--stroke-default: #24272D;
--stroke-light: #1B1E23;
/* fg — Foreground */
--fg-default: #edf0f7;
--fg-sub: #c0c8dc;
--fg-disabled: #9ba3b8;
--fg-default: #F8F9FC;
--fg-sub: #B9C1C9;
--fg-disabled: #808892;
/* color — Palette */
--color-info: #3b82f6;
--color-accent: #06b6d4;
--color-accent-muted: #0e7490;
--color-danger: #ef4444;
--color-info: #0099DD;
--color-accent: #0099DD;
--color-accent-muted: #007AB1;
--color-danger: #D61111;
--color-warning: #f97316;
--color-caution: #eab308;
--color-caution: #FEDA4A;
--color-success: #22c55e;
--color-tertiary: #a855f7;
--color-boom: #f59e0b;
@ -111,29 +111,34 @@
--static-black: #131415;
--static-white: #ffffff;
/* Gray */
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-400: #94a3b8;
--gray-500: #64748b;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
--gray-1000: #020617;
/* Gray (Definition cool-tone, 15 steps) */
--gray-0: #FFFFFF;
--gray-50: #F8F9FC;
--gray-100: #F3F6FB;
--gray-200: #EBEFF5;
--gray-250: #E1E6EC;
--gray-300: #D6DBE1;
--gray-400: #B9C1C9;
--gray-500: #808892;
--gray-550: #6C747E;
--gray-600: #565B64;
--gray-700: #3A3F49;
--gray-800: #24272D;
--gray-850: #1B1E23;
--gray-900: #121418;
--gray-1000: #000000;
/* Blue */
--blue-100: #dbeafe;
--blue-200: #bfdbfe;
--blue-300: #93c5fd;
--blue-400: #60a5fa;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--blue-800: #1e40af;
--blue-900: #1e3a8a;
--blue-1000: #172554;
/* Blue (Primary Blue-Cyan, hue ~200°) */
--blue-100: #E6F4FB;
--blue-200: #B3E0F5;
--blue-300: #80CCEE;
--blue-400: #4DB8E8;
--blue-500: #0099DD;
--blue-600: #007AB1;
--blue-700: #005C85;
--blue-800: #003D59;
--blue-900: #001F2D;
--blue-1000: #001520;
/* Green */
--green-100: #dcfce7;
@ -152,7 +157,7 @@
--yellow-200: #fef08a;
--yellow-300: #fde047;
--yellow-400: #facc15;
--yellow-500: #eab308;
--yellow-500: #FEDA4A;
--yellow-600: #ca8a04;
--yellow-700: #a16207;
--yellow-800: #854d0e;
@ -160,12 +165,12 @@
--yellow-1000: #422006;
/* Red */
--red-100: #fee2e2;
--red-100: #7A2D2D;
--red-200: #fecaca;
--red-300: #fca5a5;
--red-400: #f87171;
--red-500: #ef4444;
--red-600: #dc2626;
--red-500: #DE4141;
--red-600: #D61111;
--red-700: #b91c1c;
--red-800: #991b1b;
--red-900: #7f1d1d;
@ -189,19 +194,19 @@
/* ── Light theme overrides ── */
[data-theme='light'] {
--bg-base: #f8fafc;
--bg-surface: #ffffff;
--bg-elevated: #f1f5f9;
--bg-card: #ffffff;
--bg-surface-hover: #e2e8f0;
--stroke-default: #cbd5e1;
--stroke-light: #e2e8f0;
--fg-default: #0f172a;
--fg-sub: #475569;
--fg-disabled: #94a3b8;
--bg-base: #FFFFFF;
--bg-surface: #FFFFFF;
--bg-elevated: #F3F6FB;
--bg-card: #FFFFFF;
--bg-surface-hover: #EBEFF5;
--stroke-default: #B9C1C9;
--stroke-light: #E1E6EC;
--fg-default: #121418;
--fg-sub: #24272D;
--fg-disabled: #808892;
--hover-overlay: rgba(0, 0, 0, 0.04);
--dropdown-bg: rgba(255, 255, 255, 0.97);
--color-accent-muted: #0891b2;
--color-accent-muted: #007AB1;
--color-navy: #1d4ed8;
--color-navy-hover: #2563eb;
}

파일 보기

@ -217,8 +217,6 @@ export function generateAIBoomLines(
const totalDist = haversineDistance(incident, centroid);
// 입자 분산 폭 계산 (최종 시간 기준)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perpBearing = (mainBearing + 90) % 360;
let maxSpread = 0;
for (const p of finalPoints) {
const bearing = computeBearing(incident, p);

파일 보기

@ -31,45 +31,6 @@ export function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
/**
* HTML
* /
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ALLOWED_TAGS = new Set([
'b',
'i',
'u',
'strong',
'em',
'br',
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'sup',
'sub',
'hr',
'blockquote',
'pre',
'code',
]);
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;

파일 보기

@ -2,6 +2,11 @@ import { useState, useEffect, useCallback } from 'react';
import {
fetchRoles,
fetchPermTree,
updatePermissionsApi,
createRoleApi,
deleteRoleApi,
updateRoleApi,
updateRoleDefaultApi,
type RoleWithPermissions,
type PermTreeNode,
} from '@common/services/authApi';

파일 보기

@ -89,7 +89,7 @@ export function OilAreaAnalysis() {
processedFilesRef.current.add(file);
exifr
.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false } as unknown as Parameters<typeof exifr.parse>[1])
.then((exif) => {
const info: ImageExif = {
lat: exif?.latitude ?? null,

파일 보기

@ -300,7 +300,7 @@ export function AoiPanel() {
getLineWidth: (d: MonitorZone) => (selectedZone === d.id ? 3 : 1.5),
lineWidthUnits: 'pixels',
pickable: true,
onClick: ({ object }: { object: MonitorZone }) => {
onClick: ({ object }: { object?: MonitorZone }) => {
if (object && !isDrawing)
setSelectedZone(object.id === selectedZone ? null : object.id);
},

파일 보기

@ -238,7 +238,7 @@ export function DetectPanel() {
lineWidthMinPixels: 2,
stroked: true,
pickable: true,
onClick: ({ object }: { object: VesselDetection }) => {
onClick: ({ object }: { object?: VesselDetection }) => {
if (object) setSelectedId(object.id === selectedId ? null : object.id);
},
updateTriggers: { getRadius: [selectedId] },

파일 보기

@ -247,17 +247,17 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{mapTypes.map((item) => (
<button
key={item.mapKey}
onClick={() => toggleMap(item.mapKey)}
onClick={() => toggleMap(item.mapKey as keyof typeof mapToggles)}
className="w-full px-3 py-2 flex items-center justify-between text-title-5 text-fg-sub hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="flex items-center gap-2.5">
<span className="text-title-4">🗺</span> {item.mapNm}
</span>
<div
className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey as keyof typeof mapToggles] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
>
<div
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`}
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey as keyof typeof mapToggles] ? 'left-[16px]' : 'left-[2px]'}`}
/>
</div>
</button>

파일 보기

@ -273,12 +273,11 @@ export function BaseMap({
<Map
initialViewState={{ longitude: center[1], latitude: center[0], zoom }}
mapStyle={mapStyle}
className="w-full h-full"
onClick={handleClick}
onZoom={handleZoom}
style={{ cursor: cursor ?? 'grab' }}
style={{ cursor: cursor ?? 'grab', width: '100%', height: '100%' }}
attributionControl={false}
preserveDrawingBuffer={true}
{...({ preserveDrawingBuffer: true } as Record<string, unknown>)}
>
{/* 공통 오버레이 */}
<S57EncOverlay visible={mapToggles.s57 ?? false} />

파일 보기

@ -15,11 +15,12 @@ const PI_4 = Math.PI / 4;
const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리)
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
const { current: map } = useMap();
const animRef = useRef<number>();
const { current: mapRef } = useMap();
const animRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!map || !hydrStep) return;
if (!mapRef || !hydrStep) return;
const map = mapRef;
const container = map.getContainer();
const canvas = document.createElement('canvas');
@ -212,7 +213,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
map.off('move', onMove);
canvas.remove();
};
}, [map, hydrStep]);
}, [mapRef, hydrStep]);
return null;
}

파일 보기

@ -17,7 +17,7 @@ import type { SensitiveResource } from '@interfaces/prediction/PredictionInterfa
import type {
HydrDataStep,
SensitiveResourceFeatureCollection,
} from '@components/prediction/services/predictionApi';
} from '@interfaces/prediction/PredictionInterface';
import HydrParticleOverlay from './HydrParticleOverlay';
import { TimelineControl } from './TimelineControl';
import type { BoomLine, BoomLineCoord } from '@/types/boomLine';
@ -1331,8 +1331,9 @@ export function MapView({
zoom: zoom,
}}
mapStyle={currentMapStyle}
className="w-full h-full"
style={{
width: '100%',
height: '100%',
cursor:
isSelectingLocation || drawAnalysisMode !== null || measureMode !== null
? 'crosshair'
@ -1340,7 +1341,7 @@ export function MapView({
}}
onClick={handleMapClick}
attributionControl={false}
preserveDrawingBuffer={true}
{...({ preserveDrawingBuffer: true } as Record<string, unknown>)}
>
{/* 지도 캡처 셋업 */}
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}

파일 보기

@ -315,12 +315,6 @@ export function HNSSubstanceView() {
const [activeTab, setActiveTab] = useState(0);
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchName, setDetailSearchName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchCas, setDetailSearchCas] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류');
/* Panel 3: 물질 상세검색 state */
const [hmsSearchType, setHmsSearchType] = useState<
'all' | 'abbr' | 'korName' | 'engName' | 'cas' | 'un'
@ -439,18 +433,6 @@ ${styles}
return matchCategory && matchSearch;
});
/* Detail search filter for Panel 3 (legacy) */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const detailFiltered = substances.filter((s) => {
const qName = detailSearchName.toLowerCase();
const qCas = detailSearchCas.toLowerCase();
const matchName =
!qName || s.name.toLowerCase().includes(qName) || s.nameEn.toLowerCase().includes(qName);
const matchCas = !qCas || s.casNumber.includes(qCas);
const matchSebc =
detailSearchSebc === '전체 거동분류' || s.sebc.includes(detailSearchSebc.split(' ')[0]);
return matchName && matchCas && matchSebc;
});
/* Panel 3: HNS API 기반 검색 결과 */
const HMS_PER_PAGE = 10;
@ -1825,4 +1807,3 @@ ${styles}
</div>
);
}

파일 보기

@ -93,7 +93,7 @@ export function HNSView() {
const [inputParams, setInputParams] = useState<HNSInputParams | null>(null);
const [loadedParams, setLoadedParams] = useState<Partial<HNSInputParams> | null>(null);
const hasRunOnce = useRef(false); // 최초 실행 여부
const mapCaptureRef = useRef<(() => string | null) | null>(null);
const mapCaptureRef = useRef<(() => Promise<string | null>) | null>(null);
const handleReset = useCallback(() => {
setDispersionResult(null);
@ -376,7 +376,7 @@ export function HNSView() {
/** 분석 결과 저장 */
const handleSave = async () => {
if (!dispersionResult || !inputParams || !computedResult) {
if (!dispersionResult || !inputParams || !computedResult || !incidentCoord) {
alert('저장할 분석 결과가 없습니다. 먼저 예측을 실행해주세요.');
return;
}
@ -672,11 +672,11 @@ export function HNSView() {
};
/** 보고서 생성 — 실 데이터 수집 + 지도 캡처 후 탭 이동 */
const handleOpenReport = () => {
const handleOpenReport = async () => {
try {
let mapImage: string | null = null;
try {
mapImage = mapCaptureRef.current?.() ?? null;
mapImage = (await mapCaptureRef.current?.()) ?? null;
} catch {
/* canvas capture 실패 무시 */
}

파일 보기

@ -19,7 +19,9 @@ export function HmsDetailPanel({
];
const nfpa = s.nfpa;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sebcColor = s.sebc.startsWith('G')
const sebcColor = !s.sebc
? 'var(--fg-sub)'
: s.sebc.startsWith('G')
? 'var(--color-accent)'
: s.sebc.startsWith('E')
? 'var(--color-accent)'

파일 보기

@ -1,10 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { fetchPredictionAnalyses } from '@tabs/prediction/services/predictionApi';
import type { PredictionAnalysis } from '@tabs/prediction/services/predictionApi';
import { fetchHnsAnalyses } from '@tabs/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi';
import { fetchRescueOps } from '@tabs/rescue/services/rescueApi';
import type { RescueOpsItem } from '@tabs/rescue/services/rescueApi';
import { fetchPredictionAnalyses } from '@components/prediction/services/predictionApi';
import type { PredictionAnalysis } from '@components/prediction/services/predictionApi';
import { fetchHnsAnalyses } from '@components/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import { fetchRescueOps } from '@components/rescue/services/rescueApi';
import type { RescueOpsItem } from '@interfaces/rescue/RescueInterface';
// ── 타입 정의 ──────────────────────────────────────────
export type AnalysisModalType = 'oil' | 'hns' | 'rescue';

파일 보기

@ -70,54 +70,6 @@ interface AnalysisItem {
checked: boolean;
}
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반, 미사용 보존) ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const CATEGORY_ICON: Record<string, string> = {
: '🐟',
: '🦪',
: '🦪',
: '🐟',
: '🦪',
: '🌿',
: '🔲',
: '🦐',
: '📦',
: '🎣',
: '🎣',
: '🐟',
: '🪸',
: '🪨',
: '🚢',
: '🏖',
: '🪨',
: '🚤',
: '⛵',
: '🚢',
: '⛵',
: '⚓',
: '⚓',
: '⚓',
: '⚓',
: '🚢',
: '⛵',
: '🔴',
: '💧',
'취수구·배수구': '🚰',
LNG: '⚡',
: '🔌',
'발전소·산단': '🏭',
: '🏭',
: '🛢',
'해저케이블·배관': '🔌',
: '🪨',
_ESI: '🏖',
: '🛡',
: '🌿',
: '🐦',
: '🏖',
: '🐢',
'보호종 서식지': '🐢',
};
/* ── 헬퍼: 활성 모델 문자열 ─────────────────────── */
function getActiveModels(p: PredictionAnalysis): string {

파일 보기

@ -14,11 +14,14 @@ import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/v
import { IncidentsLeftPanel } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface';
import type { Incident, IncidentCompat } from '@interfaces/incidents/IncidentsInterface';
import { fetchHnsAnalyses } from '@components/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import { buildHnsDispersionLayers } from '../utils/hnsDispersionLayers';
import { fetchAnalysisTrajectory, fetchOilSpillSummary } from '@components/prediction/services/predictionApi';
import {
fetchAnalysisTrajectory,
fetchOilSpillSummary,
} from '@components/prediction/services/predictionApi';
import type {
TrajectoryResponse,
SensitiveResourceFeatureCollection,
@ -39,7 +42,6 @@ import {
} from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore';
import { FlyToController } from './contents/FlyToController';
import { SplitPanelContent } from './contents/SplitPanelContent';
import { VesselPopupPanel } from './contents/VesselPopupPanel';
import { IncidentPopupContent } from './contents/IncidentPopupContent';
import { VesselDetailModal } from './contents/VesselDetailModal';
@ -513,8 +515,8 @@ export function IncidentsView() {
layers.push(
new PathLayer({
id: `traj-path-${pathId}`,
data: [{ path: sorted.map((p) => [p.lon, p.lat]) }],
getPath: (d: { path: number[][] }) => d.path,
data: [{ path: sorted.map((p) => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...color, 230] as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
@ -573,7 +575,8 @@ export function IncidentsView() {
if (filtered.features.length === 0) return null;
return new GeoJsonLayer({
id: 'incidents-sensitive-geojson',
data: filtered,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: filtered as any,
pickable: false,
stroked: true,
filled: true,
@ -1623,8 +1626,7 @@ function SplitResultMap({
}
/* ── (미사용) 분석별 SVG placeholder — 참고용 보존 ────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) {
export function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) {
if (slotKey === 'oil') {
return (
<svg viewBox="0 0 320 200" className="w-full h-full">

파일 보기

@ -5,7 +5,8 @@ import {
fetchIncidentAerialMedia,
getMediaImageUrl,
} from '../services/incidentsApi';
import type { MediaInfo, AerialMediaItem } from '@interfaces/incidents/IncidentsInterface';
import type { MediaInfo } from '@interfaces/incidents/IncidentsInterface';
import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface';
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv';

파일 보기

@ -5,18 +5,20 @@
* - MapView와 BitmapLayer ( ) + ScatterplotLayer (AEGL )
*/
import { BitmapLayer, ScatterplotLayer } from '@deck.gl/layers';
import { computeDispersion } from '@tabs/hns/utils/dispersionEngine';
import { getSubstanceToxicity } from '@tabs/hns/utils/toxicityData';
import { hexToRgba } from '@common/components/map/mapUtils';
import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi';
import { computeDispersion } from '@components/hns/utils/dispersionEngine';
import { getSubstanceToxicity } from '@components/hns/utils/toxicityData';
import { hexToRgba } from '@components/common/map/mapUtils';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import type {
MeteoParams,
SourceParams,
SimParams,
} from '@interfaces/hns/HnsInterface';
import type {
DispersionModel,
AlgorithmType,
StabilityClass,
} from '@tabs/hns/utils/dispersionTypes';
} from '@/types/hns/HnsType';
// MapView와 동일한 색상 정지점
const COLOR_STOPS: [number, number, number, number][] = [

파일 보기

@ -776,6 +776,8 @@ export function OilSpillView() {
analyst: '',
officeName: '',
acdntSttsCd: 'ACTIVE',
predRunSn: null,
runDtm: null,
});
}, []);

파일 보기

@ -524,7 +524,7 @@ export function KospsPanel() {
{/* Akima 수심 보간 & NGSST 수온 */}
<div className="grid grid-cols-2 gap-2.5 mb-3">
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke">
<div className="rounded-lg p-3 bg-bg-card border border-stroke">
<div className="text-label-2 font-bold mb-2 text-color-accent">
🗺 Akima
</div>
@ -539,7 +539,7 @@ export function KospsPanel() {
<span className="text-label-2 text-fg-default">(i5, i+j5)</span>
</div>
</div>
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke">
<div className="rounded-lg p-3 bg-bg-card border border-stroke">
<div className="text-label-2 font-bold mb-2 text-color-accent">
🌡 NGSST
</div>

파일 보기

@ -4,7 +4,7 @@ export function RoadmapPanel() {
return (
<div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className={`${card} ${cardBg}`} className="m-0">
<div className={`${card} ${cardBg} m-0`}>
<div style={labelStyle('var(--color-info)')}> </div>
<div className="flex flex-col gap-2 text-label-2 text-fg-sub">
<div
@ -42,7 +42,7 @@ export function RoadmapPanel() {
</div>
</div>
</div>
<div className={`${card} ${cardBg}`} className="m-0">
<div className={`${card} ${cardBg} m-0`}>
<div style={labelStyle('var(--color-info)')}> </div>
<div className="flex flex-col gap-2 text-label-2 text-fg-sub">
{[

파일 보기

@ -5,12 +5,28 @@ import type {
BacktrackResult,
TrajectoryResponse,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
SpreadParticlesGeojson,
HydrDataStep,
OilSpillSummaryResponse,
ImageAnalyzeResult,
GscAccidentListItem,
} from '@interfaces/prediction/PredictionInterface';
export type {
PredictionAnalysis,
PredictionDetail,
BacktrackResult,
TrajectoryResponse,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
SpreadParticlesGeojson,
HydrDataStep,
OilSpillSummaryResponse,
};
export const fetchPredictionAnalyses = async (params?: {
search?: string;
acdntSn?: number;
@ -122,3 +138,18 @@ export const fetchGscAccidents = async (): Promise<GscAccidentListItem[]> => {
const response = await api.get<GscAccidentListItem[]>('/gsc/accidents');
return response.data;
};
// ============================================================
// 유류 확산 요약
// ============================================================
export const fetchOilSpillSummary = async (
acdntSn: number,
predRunSn?: number,
): Promise<OilSpillSummaryResponse> => {
const response = await api.get<OilSpillSummaryResponse>(
`/prediction/analyses/${acdntSn}/oil-summary`,
predRunSn != null ? { params: { predRunSn } } : undefined,
);
return response.data;
};

파일 보기

@ -27,7 +27,7 @@ export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportDa
author: '',
reportType: '예측보고서',
analysisCategory: '',
jurisdiction: jurisdiction || '',
jurisdiction: jurisdiction ?? '남해청',
status: '수행중',
incident: {
name: '',

파일 보기

@ -1,9 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
OilSpillReportTemplate,
type OilSpillReportData,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { OilSpillReportTemplate } from './OilSpillReportTemplate';
import type { OilSpillReportData } from '@interfaces/reports/ReportsInterface';
import type { Jurisdiction } from '@/types/reports/ReportsType';
import { loadReportsFromApi, loadReportDetail, deleteReportApi } from '../services/reportsApi';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { templateTypes } from './reportTypes';

파일 보기

@ -230,7 +230,7 @@ export function SensitiveResourceMapSection({
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
} as maplibregl.MapOptions);
mapRef.current = map;
map.on('load', () => {
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)

파일 보기

@ -62,7 +62,7 @@ export function SensitivityMapSection({
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
} as maplibregl.MapOptions);
mapRef.current = map;
map.on('load', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -104,7 +104,7 @@ export function SensitivityMapSection({
// fitBounds
const coords: [number, number][] = [];
displayGeojson.features.forEach((f) => {
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry;
const geom = (f as unknown as { geometry: { type: string; coordinates: unknown } }).geometry;
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number]);
else if (geom.type === 'Polygon')
(geom.coordinates as [number, number][][])[0]?.forEach((c) => coords.push(c));

파일 보기

@ -4,6 +4,7 @@ import type {
RescueOpsItem,
RescueScenarioItem,
RescueScenario,
ChartDataItem,
} from '@interfaces/rescue/RescueInterface';
import type { Severity } from '@/types/rescue/RescueType';
import { ScenarioMapOverlay } from './contents/ScenarioMapOverlay';

파일 보기

@ -9,6 +9,7 @@ export async function fetchRescueOps(params?: {
sttsCd?: string;
acdntTpCd?: string;
search?: string;
acdntSn?: number;
}): Promise<RescueOpsItem[]> {
const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params });
return response.data;

파일 보기

@ -34,9 +34,6 @@ export function PreScatView() {
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timelineIdx, setTimelineIdx] = useState(6);
// 초기 관할청 목록 로딩
useEffect(() => {
let cancelled = false;

파일 보기

@ -113,8 +113,6 @@ function SegRow(
function ScatLeftPanel({
segments,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
zones,
jurisdictions,
offices,
selectedOffice,
@ -125,8 +123,6 @@ function ScatLeftPanel({
jurisdictionFilter,
onJurisdictionChange,
areaFilter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAreaChange,
phaseFilter,
onPhaseChange,
statusFilter,

파일 보기

@ -24,6 +24,7 @@ export interface EnrichedWeatherStation extends WeatherStation {
};
pressure: number;
visibility: number;
salinity?: number;
}
/**

파일 보기

@ -45,7 +45,7 @@ export async function getUltraShortForecast(
// 데이터를 시간대별로 그룹화
const forecasts: WeatherForecastData[] = [];
const grouped = new Map<string, Record<string, unknown>>();
const grouped = new Map<string, WeatherForecastData>();
items.forEach((item: Record<string, string>) => {
const key = `${item.fcstDate}-${item.fcstTime}`;
@ -61,10 +61,11 @@ export async function getUltraShortForecast(
waveHeight: 0,
precipitation: 0,
humidity: 0,
});
} as WeatherForecastData);
}
const forecast = grouped.get(key);
if (!forecast) return;
// 카테고리별 값 매핑
switch (item.category) {

파일 보기

@ -207,19 +207,3 @@ export function windDirectionToText(degree: number): string {
const index = Math.round((degree % 360) / 22.5) % 16;
return directions[index];
}
// 해상 기상 정보 (Mock - 실제로는 해양기상청 API 사용)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function getMarineWeather(lat: number, lng: number) {
// TODO: 해양기상청 API 연동
// 현재는 Mock 데이터 반환
return {
waveHeight: 1.2, // 파고 (m)
waveDirection: 135, // 파향 (도)
wavePeriod: 5.5, // 주기 (초)
seaTemperature: 12.5, // 수온 (°C)
currentSpeed: 0.3, // 해류속도 (m/s)
currentDirection: 180, // 해류방향 (도)
visibility: 15, // 시정 (km)
};
}

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

파일 보기

@ -202,7 +202,7 @@ export interface HNSInputParams {
/** HNS 분석 — 재계산 모달 입력 파라미터 */
export interface RecalcParams {
substance: string;
releaseType: '연속 유출' | '순간 유출' | '밀도가스 유출';
releaseType: ReleaseType;
emissionRate: number;
totalRelease: number;
algorithm: string;
@ -282,6 +282,8 @@ export interface HNSSearchSubstance {
hazardClass: string;
ergNumber: string;
idlh: string;
twa: string;
stel: string;
aegl2: string;
erpg2: string;
responseDistanceFire: string;

파일 보기

@ -29,6 +29,8 @@ export interface IncidentListItem {
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}

파일 보기

@ -216,29 +216,6 @@ export interface OilSpillSummaryResponse {
byModel: Record<string, OilSpillSummary>;
}
export const fetchAnalysisTrajectory = async (
acdntSn: number,
predRunSn?: number,
): Promise<TrajectoryResponse> => {
const response = await api.get<TrajectoryResponse>(
`/prediction/analyses/${acdntSn}/trajectory`,
predRunSn != null ? { params: { predRunSn } } : undefined,
);
return response.data;
};
export const fetchOilSpillSummary = async (
acdntSn: number,
predRunSn?: number,
): Promise<OilSpillSummaryResponse> => {
const response = await api.get<OilSpillSummaryResponse>(
`/prediction/analyses/${acdntSn}/oil-summary`,
predRunSn != null ? { params: { predRunSn } } : undefined,
);
return response.data;
};
export interface SensitiveResourceCategory {
category: string;
count: number;

파일 보기

@ -10,23 +10,6 @@ interface ColorStep {
color: string;
}
interface Marker {
step: number;
label: string;
}
interface ContrastRating {
step: number;
rating: string;
}
interface ColorScaleBarProps {
steps: ColorStep[];
markers?: Marker[];
contrastRatings?: ContrastRating[];
darkBg?: boolean;
isDark: boolean;
}
interface ColorToken {
name: string;
@ -125,7 +108,7 @@ interface SemanticColor {
const SEMANTIC_COLORS: SemanticColor[] = [
{ name: 'Primary 500', hex: '#0099DD', usages: ['긍정의 의미', '주요 액션 버튼', '링크, 선택 상태'] },
{ name: 'Red 600', hex: '#D61111', usages: ['부정 컬러로 사용', '공지 배지의 Text로 사용'] },
{ name: 'Red 100', hex: '#FBD8DC', usages: ['공지 배지의 bg 컬러'] },
{ name: 'Red 100', hex: '#7A2D2D', usages: ['공지 배지의 bg 컬러'] },
{ name: 'Yellow 500', hex: '#FEDA4A', usages: ['즐겨찾기/북마크 의미로 사용'] },
];
@ -161,31 +144,36 @@ const COLOR_TOKEN_GROUPS: ColorTokenGroup[] = [
{
title: 'Gray',
tokens: [
{ name: 'gray.100', hex: '#f1f5f9' },
{ name: 'gray.200', hex: '#e2e8f0' },
{ name: 'gray.300', hex: '#cbd5e1' },
{ name: 'gray.400', hex: '#94a3b8' },
{ name: 'gray.500', hex: '#64748b' },
{ name: 'gray.600', hex: '#475569' },
{ name: 'gray.700', hex: '#334155' },
{ name: 'gray.800', hex: '#1e293b' },
{ name: 'gray.900', hex: '#0f172a' },
{ name: 'gray.1000', hex: '#020617' },
{ name: 'gray.0', hex: '#FFFFFF' },
{ name: 'gray.50', hex: '#F8F9FC' },
{ name: 'gray.100', hex: '#F3F6FB' },
{ name: 'gray.200', hex: '#EBEFF5' },
{ name: 'gray.250', hex: '#E1E6EC' },
{ name: 'gray.300', hex: '#D6DBE1' },
{ name: 'gray.400', hex: '#B9C1C9' },
{ name: 'gray.500', hex: '#808892' },
{ name: 'gray.550', hex: '#6C747E' },
{ name: 'gray.600', hex: '#565B64' },
{ name: 'gray.700', hex: '#3A3F49' },
{ name: 'gray.800', hex: '#24272D' },
{ name: 'gray.850', hex: '#1B1E23' },
{ name: 'gray.900', hex: '#121418' },
{ name: 'gray.1000', hex: '#000000' },
],
},
{
title: 'Blue',
title: 'Blue (Primary)',
tokens: [
{ name: 'blue.100', hex: '#dbeafe' },
{ name: 'blue.200', hex: '#bfdbfe' },
{ name: 'blue.300', hex: '#93c5fd' },
{ name: 'blue.400', hex: '#60a5fa' },
{ name: 'blue.500', hex: '#3b82f6' },
{ name: 'blue.600', hex: '#2563eb' },
{ name: 'blue.700', hex: '#1d4ed8' },
{ name: 'blue.800', hex: '#1e40af' },
{ name: 'blue.900', hex: '#1e3a8a' },
{ name: 'blue.1000', hex: '#172554' },
{ name: 'blue.100', hex: '#E6F4FB' },
{ name: 'blue.200', hex: '#B3E0F5' },
{ name: 'blue.300', hex: '#80CCEE' },
{ name: 'blue.400', hex: '#4DB8E8' },
{ name: 'blue.500', hex: '#0099DD' },
{ name: 'blue.600', hex: '#007AB1' },
{ name: 'blue.700', hex: '#005C85' },
{ name: 'blue.800', hex: '#003D59' },
{ name: 'blue.900', hex: '#001F2D' },
{ name: 'blue.1000', hex: '#001520' },
],
},
{
@ -210,7 +198,7 @@ const COLOR_TOKEN_GROUPS: ColorTokenGroup[] = [
{ name: 'yellow.200', hex: '#fef08a' },
{ name: 'yellow.300', hex: '#fde047' },
{ name: 'yellow.400', hex: '#facc15' },
{ name: 'yellow.500', hex: '#eab308' },
{ name: 'yellow.500', hex: '#FEDA4A' },
{ name: 'yellow.600', hex: '#ca8a04' },
{ name: 'yellow.700', hex: '#a16207' },
{ name: 'yellow.800', hex: '#854d0e' },
@ -221,12 +209,12 @@ const COLOR_TOKEN_GROUPS: ColorTokenGroup[] = [
{
title: 'Red',
tokens: [
{ name: 'red.100', hex: '#fee2e2' },
{ name: 'red.100', hex: '#7A2D2D' },
{ name: 'red.200', hex: '#fecaca' },
{ name: 'red.300', hex: '#fca5a5' },
{ name: 'red.400', hex: '#f87171' },
{ name: 'red.500', hex: '#ef4444' },
{ name: 'red.600', hex: '#dc2626' },
{ name: 'red.500', hex: '#DE4141' },
{ name: 'red.600', hex: '#D61111' },
{ name: 'red.700', hex: '#b91c1c' },
{ name: 'red.800', hex: '#991b1b' },
{ name: 'red.900', hex: '#7f1d1d' },
@ -448,117 +436,6 @@ const ChipRow = ({ hex, role, gray, d, subdued = false }: ChipRowProps) => (
</div>
);
// ---------- 내부 컴포넌트: ColorScaleBar ----------
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ColorScaleBar = ({
steps,
markers,
contrastRatings,
darkBg = false,
isDark,
}: ColorScaleBarProps) => {
const badgeBg = isDark ? '#374151' : '#374151';
const badgeText = isDark ? '#e5e7eb' : '#fff';
const getContrastRating = (step: number): string | undefined => {
return contrastRatings?.find((r) => r.step === step)?.rating;
};
const getMarker = (step: number): Marker | undefined => {
return markers?.find((m) => m.step === step);
};
return (
<div>
{/* 색상 바 */}
<div
className="flex rounded-lg overflow-hidden"
style={{ border: `1px solid ${darkBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}` }}
>
{steps.map(({ step, color }, idx) => {
const isFirst = idx === 0;
const isLast = idx === steps.length - 1;
const textColor = step < 50 ? (darkBg ? '#e2e8f0' : '#1e293b') : '#e2e8f0';
const rating = getContrastRating(step);
return (
<div
key={step}
className="flex flex-col items-center justify-between"
style={{
flex: 1,
backgroundColor: color,
height: '60px',
borderLeft: isFirst
? 'none'
: `1px solid ${darkBg ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`,
borderTopLeftRadius: isFirst ? '8px' : undefined,
borderBottomLeftRadius: isFirst ? '8px' : undefined,
borderTopRightRadius: isLast ? '8px' : undefined,
borderBottomRightRadius: isLast ? '8px' : undefined,
padding: '6px 0',
}}
>
{/* 상단: 단계 번호 */}
<span style={{ fontSize: '9px', color: textColor, lineHeight: 1 }}>{step}</span>
{/* 하단: 접근성 등급 */}
{rating && (
<span style={{ fontSize: '8px', color: textColor, lineHeight: 1, opacity: 0.8 }}>
{rating}
</span>
)}
</div>
);
})}
</div>
{/* 마커 행 */}
{markers && markers.length > 0 && (
<div className="flex relative" style={{ height: '32px', marginTop: '2px' }}>
{steps.map(({ step }, idx) => {
const marker = getMarker(step);
const pct = (idx / (steps.length - 1)) * 100;
if (!marker) return null;
return (
<div
key={step}
className="absolute flex flex-col items-center"
style={{ left: `calc(${pct}% )`, transform: 'translateX(-50%)' }}
>
{/* 점선 */}
<div
style={{
width: 0,
height: '10px',
borderLeft: '1px dashed rgba(156,163,175,0.7)',
}}
/>
{/* 뱃지 */}
<span
className="px-2 rounded"
style={{
fontSize: '10px',
lineHeight: '18px',
backgroundColor: badgeBg,
color: badgeText,
border: '1px solid rgba(255,255,255,0.1)',
whiteSpace: 'nowrap',
}}
>
{marker.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
};
// ---------- 내부 컴포넌트: DualModeSection ----------
interface DualModeSectionProps {
@ -570,8 +447,7 @@ interface DualModeSectionProps {
darkSpecs: React.ReactNode;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const DualModeSection = ({
export const DualModeSection = ({
lightBg = '#F5F5F5',
darkBg = '#121418',
lightContent,
@ -618,8 +494,7 @@ interface ColorSpecRowProps {
dark?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ColorSpecRow = ({ role, gray, hex, dark = false }: ColorSpecRowProps) => (
export const ColorSpecRow = ({ role, gray, hex, dark = false }: ColorSpecRowProps) => (
<div
className="flex items-center gap-4 px-4 py-3"
style={{
@ -901,27 +776,27 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
tokens: [
{
name: '--bg-base',
value: '#0a0e1a',
value: '#121418',
desc: '페이지 최하단 배경',
},
{
name: '--bg-surface',
value: '#0f1524',
value: '#1B1E23',
desc: '패널, 사이드바',
},
{
name: '--bg-elevated',
value: '#121929',
value: '#24272D',
desc: '테이블 헤더, 섹션',
},
{
name: '--bg-card',
value: '#1a2236',
value: '#24272D',
desc: '카드, 플로팅 요소',
},
{
name: '--bg-surface-hover',
value: '#1e2844',
value: '#3A3F49',
desc: '호버 인터랙션',
},
],
@ -929,11 +804,11 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
{
title: 'fg — Foreground',
tokens: [
{ name: '--fg-default', value: '#edf0f7', desc: '기본 텍스트' },
{ name: '--fg-sub', value: '#c0c8dc', desc: '보조 텍스트' },
{ name: '--fg-default', value: '#F8F9FC', desc: '기본 텍스트' },
{ name: '--fg-sub', value: '#B9C1C9', desc: '보조 텍스트' },
{
name: '--fg-disabled',
value: '#9ba3b8',
value: '#808892',
desc: '비활성, 플레이스홀더',
},
],
@ -943,12 +818,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
tokens: [
{
name: '--stroke-default',
value: '#1e2a42',
value: '#24272D',
desc: '기본 구분선',
},
{
name: '--stroke-light',
value: '#2a3a5c',
value: '#1B1E23',
desc: '연한 구분선',
},
],
@ -1051,11 +926,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
<span></span>
</div>
{[
{ name: '--color-accent', value: '#06b6d4', desc: '주요 강조' },
{ name: '--color-info', value: '#3b82f6', desc: '정보' },
{ name: '--color-danger', value: '#ef4444', desc: '위험' },
{ name: '--color-accent', value: '#0099DD', desc: '주요 강조' },
{ name: '--color-accent-muted', value: '#007AB1', desc: '차분한 강조' },
{ name: '--color-info', value: '#0099DD', desc: '정보' },
{ name: '--color-danger', value: '#D61111', desc: '위험' },
{ name: '--color-warning', value: '#f97316', desc: '주의' },
{ name: '--color-caution', value: '#eab308', desc: '경고' },
{ name: '--color-caution', value: '#FEDA4A', desc: '경고' },
{ name: '--color-success', value: '#22c55e', desc: '성공' },
{
name: '--color-tertiary',
@ -1072,6 +948,16 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
value: '#fbbf24',
desc: '오일붐 호버',
},
{
name: '--color-navy',
value: '#1e40af',
desc: 'Navy 강조',
},
{
name: '--color-navy-hover',
value: '#1d4ed8',
desc: 'Navy 호버',
},
].map((tk) => (
<div
key={tk.name}

파일 보기

@ -16,7 +16,7 @@ export type DispersionModel = 'plume' | 'puff' | 'dense_gas';
export type AlgorithmType = 'ALOHA (EPA)' | 'CAMEO' | 'Gaussian Plume' | 'AERMOD';
/** HNS 확산 — 유출 형태 UI 선택값 (연속/순간/풀증발) */
export type ReleaseType = '연속 유출' | '순간 유출' | '풀(Pool) 증발';
export type ReleaseType = '연속 유출' | '순간 유출' | '풀(Pool) 증발' | '밀도가스 유출';
/** HNS 시나리오 — 시나리오 위험도 분류 */
export type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED';