release: 2026-04-17 (320건 커밋) #190
@ -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
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
|
||||
|
||||
66
CLAUDE.md
66
CLAUDE.md
@ -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 사용)
|
||||
|
||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
@ -34,6 +35,37 @@
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.89.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
|
||||
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@ -1689,6 +1721,20 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@ -2618,6 +2664,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
140
backend/scripts/hns-import/README.md
Normal file
140
backend/scripts/hns-import/README.md
Normal file
@ -0,0 +1,140 @@
|
||||
# HNS 물질 Import 파이프라인
|
||||
|
||||
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
|
||||
|
||||
## 파이프라인 구조
|
||||
|
||||
```
|
||||
[Excel xlsm] [PDF 물질정보집 (193종)]
|
||||
├─ (1) extract-excel.py (2b) extract-pdf.py
|
||||
│ → out/base.json → out/pdf-data.json
|
||||
└─ (2a) extract-images.py ──────────────────────────┐
|
||||
→ out/images/*.png │
|
||||
↓ │
|
||||
(3) ocr-images.ts │
|
||||
→ out/ocr.json │
|
||||
↓ ↓
|
||||
(4) merge-data.ts ←──────────────┘
|
||||
→ frontend/src/data/hnsSubstanceData.json
|
||||
↓
|
||||
(5) tsx src/db/seedHns.ts
|
||||
→ HNS_SUBSTANCE 테이블
|
||||
```
|
||||
|
||||
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
|
||||
|
||||
## 전제 조건
|
||||
|
||||
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
|
||||
- Node.js 20
|
||||
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
|
||||
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### 1) Excel 메타 시트 파싱
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/hns-import/extract-excel.py
|
||||
```
|
||||
|
||||
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
|
||||
- 출력: `scripts/hns-import/out/base.json`
|
||||
|
||||
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
|
||||
|
||||
```bash
|
||||
python scripts/hns-import/extract-images.py
|
||||
```
|
||||
|
||||
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
|
||||
|
||||
### 2b) PDF 물질정보집 파싱 ★ 권장
|
||||
|
||||
```bash
|
||||
python scripts/hns-import/extract-pdf.py
|
||||
```
|
||||
|
||||
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
|
||||
- 출력: `scripts/hns-import/out/pdf-data.json`
|
||||
|
||||
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
cd backend
|
||||
npx tsx scripts/hns-import/ocr-images.ts
|
||||
```
|
||||
|
||||
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
|
||||
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
|
||||
|
||||
### 4) 최종 JSON 병합
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx scripts/hns-import/merge-data.ts
|
||||
```
|
||||
|
||||
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
|
||||
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
|
||||
|
||||
### 5) DB 재시드
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/db/seedHns.ts
|
||||
```
|
||||
|
||||
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
|
||||
|
||||
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/hns-import/extract-pdf.py && \
|
||||
npx tsx scripts/hns-import/merge-data.ts && \
|
||||
npx tsx src/db/seedHns.ts
|
||||
```
|
||||
|
||||
## 현재 데이터 현황 (2024-04 기준)
|
||||
|
||||
| 항목 | 이전 (OCR) | 현재 (PDF) |
|
||||
|------|-----------|-----------|
|
||||
| 총 물질 수 | 514종 | 514종 |
|
||||
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
|
||||
| NFPA 코드 있음 | ~150종 | 201종 |
|
||||
| CAS 번호 있음 | ~380종 | 504종 |
|
||||
| 해양거동 있음 | ~0종 | 206종 |
|
||||
| 한국어 유사명 있음 | ~200종 | 449종 |
|
||||
|
||||
## 재실행 안내
|
||||
|
||||
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
|
||||
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
|
||||
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
|
||||
|
||||
## 알려진 이슈
|
||||
|
||||
### 1) PDF 매칭 실패 35종
|
||||
|
||||
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
|
||||
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
|
||||
|
||||
**원인:**
|
||||
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin` ↔ `디이알 383`)
|
||||
- 동일 CAS 충돌 (예: `컨덴세이트`와 `나프타`가 같은 CAS)
|
||||
- 표기 차이 (예: `아이소파-G` ↔ `아이소파 G`)
|
||||
|
||||
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
|
||||
|
||||
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
|
||||
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
|
||||
|
||||
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
|
||||
|
||||
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.
|
||||
236
backend/scripts/hns-import/extract-excel.py
Normal file
236
backend/scripts/hns-import/extract-excel.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Excel 메타 시트 → base.json 변환.
|
||||
|
||||
처리 시트:
|
||||
- 화물적부도 화물코드: 1,345개 기본 레코드
|
||||
- 동의어: 215개 한/영 유사명
|
||||
- IBC CODE: IMO IBC 분류
|
||||
|
||||
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts)에 맞춘 JSON 배열.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
|
||||
def norm_key(s: str | None) -> str:
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'\s+', '', str(s)).strip().lower()
|
||||
|
||||
|
||||
def split_synonyms(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ''
|
||||
# 원본은 "·" 또는 "/" 구분, 개행 포함
|
||||
parts = re.split(r'[·/\n]+', str(raw))
|
||||
cleaned = [p.strip() for p in parts if p and p.strip()]
|
||||
return ' / '.join(cleaned)
|
||||
|
||||
|
||||
def clean_text(v) -> str:
|
||||
if v is None:
|
||||
return ''
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
|
||||
|
||||
# ────────── 화물적부도 화물코드 ──────────
|
||||
ws = wb['화물적부도 화물코드']
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
|
||||
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
|
||||
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}개')
|
||||
|
||||
# ────────── 동의어 시트 ──────────
|
||||
ws_syn = wb['동의어']
|
||||
syn_rows = list(ws_syn.iter_rows(values_only=True))
|
||||
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
|
||||
syn_map: dict[str, dict] = {}
|
||||
for r in syn_rows[2:]:
|
||||
if not r or r[0] is None:
|
||||
continue
|
||||
name_kr = clean_text(r[1])
|
||||
cas = clean_text(r[3])
|
||||
if not name_kr and not cas:
|
||||
continue
|
||||
key = norm_key(name_kr) or norm_key(cas)
|
||||
syn_map[key] = {
|
||||
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
|
||||
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
|
||||
}
|
||||
print(f'[동의어] {len(syn_map)}개')
|
||||
|
||||
# ────────── IBC CODE 시트 ──────────
|
||||
ws_ibc = wb['IBC CODE']
|
||||
ibc_map: dict[str, dict] = {}
|
||||
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
|
||||
if i < 2:
|
||||
continue # header 2 rows
|
||||
if not r or not r[0]:
|
||||
continue
|
||||
name_en = clean_text(r[0])
|
||||
key = norm_key(name_en)
|
||||
if not key:
|
||||
continue
|
||||
ibc_map[key] = {
|
||||
'ibcHazard': clean_text(r[2]), # 위험성 S/P
|
||||
'ibcShipType': clean_text(r[3]), # 선박형식
|
||||
'ibcTankType': clean_text(r[4]), # 탱크형식
|
||||
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
|
||||
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
|
||||
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
|
||||
}
|
||||
print(f'[IBC CODE] {len(ibc_map)}개')
|
||||
|
||||
wb.close()
|
||||
|
||||
# ────────── 통합 레코드 생성 ──────────
|
||||
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
|
||||
groups: dict[str, list] = defaultdict(list)
|
||||
for r in cargo_rows:
|
||||
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
|
||||
# 그룹 키: CAS 우선, 없으면 국문명
|
||||
cas_s = clean_text(cas)
|
||||
group_key = cas_s if cas_s else norm_key(name_kr)
|
||||
groups[group_key].append({
|
||||
'abbreviation': clean_text(abbr),
|
||||
'nameKr': clean_text(name_kr),
|
||||
'nameEn': clean_text(name_en),
|
||||
'synonymsKr': split_synonyms(syn_kr),
|
||||
'synonymsEn': split_synonyms(syn_en),
|
||||
'usage': clean_text(usage),
|
||||
'unNumber': clean_text(un),
|
||||
'casNumber': cas_s,
|
||||
})
|
||||
|
||||
records: list[dict] = []
|
||||
next_id = 1
|
||||
for group_key, entries in groups.items():
|
||||
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
|
||||
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
|
||||
|
||||
name_kr_key = norm_key(primary['nameKr'])
|
||||
name_en_key = norm_key(primary['nameEn'])
|
||||
|
||||
# 동의어 병합
|
||||
syn_extra = syn_map.get(name_kr_key, {})
|
||||
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
|
||||
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
|
||||
|
||||
# IBC 병합 (영문명 기준)
|
||||
ibc = ibc_map.get(name_en_key, {})
|
||||
|
||||
# cargoCodes 집계
|
||||
cargo_codes = [
|
||||
{
|
||||
'code': e['abbreviation'],
|
||||
'name': e['nameEn'] or e['nameKr'],
|
||||
'company': '국제공통',
|
||||
'source': '적부도',
|
||||
}
|
||||
for e in entries
|
||||
if e['abbreviation']
|
||||
]
|
||||
|
||||
record = {
|
||||
'id': next_id,
|
||||
'abbreviation': primary['abbreviation'],
|
||||
'nameKr': primary['nameKr'],
|
||||
'nameEn': primary['nameEn'],
|
||||
'synonymsKr': synonyms_kr,
|
||||
'synonymsEn': synonyms_en,
|
||||
'unNumber': primary['unNumber'],
|
||||
'casNumber': primary['casNumber'],
|
||||
'transportMethod': '',
|
||||
'sebc': '',
|
||||
# 물리·화학 (OCR 단계에서 채움)
|
||||
'usage': primary['usage'],
|
||||
'state': '',
|
||||
'color': '',
|
||||
'odor': '',
|
||||
'flashPoint': '',
|
||||
'autoIgnition': '',
|
||||
'boilingPoint': '',
|
||||
'density': '',
|
||||
'solubility': '',
|
||||
'vaporPressure': '',
|
||||
'vaporDensity': '',
|
||||
'explosionRange': '',
|
||||
# 위험도
|
||||
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
|
||||
'hazardClass': '',
|
||||
'ergNumber': '',
|
||||
'idlh': '',
|
||||
'aegl2': '',
|
||||
'erpg2': '',
|
||||
# 방제
|
||||
'responseDistanceFire': '',
|
||||
'responseDistanceSpillDay': '',
|
||||
'responseDistanceSpillNight': '',
|
||||
'marineResponse': '',
|
||||
'ppeClose': '',
|
||||
'ppeFar': '',
|
||||
# MSDS
|
||||
'msds': {
|
||||
'hazard': '',
|
||||
'firstAid': '',
|
||||
'fireFighting': '',
|
||||
'spillResponse': '',
|
||||
'exposure': '',
|
||||
'regulation': '',
|
||||
},
|
||||
# IBC
|
||||
'ibcHazard': ibc.get('ibcHazard', ''),
|
||||
'ibcShipType': ibc.get('ibcShipType', ''),
|
||||
'ibcTankType': ibc.get('ibcTankType', ''),
|
||||
'ibcDetection': ibc.get('ibcDetection', ''),
|
||||
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
|
||||
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
|
||||
# EmS (OCR에서 채움)
|
||||
'emsCode': '',
|
||||
'emsFire': '',
|
||||
'emsSpill': '',
|
||||
'emsFirstAid': '',
|
||||
# cargoCodes / portFrequency
|
||||
'cargoCodes': cargo_codes,
|
||||
'portFrequency': [],
|
||||
}
|
||||
records.append(record)
|
||||
next_id += 1
|
||||
|
||||
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
|
||||
|
||||
# 저장
|
||||
out_path = OUT_DIR / 'base.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
170
backend/scripts/hns-import/extract-images.py
Normal file
170
backend/scripts/hns-import/extract-images.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
|
||||
|
||||
엑셀 워크시트 → drawing → image 관계 체인을 추적해
|
||||
각 물질 시트의 핵심 이미지만 out/images/{nameKr}.png 로 저장.
|
||||
|
||||
동시에 out/image-map.json 생성 (파일명 ↔ 시트명/국문명 매핑).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
NS = {
|
||||
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||
}
|
||||
|
||||
# 메타 시트(데이터 시트)는 스킵
|
||||
SKIP_SHEETS = {
|
||||
'화물적부도 화물코드',
|
||||
'항구별 코드',
|
||||
'동의어',
|
||||
'IBC CODE',
|
||||
'경계선',
|
||||
}
|
||||
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
|
||||
SKIP_PATTERN = re.compile(r'^\d{3}$')
|
||||
|
||||
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
|
||||
MIN_IMAGE_SIZE = 50_000 # 50 KB
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
name = name.strip().rstrip(',').strip()
|
||||
name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||
return name
|
||||
|
||||
|
||||
def norm_path(p: str) -> str:
|
||||
return os.path.normpath(p).replace(os.sep, '/')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
image_map: dict[str, dict] = {}
|
||||
saved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
|
||||
with zipfile.ZipFile(SOURCE_XLSX) as z:
|
||||
# 1) workbook.xml → sheet 목록
|
||||
with z.open('xl/workbook.xml') as f:
|
||||
wb_root = ET.parse(f).getroot()
|
||||
sheets = []
|
||||
for s in wb_root.findall('m:sheets/m:sheet', NS):
|
||||
sheets.append({
|
||||
'name': s.get('name'),
|
||||
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
|
||||
})
|
||||
with z.open('xl/_rels/workbook.xml.rels') as f:
|
||||
rels_root = ET.parse(f).getroot()
|
||||
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
|
||||
for s in sheets:
|
||||
s['target'] = rid_target.get(s['rid'])
|
||||
|
||||
print(f'[시트] 총 {len(sheets)}개')
|
||||
|
||||
for s in sheets:
|
||||
name = s['name']
|
||||
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sheet_file = 'xl/' + s['target']
|
||||
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
|
||||
try:
|
||||
with z.open(rels_file) as f:
|
||||
srels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 시트 → drawing
|
||||
drawing_rel = None
|
||||
for r in srels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
|
||||
drawing_rel = t
|
||||
break
|
||||
if not drawing_rel:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
|
||||
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
|
||||
try:
|
||||
with z.open(drawing_rels_path) as f:
|
||||
drels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# drawing → images
|
||||
image_paths: list[str] = []
|
||||
for r in drels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'image' in t.lower():
|
||||
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
|
||||
image_paths.append(img_path)
|
||||
if not image_paths:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
|
||||
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
|
||||
sized.sort(reverse=True)
|
||||
largest_size, largest_path = sized[0]
|
||||
if largest_size < MIN_IMAGE_SIZE:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 저장
|
||||
safe = safe_filename(name)
|
||||
ext = os.path.splitext(largest_path)[1].lower() or '.png'
|
||||
out_name = f'{safe}{ext}'
|
||||
out_path = IMG_DIR / out_name
|
||||
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
|
||||
fout.write(fin.read())
|
||||
image_map[out_name] = {
|
||||
'sheetName': name,
|
||||
'nameKr': safe,
|
||||
'source': largest_path,
|
||||
'sizeBytes': largest_size,
|
||||
}
|
||||
saved += 1
|
||||
if saved % 25 == 0:
|
||||
print(f' {saved}개 저장 완료')
|
||||
|
||||
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
|
||||
|
||||
map_path = OUT_DIR / 'image-map.json'
|
||||
with open(map_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(image_map, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] 매핑 파일: {map_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
707
backend/scripts/hns-import/extract-pdf.py
Normal file
707
backend/scripts/hns-import/extract-pdf.py
Normal file
@ -0,0 +1,707 @@
|
||||
"""PDF 물질정보집 → pdf-data.json 변환.
|
||||
|
||||
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
|
||||
해양경찰청 발행 193종 물질 정보
|
||||
|
||||
PDF 구조:
|
||||
- 페이지 1-21: 표지/머리말/목차
|
||||
- 페이지 22-407: 193종 × 2페이지 물질 카드
|
||||
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동 등
|
||||
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
|
||||
- 물질 NO(1-193) → 0-인덱스 시작 페이지: 21 + (NO-1) * 2
|
||||
|
||||
출력: out/pdf-data.json
|
||||
{ [nameKr]: OcrResult } — merge-data.ts 와 동일한 키 구조
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
PDF_PATH = Path(os.environ.get(
|
||||
'HNS_PDF_PATH',
|
||||
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
|
||||
))
|
||||
|
||||
# 전각 문자 → 반각 변환 테이블
|
||||
_FULLWIDTH = str.maketrans(
|
||||
'()tC°℃ ,',
|
||||
'()tC℃℃ ,',
|
||||
)
|
||||
|
||||
|
||||
def clean(s: str) -> str:
|
||||
"""텍스트 정리."""
|
||||
if not s:
|
||||
return ''
|
||||
s = s.translate(_FULLWIDTH)
|
||||
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
|
||||
s = re.sub(r'(?<=[0-9])\s*[仁七부사 ](?=\s|$|이|이하)', '℃', s)
|
||||
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '℃', s)
|
||||
s = re.sub(r'\s+', ' ', s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def norm_key(s: str) -> str:
|
||||
"""정규화 키: 공백/특수문자 제거 + 소문자."""
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
|
||||
|
||||
|
||||
def normalize_cas(raw: str) -> str:
|
||||
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
|
||||
if not raw:
|
||||
return ''
|
||||
# 혼합물
|
||||
if '혼합물' in raw:
|
||||
return ''
|
||||
# 특수 대시 → -
|
||||
s = raw.replace('—', '-').replace('-', '-').replace('–', '-')
|
||||
# OCR 오인식: 이,이, 공백 등 → 0
|
||||
s = re.sub(r'[이oO0]', '0', s)
|
||||
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
|
||||
# CAS 포맷 검증 후 반환
|
||||
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
|
||||
if m:
|
||||
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
|
||||
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
|
||||
s2 = re.sub(r'[^0-9\-]', '', s)
|
||||
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
|
||||
if m2:
|
||||
return m2.group(1).lstrip('0') or '0'
|
||||
return ''
|
||||
|
||||
|
||||
def find_cas_in_text(text: str) -> str:
|
||||
"""텍스트에서 CAS 번호 패턴 검색."""
|
||||
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
|
||||
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
|
||||
for c in candidates:
|
||||
cas = normalize_cas(c)
|
||||
if cas and len(cas) >= 5:
|
||||
return cas
|
||||
return ''
|
||||
|
||||
|
||||
def parse_nfpa(text: str) -> dict | None:
|
||||
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
|
||||
m = re.search(r'건강\s*[::]\s*(\d)\s*화재\s*[::]\s*(\d)\s*반응\s*[::]\s*(\d)', text)
|
||||
if m:
|
||||
return {
|
||||
'health': int(m.group(1)),
|
||||
'fire': int(m.group(2)),
|
||||
'reactivity': int(m.group(3)),
|
||||
'special': '',
|
||||
}
|
||||
# 대안 패턴: 줄바꿈 포함
|
||||
m2 = re.search(r'건강\s*[::]\s*(\d).*?화재\s*[::]\s*(\d).*?반응\s*[::]\s*(\d)', text, re.DOTALL)
|
||||
if m2:
|
||||
return {
|
||||
'health': int(m2.group(1)),
|
||||
'fire': int(m2.group(2)),
|
||||
'reactivity': int(m2.group(3)),
|
||||
'special': '',
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
|
||||
"""레이블 직후 값 추출 (단순 패턴)."""
|
||||
idx = text.find(label)
|
||||
if idx < 0:
|
||||
return ''
|
||||
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
|
||||
# 첫 비공백 줄 추출
|
||||
lines = snippet.split('\n')
|
||||
for line in lines:
|
||||
v = clean(line)
|
||||
if v and v not in (':', ':', ''):
|
||||
return v[:max_chars]
|
||||
return ''
|
||||
|
||||
|
||||
def parse_summary_card(text: str, index_entry: dict) -> dict:
|
||||
"""요약 카드(첫 번째 페이지) 파싱."""
|
||||
result: dict = {}
|
||||
|
||||
# 인화점
|
||||
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and '위험' not in val and len(val) < 40:
|
||||
result['flashPoint'] = val
|
||||
|
||||
# 발화점
|
||||
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['autoIgnition'] = val
|
||||
|
||||
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
|
||||
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
# 파편화된 텍스트 제거
|
||||
if val and re.search(r'\d', val) and len(val) < 60:
|
||||
result['vaporPressure'] = val
|
||||
|
||||
# 증기밀도 숫자값
|
||||
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 20:
|
||||
result['vaporDensity'] = val
|
||||
|
||||
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
|
||||
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and '%' in val and len(val) < 30:
|
||||
result['explosionRange'] = val
|
||||
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
|
||||
if not result.get('explosionRange'):
|
||||
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
|
||||
if m:
|
||||
result['explosionRange'] = clean(m.group(1))
|
||||
|
||||
# 화재시 대피거리
|
||||
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val:
|
||||
result['responseDistanceFire'] = val
|
||||
|
||||
# 해양거동
|
||||
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
|
||||
if not m:
|
||||
m = re.search(r'해양거동\s+([^\n]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 80:
|
||||
result['marineResponse'] = val
|
||||
|
||||
# 상온상태
|
||||
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['state'] = val
|
||||
|
||||
# 냄새
|
||||
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['odor'] = val
|
||||
|
||||
# 비중
|
||||
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
|
||||
if not m:
|
||||
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['density'] = val
|
||||
|
||||
# 색상
|
||||
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['color'] = val
|
||||
|
||||
# 이격거리 / 방호거리 거리 숫자 추출
|
||||
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
|
||||
if m_hot:
|
||||
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
|
||||
|
||||
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
|
||||
if m_warm:
|
||||
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_detail_card(text: str) -> dict:
|
||||
"""상세 카드(두 번째 페이지) 파싱."""
|
||||
result: dict = {}
|
||||
|
||||
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
|
||||
# 형식: "001 과산화수소" or "0이 과산화수소"
|
||||
first_lines = text.strip().split('\n')[:4]
|
||||
for line in first_lines:
|
||||
line = line.strip()
|
||||
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
|
||||
m = re.match(r'^[0-9이이아오0-9]{2,3}\s+([\w\s\-,./()()]+)$', line)
|
||||
if m:
|
||||
candidate = clean(m.group(1).strip())
|
||||
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
|
||||
result['nameKr'] = candidate
|
||||
break
|
||||
|
||||
# ── 분류 ──────────────────────────────────────────────────────────
|
||||
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
|
||||
if m:
|
||||
result['hazardClass'] = clean(m.group(0))
|
||||
|
||||
# ── 물질요약 ───────────────────────────────────────────────────────
|
||||
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
|
||||
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
|
||||
if not m:
|
||||
# 분류값 이후 ~ 유사명 전
|
||||
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
|
||||
if m:
|
||||
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
if summary and len(summary) > 15:
|
||||
result['materialSummary'] = summary[:500]
|
||||
|
||||
# ── 유사명 ─────────────────────────────────────────────────────────
|
||||
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
|
||||
if m:
|
||||
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
# CAS 번호 형태면 제외
|
||||
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
|
||||
result['synonymsKr'] = synonyms_raw
|
||||
|
||||
# ── CAS 번호 ────────────────────────────────────────────────────────
|
||||
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
|
||||
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
|
||||
if not m:
|
||||
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
|
||||
if m:
|
||||
cas = normalize_cas(m.group(1).strip().split()[0])
|
||||
if cas:
|
||||
result['casNumber'] = cas
|
||||
|
||||
# 2순위: 텍스트 전체에서 CAS 패턴 검색
|
||||
if not result.get('casNumber'):
|
||||
cas = find_cas_in_text(text)
|
||||
if cas:
|
||||
result['casNumber'] = cas
|
||||
|
||||
# ── UN 번호 ─────────────────────────────────────────────────────────
|
||||
# NFPA 코드 이후 줄에 있는 4자리 숫자
|
||||
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
|
||||
if not m:
|
||||
# NFPA 다음 4자리 숫자
|
||||
m = re.search(r'반응\s*[::]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
|
||||
if m:
|
||||
result['unNumber'] = m.group(1).strip()
|
||||
|
||||
# ── NFPA 코드 ───────────────────────────────────────────────────────
|
||||
nfpa = parse_nfpa(text)
|
||||
if nfpa:
|
||||
result['nfpa'] = nfpa
|
||||
|
||||
# ── 용도 ────────────────────────────────────────────────────────────
|
||||
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
|
||||
if m:
|
||||
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||
# GHS 마크(특수문자 블록) 제거
|
||||
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
|
||||
if usage and len(usage) < 200:
|
||||
result['usage'] = usage
|
||||
|
||||
# ── 물질특성 블록 ───────────────────────────────────────────────────
|
||||
props_start = text.find('물질특성')
|
||||
props_end = text.find('인체 유해성')
|
||||
if props_end < 0:
|
||||
props_end = text.find('인체유해성')
|
||||
if props_end < 0:
|
||||
props_end = text.find('흡입노출')
|
||||
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
|
||||
|
||||
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
|
||||
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||
result['flashPoint'] = val
|
||||
|
||||
# 발화점 (상세) — 숫자 포함 값만 허용
|
||||
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||
result['autoIgnition'] = val
|
||||
|
||||
# 끓는점
|
||||
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['boilingPoint'] = val
|
||||
|
||||
# 수용해도
|
||||
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 50:
|
||||
result['solubility'] = val
|
||||
|
||||
# 상온상태 (상세)
|
||||
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1)).strip('()')
|
||||
if val and len(val) < 60:
|
||||
result['state'] = val
|
||||
|
||||
# 색상 (상세)
|
||||
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 40:
|
||||
result['color'] = val
|
||||
|
||||
# 냄새 (상세)
|
||||
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
|
||||
if not m:
|
||||
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 60:
|
||||
result['odor'] = val
|
||||
|
||||
# 비중 (상세)
|
||||
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['density'] = val
|
||||
|
||||
# 증기압 (상세)
|
||||
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['vaporPressure'] = val
|
||||
|
||||
# 증기밀도 (상세)
|
||||
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 20:
|
||||
result['vaporDensity'] = val
|
||||
|
||||
# 점도
|
||||
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
|
||||
if not m:
|
||||
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and len(val) < 30:
|
||||
result['viscosity'] = val
|
||||
|
||||
# ── 인체유해성 블록 ─────────────────────────────────────────────────
|
||||
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
|
||||
if hazard_start < 0:
|
||||
hazard_start = text.find('급성독성')
|
||||
response_start = text.find('초동대응')
|
||||
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
|
||||
|
||||
# IDLH
|
||||
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['idlh'] = val
|
||||
|
||||
# TWA
|
||||
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||
if m:
|
||||
val = clean(m.group(1))
|
||||
if val and re.search(r'\d', val):
|
||||
result['twa'] = val
|
||||
|
||||
# ── 응급조치 ─────────────────────────────────────────────────────────
|
||||
fa_start = text.find('흡입노출')
|
||||
fa_end = text.find('초동대응')
|
||||
if fa_start >= 0:
|
||||
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
|
||||
fa = re.sub(r'\s+', ' ', fa_text).strip()
|
||||
result['msds'] = {
|
||||
'firstAid': fa[:600],
|
||||
'spillResponse': '',
|
||||
'hazard': '',
|
||||
'fireFighting': '',
|
||||
'exposure': '',
|
||||
'regulation': '',
|
||||
}
|
||||
|
||||
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
|
||||
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceSpillDay'] = m.group(1) + 'm'
|
||||
|
||||
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceSpillNight'] = m.group(1) + 'm'
|
||||
|
||||
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||
if m:
|
||||
result['responseDistanceFire'] = m.group(1) + 'm'
|
||||
|
||||
# ── GHS 분류 ─────────────────────────────────────────────────────────
|
||||
ghs_items = re.findall(
|
||||
r'(?:인화성[^\n((]{2,40}|급성독성[^\n((]{2,40}|피부부식[^\n((]{2,40}|'
|
||||
r'눈\s*손상[^\n((]{2,40}|발암성[^\n((]{2,40}|생식독성[^\n((]{2,40}|'
|
||||
r'수생환경[^\n((]{2,40}|특정표적[^\n((]{2,40}|흡인유해[^\n((]{2,40})',
|
||||
text,
|
||||
)
|
||||
if ghs_items:
|
||||
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
|
||||
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
|
||||
index: dict[int, dict] = {}
|
||||
|
||||
for page_idx in range(3, 21):
|
||||
page = pdf[page_idx]
|
||||
text = page.get_text()
|
||||
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if not re.match(r'^\d{1,3}$', line):
|
||||
continue
|
||||
no = int(line)
|
||||
if not (1 <= no <= 193):
|
||||
continue
|
||||
if no in index:
|
||||
continue
|
||||
|
||||
# 탐색 창: NO 앞 1~4줄
|
||||
cas, name_en, name_kr = '', '', ''
|
||||
if i >= 1:
|
||||
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
|
||||
raw_cas = lines[i - 1]
|
||||
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
|
||||
if not cas and '혼합물' in raw_cas:
|
||||
cas = '혼합물'
|
||||
|
||||
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
|
||||
if i >= 2:
|
||||
name_en = lines[i - 2]
|
||||
if i >= 3:
|
||||
name_kr = lines[i - 3]
|
||||
elif i >= 2:
|
||||
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
|
||||
raw_cas2 = lines[i - 2] if i >= 2 else ''
|
||||
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
|
||||
if cas or '혼합물' in raw_cas2:
|
||||
name_en = lines[i - 1] if i >= 1 else ''
|
||||
# name_kr는 찾기 어려움
|
||||
|
||||
if not name_kr and i >= 3:
|
||||
# 이름이 공백/짧으면 더 위 줄에서 찾기
|
||||
for j in range(3, min(6, i + 1)):
|
||||
cand = lines[i - j]
|
||||
if re.search(r'[가-힣]', cand) and len(cand) > 1:
|
||||
name_kr = cand
|
||||
break
|
||||
|
||||
index[no] = {
|
||||
'no': no,
|
||||
'nameKr': name_kr,
|
||||
'nameEn': name_en,
|
||||
'casNumber': cas if cas != '혼합물' else '',
|
||||
}
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def extract_name_from_summary(text: str) -> tuple[str, str]:
|
||||
"""요약 카드에서 nameKr, nameEn 추출."""
|
||||
name_kr, name_en = '', ''
|
||||
lines = text.strip().split('\n')
|
||||
|
||||
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
|
||||
found_header = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 제목 줄 건너뜀
|
||||
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
|
||||
found_header = True
|
||||
continue
|
||||
# 3자리 번호 줄 건너뜀
|
||||
if re.match(r'^\d{1,3}$', line):
|
||||
continue
|
||||
# 한글이 있으면 nameKr 후보
|
||||
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
|
||||
if not name_kr:
|
||||
name_kr = clean(line)
|
||||
|
||||
# 영문명: (영문명) 형태
|
||||
m_en = re.search(r'[((]([A-Za-z][^))]{3,60})[))]', line)
|
||||
if m_en and not name_en:
|
||||
name_en = clean(m_en.group(1))
|
||||
|
||||
if name_kr and name_en:
|
||||
break
|
||||
|
||||
return name_kr, name_en
|
||||
|
||||
|
||||
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
|
||||
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
|
||||
start_idx = 21 + (no - 1) * 2
|
||||
if start_idx + 1 >= pdf.page_count:
|
||||
return None
|
||||
|
||||
summary_text = pdf[start_idx].get_text()
|
||||
detail_text = pdf[start_idx + 1].get_text()
|
||||
|
||||
summary = parse_summary_card(summary_text, index_entry)
|
||||
detail = parse_detail_card(detail_text)
|
||||
|
||||
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
|
||||
name_kr = index_entry.get('nameKr', '')
|
||||
if not name_kr:
|
||||
name_kr = detail.get('nameKr', '')
|
||||
if not name_kr:
|
||||
name_kr, _ = extract_name_from_summary(summary_text)
|
||||
|
||||
# nameEn
|
||||
name_en = index_entry.get('nameEn', '')
|
||||
|
||||
# 통합: detail 우선, 없으면 summary
|
||||
merged: dict = {
|
||||
'nameKr': name_kr,
|
||||
'nameEn': name_en,
|
||||
}
|
||||
|
||||
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
|
||||
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
|
||||
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
|
||||
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
|
||||
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
|
||||
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
|
||||
detail_val = detail.get(key)
|
||||
summary_val = summary.get(key)
|
||||
if detail_val:
|
||||
merged[key] = detail_val
|
||||
elif summary_val:
|
||||
merged[key] = summary_val
|
||||
|
||||
# CAS: 인덱스 우선
|
||||
if index_entry.get('casNumber') and not merged.get('casNumber'):
|
||||
merged['casNumber'] = index_entry['casNumber']
|
||||
|
||||
# NFPA: detail 우선
|
||||
if 'nfpa' in detail:
|
||||
merged['nfpa'] = detail['nfpa']
|
||||
|
||||
if 'msds' not in merged:
|
||||
merged['msds'] = {
|
||||
'firstAid': '', 'spillResponse': '', 'hazard': '',
|
||||
'fireFighting': '', 'exposure': '', 'regulation': '',
|
||||
}
|
||||
|
||||
merged['_no'] = no
|
||||
merged['_pageIdx'] = start_idx
|
||||
return merged
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not PDF_PATH.exists():
|
||||
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
|
||||
|
||||
print(f'[읽기] {PDF_PATH}')
|
||||
pdf = fitz.open(str(PDF_PATH))
|
||||
print(f'[PDF] 총 {pdf.page_count}페이지')
|
||||
|
||||
# 1. 인덱스 파싱
|
||||
print('[인덱스] 목차 페이지 파싱 중...')
|
||||
index = parse_index_pages(pdf)
|
||||
print(f'[인덱스] {len(index)}개 항목 발견')
|
||||
|
||||
# 2. 물질 카드 파싱
|
||||
results: dict[str, dict] = {}
|
||||
failed: list[int] = []
|
||||
|
||||
for no in range(1, 194):
|
||||
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
|
||||
try:
|
||||
rec = parse_substance(pdf, no, entry)
|
||||
if rec:
|
||||
name_kr = rec.get('nameKr', '')
|
||||
if name_kr:
|
||||
key = name_kr
|
||||
if key in results:
|
||||
key = f'{name_kr}_{no}'
|
||||
results[key] = rec
|
||||
else:
|
||||
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
|
||||
failed.append(no)
|
||||
except Exception as e:
|
||||
print(f' [오류] NO={no}: {e}')
|
||||
failed.append(no)
|
||||
|
||||
pdf.close()
|
||||
|
||||
# 3. 저장
|
||||
out_path = OUT_DIR / 'pdf-data.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
size_kb = out_path.stat().st_size / 1024
|
||||
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
|
||||
|
||||
if failed:
|
||||
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
|
||||
|
||||
# 4. 통계
|
||||
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
|
||||
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
|
||||
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
|
||||
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
|
||||
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}종')
|
||||
|
||||
# 5. 샘플 출력
|
||||
print('\n[샘플] 주요 항목:')
|
||||
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
|
||||
for k in sample_keys:
|
||||
if k in results:
|
||||
v = results[k]
|
||||
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
23
backend/scripts/hns-import/merge-batch.py
Normal file
23
backend/scripts/hns-import/merge-batch.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""배치 JSON을 ocr.json에 병합."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OCR_PATH = OUT_DIR / 'ocr.json'
|
||||
BATCH_PATH = OUT_DIR / 'batch.json'
|
||||
|
||||
with open(OCR_PATH, encoding='utf-8') as f:
|
||||
ocr = json.load(f)
|
||||
with open(BATCH_PATH, encoding='utf-8') as f:
|
||||
batch = json.load(f)
|
||||
|
||||
added = [k for k in batch if k not in ocr]
|
||||
updated = [k for k in batch if k in ocr]
|
||||
ocr.update(batch)
|
||||
|
||||
with open(OCR_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')
|
||||
362
backend/scripts/hns-import/merge-data.ts
Normal file
362
backend/scripts/hns-import/merge-data.ts
Normal file
@ -0,0 +1,362 @@
|
||||
/**
|
||||
* base.json + pdf-data.json + ocr.json → frontend/src/data/hnsSubstanceData.json
|
||||
*
|
||||
* 우선순위: pdf-data (PDF 텍스트 추출, 최고 정확도) > base.json > ocr.json (이미지 OCR, 낮은 정확도)
|
||||
* 매칭 키 순서:
|
||||
* 1. CAS 번호 (가장 신뢰할 수 있는 식별자)
|
||||
* 2. 국문명(nameKr) 정규화 비교
|
||||
* 3. 동의어(synonymsKr) 정규화 비교
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const BASE_PATH = resolve(OUT_DIR, 'base.json');
|
||||
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
|
||||
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
|
||||
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
|
||||
|
||||
function normalizeName(s: string | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[,.·/\-_()[\]()]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCas(s: string | undefined): string {
|
||||
if (!s) return '';
|
||||
// 앞자리 0 제거 후 정규화
|
||||
return s
|
||||
.replace(/[^0-9\-]/g, '')
|
||||
.replace(/^0+/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface NfpaBlock {
|
||||
health: number;
|
||||
fire: number;
|
||||
reactivity: number;
|
||||
special: string;
|
||||
}
|
||||
|
||||
interface MsdsBlock {
|
||||
hazard: string;
|
||||
firstAid: string;
|
||||
fireFighting: string;
|
||||
spillResponse: string;
|
||||
exposure: string;
|
||||
regulation: string;
|
||||
}
|
||||
|
||||
interface BaseRecord {
|
||||
id: number;
|
||||
abbreviation: string;
|
||||
nameKr: string;
|
||||
nameEn: string;
|
||||
synonymsEn: string;
|
||||
synonymsKr: string;
|
||||
unNumber: string;
|
||||
casNumber: string;
|
||||
transportMethod: string;
|
||||
sebc: string;
|
||||
usage: string;
|
||||
state: string;
|
||||
color: string;
|
||||
odor: string;
|
||||
flashPoint: string;
|
||||
autoIgnition: string;
|
||||
boilingPoint: string;
|
||||
density: string;
|
||||
solubility: string;
|
||||
vaporPressure: string;
|
||||
vaporDensity: string;
|
||||
explosionRange: string;
|
||||
nfpa: NfpaBlock;
|
||||
hazardClass: string;
|
||||
ergNumber: string;
|
||||
idlh: string;
|
||||
aegl2: string;
|
||||
erpg2: string;
|
||||
responseDistanceFire: string;
|
||||
responseDistanceSpillDay: string;
|
||||
responseDistanceSpillNight: string;
|
||||
marineResponse: string;
|
||||
ppeClose: string;
|
||||
ppeFar: string;
|
||||
msds: MsdsBlock;
|
||||
ibcHazard: string;
|
||||
ibcShipType: string;
|
||||
ibcTankType: string;
|
||||
ibcDetection: string;
|
||||
ibcFireFighting: string;
|
||||
ibcMinRequirement: string;
|
||||
emsCode: string;
|
||||
emsFire: string;
|
||||
emsSpill: string;
|
||||
emsFirstAid: string;
|
||||
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
||||
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
||||
}
|
||||
|
||||
interface PdfResult {
|
||||
[key: string]: unknown;
|
||||
casNumber?: string;
|
||||
nameKr?: string;
|
||||
nfpa?: Partial<NfpaBlock>;
|
||||
msds?: Partial<MsdsBlock>;
|
||||
}
|
||||
|
||||
interface OcrResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function firstString(...values: Array<unknown>): string {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
|
||||
const n = source.nfpa as Partial<NfpaBlock> | undefined;
|
||||
if (!n || typeof n !== 'object') return null;
|
||||
const h = Number(n.health);
|
||||
const f = Number(n.fire);
|
||||
const r = Number(n.reactivity);
|
||||
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
|
||||
return {
|
||||
health: h,
|
||||
fire: f,
|
||||
reactivity: r,
|
||||
special: typeof n.special === 'string' ? n.special : '',
|
||||
};
|
||||
}
|
||||
|
||||
function pickMsds(
|
||||
pdf: PdfResult | undefined,
|
||||
ocr: OcrResult | undefined,
|
||||
base: MsdsBlock,
|
||||
): MsdsBlock {
|
||||
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
|
||||
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
|
||||
return {
|
||||
hazard: firstString(base.hazard, p.hazard, o.hazard),
|
||||
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
|
||||
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
|
||||
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
|
||||
exposure: firstString(base.exposure, p.exposure, o.exposure),
|
||||
regulation: firstString(base.regulation, p.regulation, o.regulation),
|
||||
};
|
||||
}
|
||||
|
||||
function merge(
|
||||
base: BaseRecord,
|
||||
pdf: PdfResult | undefined,
|
||||
ocr: OcrResult | undefined,
|
||||
): BaseRecord {
|
||||
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
|
||||
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
|
||||
// pdf NFPA 우선, 없으면 ocr, 없으면 base
|
||||
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
|
||||
|
||||
return {
|
||||
...base,
|
||||
// pdf > base > ocr 우선순위
|
||||
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
|
||||
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
|
||||
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
|
||||
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
|
||||
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
|
||||
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
|
||||
state: firstString(pdf?.state, base.state, ocr?.state),
|
||||
color: firstString(pdf?.color, base.color, ocr?.color),
|
||||
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
|
||||
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
|
||||
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
|
||||
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
|
||||
density: firstString(pdf?.density, base.density, ocr?.density),
|
||||
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
|
||||
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
|
||||
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
|
||||
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
|
||||
nfpa,
|
||||
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
|
||||
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
|
||||
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
|
||||
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
|
||||
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
|
||||
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
|
||||
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
|
||||
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
|
||||
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
|
||||
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
|
||||
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
|
||||
msds: pickMsds(pdf, ocr, base.msds),
|
||||
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
|
||||
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
|
||||
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
|
||||
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(BASE_PATH)) {
|
||||
console.error(`base.json 없음: ${BASE_PATH}`);
|
||||
console.error('→ extract-excel.py 를 먼저 실행하세요.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
|
||||
|
||||
// PDF 데이터 로드
|
||||
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
|
||||
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
// OCR 데이터 로드
|
||||
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
|
||||
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
console.log(
|
||||
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}종`,
|
||||
);
|
||||
|
||||
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
|
||||
// 1) nameKr 정규화 인덱스
|
||||
const pdfByName = new Map<string, PdfResult>();
|
||||
// 2) CAS 번호 인덱스
|
||||
const pdfByCas = new Map<string, PdfResult>();
|
||||
|
||||
for (const [key, value] of Object.entries(pdfRaw)) {
|
||||
const normKey = normalizeName(key);
|
||||
if (normKey) pdfByName.set(normKey, value);
|
||||
|
||||
const cas = normalizeCas(value.casNumber);
|
||||
if (cas) {
|
||||
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
|
||||
const ocrByName = new Map<string, OcrResult>();
|
||||
const ocrNormToOrig = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(ocr)) {
|
||||
const normKey = normalizeName(key);
|
||||
if (normKey) {
|
||||
ocrByName.set(normKey, value);
|
||||
ocrNormToOrig.set(normKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 병합 ──────────────────────────────────────────────────────────
|
||||
let pdfMatchedByName = 0;
|
||||
let pdfMatchedByCas = 0;
|
||||
let pdfMatchedBySynonym = 0;
|
||||
let ocrMatched = 0;
|
||||
const pdfUnmatched = new Set(Object.keys(pdfRaw));
|
||||
const ocrUnmatched = new Set(ocrByName.keys());
|
||||
|
||||
const merged = base.map((record) => {
|
||||
let pdfResult: PdfResult | undefined;
|
||||
let ocrResult: OcrResult | undefined;
|
||||
|
||||
// ── PDF 매칭 ────────────────────────────────────────────────────
|
||||
// 1. CAS 번호 매칭 (가장 정확)
|
||||
const baseCas = normalizeCas(record.casNumber);
|
||||
if (baseCas) {
|
||||
pdfResult = pdfByCas.get(baseCas);
|
||||
if (pdfResult) {
|
||||
pdfMatchedByCas++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. nameKr 정규화 매칭
|
||||
if (!pdfResult) {
|
||||
const normKr = normalizeName(record.nameKr);
|
||||
pdfResult = pdfByName.get(normKr);
|
||||
if (pdfResult) {
|
||||
pdfMatchedByName++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. synonymsKr 동의어 매칭
|
||||
if (!pdfResult && record.synonymsKr) {
|
||||
const synonyms = record.synonymsKr.split(' / ');
|
||||
for (const syn of synonyms) {
|
||||
const normSyn = normalizeName(syn);
|
||||
if (!normSyn) continue;
|
||||
pdfResult = pdfByName.get(normSyn);
|
||||
if (pdfResult) {
|
||||
pdfMatchedBySynonym++;
|
||||
const origKey = pdfResult.nameKr as string | undefined;
|
||||
if (origKey) pdfUnmatched.delete(origKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
|
||||
const normKr = normalizeName(record.nameKr);
|
||||
const ocrByNameResult = ocrByName.get(normKr);
|
||||
if (ocrByNameResult) {
|
||||
ocrResult = ocrByNameResult;
|
||||
ocrMatched++;
|
||||
ocrUnmatched.delete(normKr);
|
||||
}
|
||||
|
||||
if (!ocrResult && record.synonymsKr) {
|
||||
const synonyms = record.synonymsKr.split(' / ');
|
||||
for (const syn of synonyms) {
|
||||
const normSyn = normalizeName(syn);
|
||||
if (!normSyn) continue;
|
||||
const synOcrResult = ocrByName.get(normSyn);
|
||||
if (synOcrResult) {
|
||||
ocrResult = synOcrResult;
|
||||
ocrMatched++;
|
||||
ocrUnmatched.delete(normSyn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merge(record, pdfResult, ocrResult);
|
||||
});
|
||||
|
||||
// ── 통계 출력 ──────────────────────────────────────────────────────
|
||||
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
|
||||
console.log(
|
||||
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
|
||||
);
|
||||
console.log(`[OCR 매칭] ${ocrMatched}종`);
|
||||
|
||||
if (pdfUnmatched.size > 0) {
|
||||
const unmatchedList = Array.from(pdfUnmatched).sort();
|
||||
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
|
||||
writeFileSync(
|
||||
unmatchedPath,
|
||||
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
console.warn(
|
||||
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
|
||||
);
|
||||
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
|
||||
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
|
||||
}
|
||||
|
||||
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
|
||||
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
|
||||
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}종`);
|
||||
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}종`);
|
||||
}
|
||||
|
||||
main();
|
||||
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환.
|
||||
*
|
||||
* 입력: out/images/*.png (222개)
|
||||
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
|
||||
*
|
||||
* 환경변수: ANTHROPIC_API_KEY
|
||||
* 모델: claude-sonnet-4-5 (Vision + 비용 효율)
|
||||
* 동시성: 5, 재시도 3회
|
||||
*
|
||||
* 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, basename, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
|
||||
? resolve(process.env.HNS_OCR_IMG_DIR)
|
||||
: resolve(OUT_DIR, 'images');
|
||||
const OCR_PATH = process.env.HNS_OCR_OUT
|
||||
? resolve(process.env.HNS_OCR_OUT)
|
||||
: resolve(OUT_DIR, 'ocr.json');
|
||||
const FAIL_PATH = process.env.HNS_OCR_FAIL
|
||||
? resolve(process.env.HNS_OCR_FAIL)
|
||||
: resolve(OUT_DIR, 'ocr-failures.json');
|
||||
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
|
||||
const OCR_ONLY = process.env.HNS_OCR_ONLY
|
||||
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const CONCURRENCY = 5;
|
||||
const MAX_RETRIES = 3;
|
||||
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
|
||||
|
||||
이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다:
|
||||
- 상단: 국문명, 영문명
|
||||
- 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호
|
||||
- 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리
|
||||
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치
|
||||
|
||||
아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null.
|
||||
숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)").
|
||||
NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시).
|
||||
|
||||
응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이).
|
||||
|
||||
스키마:
|
||||
{
|
||||
"transportMethod": "",
|
||||
"state": "",
|
||||
"color": "",
|
||||
"odor": "",
|
||||
"flashPoint": "",
|
||||
"autoIgnition": "",
|
||||
"boilingPoint": "",
|
||||
"density": "",
|
||||
"solubility": "",
|
||||
"vaporPressure": "",
|
||||
"vaporDensity": "",
|
||||
"explosionRange": "",
|
||||
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
|
||||
"hazardClass": "",
|
||||
"ergNumber": "",
|
||||
"idlh": "",
|
||||
"aegl2": "",
|
||||
"erpg2": "",
|
||||
"twa": "",
|
||||
"stel": "",
|
||||
"responseDistanceFire": "",
|
||||
"responseDistanceSpillDay": "",
|
||||
"responseDistanceSpillNight": "",
|
||||
"marineResponse": "",
|
||||
"ppeClose": "",
|
||||
"ppeFar": "",
|
||||
"msds": {
|
||||
"hazard": "",
|
||||
"firstAid": "",
|
||||
"fireFighting": "",
|
||||
"spillResponse": "",
|
||||
"exposure": "",
|
||||
"regulation": ""
|
||||
},
|
||||
"emsCode": "",
|
||||
"emsFire": "",
|
||||
"emsSpill": "",
|
||||
"emsFirstAid": "",
|
||||
"sebc": ""
|
||||
}`;
|
||||
|
||||
interface OcrResult {
|
||||
transportMethod?: string;
|
||||
state?: string;
|
||||
color?: string;
|
||||
odor?: string;
|
||||
flashPoint?: string;
|
||||
autoIgnition?: string;
|
||||
boilingPoint?: string;
|
||||
density?: string;
|
||||
solubility?: string;
|
||||
vaporPressure?: string;
|
||||
vaporDensity?: string;
|
||||
explosionRange?: string;
|
||||
nfpa?: { health: number; fire: number; reactivity: number; special: string };
|
||||
hazardClass?: string;
|
||||
ergNumber?: string;
|
||||
idlh?: string;
|
||||
aegl2?: string;
|
||||
erpg2?: string;
|
||||
twa?: string;
|
||||
stel?: string;
|
||||
responseDistanceFire?: string;
|
||||
responseDistanceSpillDay?: string;
|
||||
responseDistanceSpillNight?: string;
|
||||
marineResponse?: string;
|
||||
ppeClose?: string;
|
||||
ppeFar?: string;
|
||||
msds?: {
|
||||
hazard?: string;
|
||||
firstAid?: string;
|
||||
fireFighting?: string;
|
||||
spillResponse?: string;
|
||||
exposure?: string;
|
||||
regulation?: string;
|
||||
};
|
||||
emsCode?: string;
|
||||
emsFire?: string;
|
||||
emsSpill?: string;
|
||||
emsFirstAid?: string;
|
||||
sebc?: string;
|
||||
}
|
||||
|
||||
function loadExisting<T>(path: string, fallback: T): T {
|
||||
if (!existsSync(path)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJson(text: string): OcrResult | null {
|
||||
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
|
||||
const firstBrace = cleaned.indexOf('{');
|
||||
const lastBrace = cleaned.lastIndexOf('}');
|
||||
if (firstBrace < 0 || lastBrace < 0) return null;
|
||||
try {
|
||||
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
|
||||
const imageData = readFileSync(imagePath).toString('base64');
|
||||
const ext = extname(imagePath).slice(1).toLowerCase();
|
||||
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
|
||||
| 'image/png'
|
||||
| 'image/jpeg';
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: SYSTEM_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: mediaType, data: imageData },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const textBlock = response.content.find((b) => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') {
|
||||
throw new Error('응답에 텍스트 블록 없음');
|
||||
}
|
||||
const result = extractJson(textBlock.text);
|
||||
if (!result) {
|
||||
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function processWithRetry(
|
||||
client: Anthropic,
|
||||
imagePath: string,
|
||||
nameKr: string,
|
||||
): Promise<OcrResult> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await callVision(client, imagePath);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const wait = 1000 * Math.pow(2, attempt - 1);
|
||||
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const idx = cursor++;
|
||||
await worker(items[idx], idx);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new Anthropic();
|
||||
|
||||
if (!existsSync(IMG_DIR)) {
|
||||
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
|
||||
const images = OCR_ONLY
|
||||
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
|
||||
: allImages;
|
||||
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
|
||||
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
|
||||
|
||||
let pending = images.filter((f) => {
|
||||
const nameKr = basename(f, extname(f));
|
||||
return !(nameKr in existing);
|
||||
});
|
||||
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
|
||||
pending = pending.slice(0, OCR_LIMIT);
|
||||
}
|
||||
|
||||
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`);
|
||||
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
|
||||
console.log(`[출력] ${OCR_PATH}`);
|
||||
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
await runPool(pending, async (file, idx) => {
|
||||
const nameKr = basename(file, extname(file));
|
||||
const path = resolve(IMG_DIR, file);
|
||||
try {
|
||||
const result = await processWithRetry(client, path, nameKr);
|
||||
existing[nameKr] = result;
|
||||
delete failures[nameKr];
|
||||
done++;
|
||||
if (done % 10 === 0 || done === pending.length) {
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
failures[nameKr] = String(err).slice(0, 500);
|
||||
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
|
||||
|
||||
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
|
||||
console.log(` OCR 결과: ${OCR_PATH}`);
|
||||
if (Object.keys(failures).length > 0) {
|
||||
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
324
backend/scripts/hns-import/ocr-local.py
Normal file
324
backend/scripts/hns-import/ocr-local.py
Normal file
@ -0,0 +1,324 @@
|
||||
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
|
||||
|
||||
전용 venv(.venv)에 설치된 easyocr을 사용한다.
|
||||
|
||||
1. 이미지 → EasyOCR → (bbox, text, conf) 리스트
|
||||
2. y좌표로 행 그룹화 후 각 행 내 x좌표 정렬
|
||||
3. 레이블 키워드 기반 필드 매핑 (정규식)
|
||||
4. 결과를 out/ocr.json 에 누적 저장 (재실행 가능)
|
||||
|
||||
실행:
|
||||
cd backend/scripts/hns-import
|
||||
source .venv/Scripts/activate # Windows Git Bash
|
||||
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
|
||||
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
|
||||
|
||||
|
||||
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
|
||||
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
|
||||
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
|
||||
LABEL_CANDIDATES: dict[str, list[str]] = {
|
||||
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
|
||||
'unNumber': ['UN번호', 'UNNO', 'UN'],
|
||||
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
|
||||
'usage': ['용도'],
|
||||
'state': ['성상', '상태', '형태'],
|
||||
'color': ['색상', '색'],
|
||||
'odor': ['냄새'],
|
||||
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
|
||||
'autoIgnition': ['발화점', '발회점', '발하점'],
|
||||
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
|
||||
'density': ['비중'],
|
||||
'solubility': ['용해도', '용해'],
|
||||
'vaporPressure': ['증기압', '증기압력'],
|
||||
'vaporDensity': ['증기밀도'],
|
||||
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
|
||||
'idlh': ['IDLH'],
|
||||
'aegl2': ['AEGL-2', 'AEGL2'],
|
||||
'erpg2': ['ERPG-2', 'ERPG2'],
|
||||
'twa': ['TWA'],
|
||||
'stel': ['STEL'],
|
||||
'ergNumber': ['ERG번호', 'ERG'],
|
||||
'hazardClass': ['위험분류', '위험', '분류'],
|
||||
'synonymsKr': ['유사명'],
|
||||
'responseDistanceFire': ['대피거리', '머피거리'],
|
||||
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
|
||||
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
|
||||
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
|
||||
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
|
||||
'marineResponse': ['해상대응', '해상'],
|
||||
}
|
||||
|
||||
|
||||
def _norm_label(s: str) -> str:
|
||||
"""공백/특수문자 제거 후 비교용 정규화."""
|
||||
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
|
||||
|
||||
|
||||
LABEL_INDEX: dict[str, str] = {}
|
||||
for _field, _candidates in LABEL_CANDIDATES.items():
|
||||
for _cand in _candidates:
|
||||
LABEL_INDEX[_norm_label(_cand)] = _field
|
||||
|
||||
# NFPA 셀 값(한 자릿수 0~4) 추출용
|
||||
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
|
||||
|
||||
|
||||
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
|
||||
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
|
||||
if not items:
|
||||
return []
|
||||
heights = [it['y1'] - it['y0'] for it in items]
|
||||
median_h = sorted(heights)[len(heights) // 2]
|
||||
y_tol = max(8, median_h * y_tolerance_ratio)
|
||||
|
||||
sorted_items = sorted(items, key=lambda it: it['cy'])
|
||||
rows: list[list[dict]] = []
|
||||
for it in sorted_items:
|
||||
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
|
||||
rows[-1].append(it)
|
||||
else:
|
||||
rows.append([it])
|
||||
for row in rows:
|
||||
row.sort(key=lambda it: it['cx'])
|
||||
return rows
|
||||
|
||||
|
||||
def _match_label(text: str) -> str | None:
|
||||
key = _norm_label(text)
|
||||
if not key:
|
||||
return None
|
||||
# 정확 일치 우선
|
||||
if key in LABEL_INDEX:
|
||||
return LABEL_INDEX[key]
|
||||
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
|
||||
for cand_key, field in LABEL_INDEX.items():
|
||||
if len(cand_key) >= 2 and key.startswith(cand_key):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def parse_card(items: list[dict]) -> dict[str, Any]:
|
||||
"""OCR 결과 목록을 필드 dict로 변환."""
|
||||
rows = group_rows(items)
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# 1) 행 내 "레이블 → 값" 쌍 추출
|
||||
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
|
||||
for row in rows:
|
||||
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
|
||||
idx = 0
|
||||
while idx < len(row):
|
||||
field = _match_label(row[idx]['text'])
|
||||
if field:
|
||||
# 다음 non-label 조각을 값으로 취함
|
||||
value_parts: list[str] = []
|
||||
j = idx + 1
|
||||
while j < len(row):
|
||||
nxt = row[j]
|
||||
if _match_label(nxt['text']):
|
||||
break
|
||||
value_parts.append(nxt['text'])
|
||||
j += 1
|
||||
if value_parts and field not in result:
|
||||
value = ' '.join(value_parts).strip()
|
||||
if value and value not in ('-', '–', 'N/A'):
|
||||
result[field] = value
|
||||
idx = j
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
|
||||
nfpa_idx_row: int | None = None
|
||||
for ri, row in enumerate(rows):
|
||||
for cell in row:
|
||||
if re.search(r'NFPA', cell['text']):
|
||||
nfpa_idx_row = ri
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
|
||||
candidates: list[int] = []
|
||||
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
|
||||
for cell in rows[ri]:
|
||||
m = NFPA_VALUE_RE.match(cell['text'].strip())
|
||||
if m:
|
||||
candidates.append(int(cell['text'].strip()))
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
result['nfpa'] = {
|
||||
'health': candidates[0],
|
||||
'fire': candidates[1],
|
||||
'reactivity': candidates[2],
|
||||
'special': '',
|
||||
}
|
||||
|
||||
# 3) EmS 코드 (F-x / S-x 패턴)
|
||||
all_text = ' '.join(cell['text'] for row in rows for cell in row)
|
||||
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
|
||||
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
|
||||
if f_match or s_match:
|
||||
parts = []
|
||||
if f_match:
|
||||
parts.append(f'F-{f_match.group(1)}')
|
||||
if s_match:
|
||||
parts.append(f'S-{s_match.group(1)}')
|
||||
if parts:
|
||||
result['emsCode'] = ', '.join(parts)
|
||||
|
||||
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
|
||||
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
|
||||
if erg_match:
|
||||
result['ergNumber'] = erg_match.group(1)
|
||||
|
||||
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _preprocess_image(pil_img, upscale: float = 2.5):
|
||||
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
import numpy as np
|
||||
|
||||
if pil_img.mode != 'RGB':
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
# 1) 업스케일 (LANCZOS)
|
||||
w, h = pil_img.size
|
||||
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
|
||||
|
||||
# 2) 대비 향상
|
||||
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
|
||||
|
||||
# 3) 샤프닝
|
||||
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
|
||||
|
||||
return np.array(pil_img)
|
||||
|
||||
|
||||
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
|
||||
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
|
||||
from PIL import Image
|
||||
with Image.open(image_path) as pil:
|
||||
img = _preprocess_image(pil, upscale=upscale)
|
||||
raw = reader.readtext(img, detail=1, paragraph=False)
|
||||
items: list[dict] = []
|
||||
for bbox, text, conf in raw:
|
||||
if not text or not str(text).strip():
|
||||
continue
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
items.append({
|
||||
'text': str(text).strip(),
|
||||
'cx': sum(xs) / 4.0,
|
||||
'cy': sum(ys) / 4.0,
|
||||
'x0': min(xs),
|
||||
'x1': max(xs),
|
||||
'y0': min(ys),
|
||||
'y1': max(ys),
|
||||
'conf': float(conf),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def load_json(path: Path, fallback):
|
||||
if not path.exists():
|
||||
return fallback
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--limit', type=int, default=None)
|
||||
parser.add_argument('--only', type=str, default=None,
|
||||
help='파이프(|)로 구분된 물질명 리스트')
|
||||
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
|
||||
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
|
||||
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='파싱 중간 결과(row 단위) 함께 출력')
|
||||
args = parser.parse_args()
|
||||
|
||||
import easyocr # noqa: WPS433
|
||||
|
||||
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
|
||||
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
||||
print('[로딩] 완료')
|
||||
|
||||
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
|
||||
if args.only:
|
||||
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
|
||||
images = [p for p in images if p.stem in only_set]
|
||||
|
||||
existing: dict[str, Any] = load_json(args.out, {})
|
||||
failures: dict[str, str] = load_json(args.fail, {})
|
||||
|
||||
pending = [p for p in images if p.stem not in existing]
|
||||
if args.limit:
|
||||
pending = pending[: args.limit]
|
||||
|
||||
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}개')
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
for i, path in enumerate(pending, start=1):
|
||||
name = path.stem
|
||||
try:
|
||||
items = run_ocr(path, reader)
|
||||
parsed = parse_card(items)
|
||||
if args.debug:
|
||||
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
|
||||
for row in group_rows(items):
|
||||
print(' |', ' │ '.join(f'{c["text"]}' for c in row))
|
||||
print(f' → parsed: {parsed}')
|
||||
existing[name] = parsed
|
||||
if name in failures:
|
||||
del failures[name]
|
||||
ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
failures[name] = f'{type(e).__name__}: {e}'[:500]
|
||||
fail += 1
|
||||
print(f'[실패] {name}: {e}')
|
||||
|
||||
if i % 10 == 0 or i == len(pending):
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
|
||||
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
|
||||
print(f' 결과: {args.out}')
|
||||
if failures:
|
||||
print(f' 실패 목록: {args.fail}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
backend/scripts/hns-import/requirements.txt
Normal file
29
backend/scripts/hns-import/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
||||
# extract-excel.py 용
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
|
||||
easyocr==1.7.2
|
||||
filelock==3.19.1
|
||||
fsspec==2025.10.0
|
||||
ImageIO==2.37.2
|
||||
Jinja2==3.1.6
|
||||
lazy-loader==0.5
|
||||
MarkupSafe==3.0.3
|
||||
mpmath==1.3.0
|
||||
networkx==3.2.1
|
||||
ninja==1.13.0
|
||||
numpy==2.0.2
|
||||
opencv-python-headless==4.13.0.92
|
||||
packaging==26.1
|
||||
pillow==11.3.0
|
||||
pyclipper==1.3.0.post6
|
||||
python-bidi==0.6.7
|
||||
PyYAML==6.0.3
|
||||
scikit-image==0.24.0
|
||||
scipy==1.13.1
|
||||
shapely==2.0.7
|
||||
sympy==1.14.0
|
||||
tifffile==2024.8.30
|
||||
torch==2.8.0
|
||||
torchvision==0.23.0
|
||||
typing_extensions==4.15.0
|
||||
@ -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} />}
|
||||
|
||||
19
frontend/src/components/hns/components/HNSSubstanceView.tsx
Normal file → Executable file
19
frontend/src/components/hns/components/HNSSubstanceView.tsx
Normal file → Executable file
@ -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">(i≤5, i+j≤5)</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';
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user