Compare commits
309 커밋
feature/wi
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b669b25f6e | |||
| 3b24e68547 | |||
| 04a205c9ec | |||
| c7c7bcdc45 | |||
| 846c63eae9 | |||
| 7de0b008c4 | |||
| d07bd3e0f1 | |||
| 9a85cb545c | |||
| 4a730d1582 | |||
| 1980463904 | |||
| 26b86a5a4b | |||
| 2ee4df5afb | |||
| 784b36e69b | |||
| c5dc5c60c5 | |||
| 9f4a578af3 | |||
| 1a31795970 | |||
| e31cb9b764 | |||
| c46bf50348 | |||
| 13bda6d15b | |||
| 650bb2b035 | |||
| 749453b9d1 | |||
| 0a9a5f433e | |||
| 3525e22590 | |||
| ee90b2efdb | |||
| bdb9f95b96 | |||
| 5987dcb991 | |||
| 798002580f | |||
| 0c61041974 | |||
| ea208cbf52 | |||
| c219adf2d9 | |||
| 2f5d2fdb1b | |||
| cd4717f303 | |||
| 7a5028226b | |||
| a6b0e92a8e | |||
| 765d3fb9d2 | |||
| 38d931db65 | |||
| 28544d5c8f | |||
| 10510a0410 | |||
| 1da2553694 | |||
| 7fa3fa6a2e | |||
| 1f66723060 | |||
| 2d6827c0a9 | |||
| 2082e9a79b | |||
| 988cc47e9f | |||
| 0daae3c807 | |||
| fa5c7f518f | |||
| 72ead1140f | |||
| 938665e323 | |||
| 29c5293ce7 | |||
| ae0a17990b | |||
| 6b19d34e5b | |||
| 679649ab8c | |||
| 279dcbc0e1 | |||
| 2fe9deeabe | |||
| 388116aa88 | |||
| 3eb66e2e54 | |||
| 15ca946a00 | |||
| 20d5c08bc7 | |||
| 8a0e5daf60 | |||
| c12e747b14 | |||
| 69b01fca9e | |||
| fef7583eb5 | |||
| f47aeef3ce | |||
| 8093727efe | |||
| 547e83e617 | |||
| af4ab9dd80 | |||
| 28931d9a5e | |||
| ad24445101 | |||
| 4f5260ae12 | |||
| 9630b1daac | |||
| bf0de764c6 | |||
| 965b238b08 | |||
| 2640d882da | |||
|
|
387e2a2e40 | ||
|
|
1142e0cc46 | ||
| 5de10662a7 | |||
| 972e6319cc | |||
| 0d53f850b2 | |||
| 7e0da5ea76 | |||
| cc3e0c5596 | |||
| 1ef0f5bce9 | |||
| d8d236c624 | |||
| a33dd09485 | |||
| f375ecc3ab | |||
| 9e51651fc7 | |||
| 4065ec76ef | |||
| 109c0d2480 | |||
| 4d71ca3a01 | |||
| 04f89ad24c | |||
| fbdf0e9122 | |||
| 646fa38f39 | |||
| 6620f00ee1 | |||
| e4b9c3e5dd | |||
| 7921bfef96 | |||
| 77d36ec8d0 | |||
| c4b9b85b24 | |||
| 5ea904fc3a | |||
| 7cdbc8664f | |||
| 5b36ea3991 | |||
| afa1d16b6b | |||
| f4630429f7 | |||
| 1bb0154153 | |||
| 76ab75f561 | |||
| f93aceeef0 | |||
| c7b0b7a3c2 | |||
| 5489bb0db5 | |||
| a511c0280e | |||
| 6803fc156c | |||
| 08bcfbf24d | |||
| a0be19d060 | |||
| 42d749426e | |||
| c77ac4e7a8 | |||
| 38d11df363 | |||
| dafd6cc1ac | |||
| 7a8e2ddea1 | |||
| a50b149dda | |||
| 5ae838c3a9 | |||
| a474cf6d1d | |||
| 625b15e395 | |||
| 440e6fd9fd | |||
| a719130f20 | |||
| f960660f3b | |||
| 7d2a889e11 | |||
| 0da3adb793 | |||
| c40711cae1 | |||
| 7d3b5ed419 | |||
| 0bf7587a1b | |||
| d931219169 | |||
| 2a99ffbbe1 | |||
| a86188f473 | |||
| 5a792bb53c | |||
| 2c0f43962b | |||
| 4361fdbf2d | |||
| 8eda3f27a0 | |||
| 1458f4b1de | |||
| 0e6d63f1f0 | |||
| 7890651e90 | |||
| d8a5acc1e6 | |||
| 5e2076647c | |||
| 71cdc634c6 | |||
| 6d5fb70020 | |||
| d10b27db87 | |||
| 837832b000 | |||
| f3cfc86921 | |||
| e2254cc960 | |||
| d71c43ae5a | |||
| 4869bbbed4 | |||
| c92014f3a3 | |||
| 9bae76f1d4 | |||
| 2cdd9cf52b | |||
| 3a224ea649 | |||
| 29477e4e2a | |||
| 8fb3c67307 | |||
| af1a7f80b9 | |||
| 944995bc50 | |||
| 6c9b212632 | |||
| 9d630a9466 | |||
| 94e0837072 | |||
| 2472be673b | |||
| 960d035700 | |||
| ff202c6e05 | |||
| 4620a2a3c9 | |||
| e285f2330f | |||
| ebe76176e3 | |||
| 9cfa357f7e | |||
| 3d4801c7ea | |||
| 0e34b6fa90 | |||
| c01db13b22 | |||
| fbbf36020b | |||
| bc7e966cb1 | |||
| b7943729f7 | |||
| 696c2d5b7c | |||
| ce627156e3 | |||
| c07de4251e | |||
| bd62570e7c | |||
| 1be8c188f7 | |||
| 6c68d04fc3 | |||
| 8666131929 | |||
| 18f70ffab9 | |||
| e50b3304c2 | |||
| 24a8bae625 | |||
| 448413f5b1 | |||
| 6433757262 | |||
| f8ee28fa9a | |||
| 4ae6ee754e | |||
| 3fd5537553 | |||
| 5f84d5f11e | |||
| 7a80eaf75e | |||
| a55d3c18c2 | |||
| f0cd410771 | |||
| b407dba441 | |||
| e4cd57a56d | |||
| 827061b17c | |||
| 932c8eca3f | |||
| 37397d4d6c | |||
| a3b1a701e4 | |||
| 7bbc1479fc | |||
| ebe49c7b77 | |||
| 265ffe65ea | |||
| d4b3bbdc99 | |||
| 50945c9049 | |||
| d0491c3f0f | |||
| da1d4a2f31 | |||
| 4b341b4812 | |||
| e06287ba5b | |||
|
|
aefd38b3bc | ||
| 087fe57e0d | |||
| b4ddbff770 | |||
| f0f1d0e14d | |||
| c3bb23f919 | |||
| 7a1eb80627 | |||
| 84fa49189c | |||
| d6d476e9bd | |||
| 0186b652a7 | |||
| c95a906b35 | |||
| 3a3ad60194 | |||
| e4fa46db81 | |||
| 5f622c7520 | |||
| 213ab224b7 | |||
| 4d22916ae1 | |||
| 1a118ba3c0 | |||
| 2b48acf2ae | |||
| 503b9a1d3c | |||
| 9881b99ee7 | |||
| 7cef385c3a | |||
| 9dd56493da | |||
| 409905d66a | |||
| 007c950e8c | |||
| 7f276bebe2 | |||
| e32c630da5 | |||
| 9c44ab4ffa | |||
| f336f6b93a | |||
| 5865734b15 | |||
| 36829b9ff4 | |||
| 931971dc5c | |||
| abab9a581f | |||
| 94b162aa2a | |||
| 7fff1dae19 | |||
| ef2ef8a542 | |||
| d9fb4506bc | |||
|
|
0cf3ff1ea0 | ||
|
|
7949b96866 | ||
|
|
6bea387ee2 | ||
| 16db2e1925 | |||
| 44a7d0030a | |||
| fbc2173027 | |||
| 63cf614365 | |||
| 86e534b6dc | |||
| 621d8e3516 | |||
| c7c7537dbb | |||
| 6b9ed4e06e | |||
| 33155e0f87 | |||
| e096010ea9 | |||
| 20890fe8a9 | |||
| e8b5a4e093 | |||
| 734ebeeaab | |||
|
|
7110d76276 | ||
|
|
7fb98ebb08 | ||
|
|
8c0ada08fd | ||
|
|
39277c1c02 | ||
|
|
0549fb879f | ||
|
|
f0fee9d92b | ||
|
|
5191e606a1 | ||
|
|
19fdc489f3 | ||
|
|
7564f42918 | ||
|
|
00e7a3e70a | ||
|
|
0c4bfb2f24 | ||
|
|
044994bd57 | ||
|
|
326237b91f | ||
|
|
bbdb654857 | ||
|
|
f5bcbde40e | ||
|
|
6b5d5f89dd | ||
| 595fac5adb | |||
| 99c2e8d6ae | |||
| 34ed6b6291 | |||
| b24a6f4c54 | |||
| 130f563ab2 | |||
| 075c6cd9bc | |||
| c4f11423aa | |||
| 301df70376 | |||
| da077bf884 | |||
| a3b2787ba0 | |||
|
|
fbef59341e | ||
|
|
615f7f9277 | ||
|
|
c4728be7a1 | ||
|
|
9386c1e29a | ||
|
|
939bd0fc88 | ||
|
|
48b5e876ac | ||
|
|
97e9d58cc1 | ||
|
|
6944a9e342 | ||
|
|
fb74df5c1f | ||
|
|
b25eccee37 | ||
|
|
bb3bd8358b | ||
|
|
9c834c4e5e | ||
|
|
a470df5518 | ||
| 3ad24a6e1a | |||
|
|
d9a51d2101 | ||
| 714bac9f24 | |||
| ecca827098 | |||
|
|
ed3758645c | ||
|
|
df01fd1b1d | ||
|
|
5b734d3cf1 | ||
|
|
9574594151 | ||
|
|
ce80e620c1 | ||
|
|
476b6b99ac | ||
|
|
626fea4c75 | ||
|
|
e65e722059 | ||
|
|
99c2c142be | ||
| dc4be29cfc |
@ -5,29 +5,30 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(curl -s *)",
|
"Bash(*)",
|
||||||
"Bash(fnm *)",
|
"Bash(npm run *)",
|
||||||
"Bash(git add *)",
|
"Bash(npm install *)",
|
||||||
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git log *)",
|
||||||
"Bash(git branch *)",
|
"Bash(git branch *)",
|
||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)",
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git config *)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
"Bash(git remote *)",
|
"Bash(git remote *)",
|
||||||
|
"Bash(git config *)",
|
||||||
"Bash(git rev-parse *)",
|
"Bash(git rev-parse *)",
|
||||||
"Bash(git show *)",
|
"Bash(git show *)",
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git tag *)",
|
"Bash(git tag *)",
|
||||||
"Bash(node *)",
|
"Bash(curl -s *)",
|
||||||
"Bash(npm install *)",
|
"Bash(fnm *)"
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(npm test *)",
|
|
||||||
"Bash(npx *)"
|
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
@ -84,6 +85,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"deny": [],
|
"enabledPlugins": {
|
||||||
"allow": []
|
"frontend-design@claude-plugins-official": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-13",
|
"applied_date": "2026-04-17",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
# commit-msg hook
|
# commit-msg hook
|
||||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
# 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_FILE="$1"
|
||||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|||||||
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||||
|
|
||||||
|
$raw = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 }
|
||||||
|
$toolName = $Matches[1]
|
||||||
|
|
||||||
|
if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 }
|
||||||
|
|
||||||
|
if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 }
|
||||||
|
$sessionId = $Matches[1]
|
||||||
|
|
||||||
|
$hooksDir = '.github\java-upgrade\hooks'
|
||||||
|
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null }
|
||||||
|
|
||||||
|
$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n"
|
||||||
|
[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false))
|
||||||
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
TOOL_NAME="${INPUT#*\"tool_name\":\"}"
|
||||||
|
TOOL_NAME="${TOOL_NAME%%\"*}"
|
||||||
|
|
||||||
|
case "$TOOL_NAME" in
|
||||||
|
run_in_terminal|appmod-*) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$INPUT" in
|
||||||
|
*'"session_id":"'*) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SESSION_ID="${INPUT#*\"session_id\":\"}"
|
||||||
|
SESSION_ID="${SESSION_ID%%\"*}"
|
||||||
|
[ -z "$SESSION_ID" ] && exit 0
|
||||||
|
|
||||||
|
HOOKS_DIR=".github/java-upgrade/hooks"
|
||||||
|
mkdir -p "$HOOKS_DIR"
|
||||||
|
|
||||||
|
LINE=$(printf '%s' "$INPUT" | tr -d '\r\n')
|
||||||
|
printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json"
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -79,6 +79,9 @@ prediction/image/**/*.pth
|
|||||||
frontend/public/hns-manual/pages/
|
frontend/public/hns-manual/pages/
|
||||||
frontend/public/hns-manual/images/
|
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 Code (team workflow tracked, override global gitignore)
|
||||||
!.claude/
|
!.claude/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@ -99,3 +102,10 @@ frontend/public/hns-manual/images/
|
|||||||
# Lock files (keep for reproducible builds)
|
# Lock files (keep for reproducible builds)
|
||||||
!frontend/package-lock.json
|
!frontend/package-lock.json
|
||||||
!backend/package-lock.json
|
!backend/package-lock.json
|
||||||
|
|
||||||
|
|
||||||
|
# mcp
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# python
|
||||||
|
.venv
|
||||||
@ -54,7 +54,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
│ └── components/ 탭 단위 패키지 (@components/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -96,7 +96,7 @@ wing/
|
|||||||
### Path Alias
|
### Path Alias
|
||||||
|
|
||||||
- `@common/*` -> `src/common/*` (공통 모듈)
|
- `@common/*` -> `src/common/*` (공통 모듈)
|
||||||
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
|
- `@components/*` -> `src/components/*` (탭 패키지)
|
||||||
|
|
||||||
## 팀 컨벤션
|
## 팀 컨벤션
|
||||||
|
|
||||||
@ -107,6 +107,8 @@ wing/
|
|||||||
- `naming.md` -- 네이밍 규칙
|
- `naming.md` -- 네이밍 규칙
|
||||||
- `testing.md` -- 테스트 규칙
|
- `testing.md` -- 테스트 규칙
|
||||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||||
|
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
||||||
|
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
||||||
|
|
||||||
## 개발 문서 (docs/)
|
## 개발 문서 (docs/)
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터
|
|||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
|
Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*`
|
||||||
|
|
||||||
```
|
```
|
||||||
wing/
|
wing/
|
||||||
@ -95,7 +95,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
|
|||||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"pg": "^8.19.0"
|
"pg": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@ -34,6 +35,37 @@
|
|||||||
"typescript": "^5.7.3"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@ -1689,6 +1721,20 @@
|
|||||||
"bignumber.js": "^9.0.0"
|
"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": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@ -2618,6 +2664,13 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"pg": "^8.19.0"
|
"pg": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
140
backend/scripts/hns-import/README.md
Normal file
140
backend/scripts/hns-import/README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# HNS 물질 Import 파이프라인
|
||||||
|
|
||||||
|
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
|
||||||
|
|
||||||
|
## 파이프라인 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[Excel xlsm] [PDF 물질정보집 (193종)]
|
||||||
|
├─ (1) extract-excel.py (2b) extract-pdf.py
|
||||||
|
│ → out/base.json → out/pdf-data.json
|
||||||
|
└─ (2a) extract-images.py ──────────────────────────┐
|
||||||
|
→ out/images/*.png │
|
||||||
|
↓ │
|
||||||
|
(3) ocr-images.ts │
|
||||||
|
→ out/ocr.json │
|
||||||
|
↓ ↓
|
||||||
|
(4) merge-data.ts ←──────────────┘
|
||||||
|
→ frontend/src/data/hnsSubstanceData.json
|
||||||
|
↓
|
||||||
|
(5) tsx src/db/seedHns.ts
|
||||||
|
→ HNS_SUBSTANCE 테이블
|
||||||
|
```
|
||||||
|
|
||||||
|
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
|
||||||
|
|
||||||
|
## 전제 조건
|
||||||
|
|
||||||
|
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
|
||||||
|
- Node.js 20
|
||||||
|
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
|
||||||
|
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||||
|
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
### 1) Excel 메타 시트 파싱
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python scripts/hns-import/extract-excel.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||||
|
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
|
||||||
|
- 출력: `scripts/hns-import/out/base.json`
|
||||||
|
|
||||||
|
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/hns-import/extract-images.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
|
||||||
|
|
||||||
|
### 2b) PDF 물질정보집 파싱 ★ 권장
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/hns-import/extract-pdf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||||
|
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
|
||||||
|
- 출력: `scripts/hns-import/out/pdf-data.json`
|
||||||
|
|
||||||
|
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
|
cd backend
|
||||||
|
npx tsx scripts/hns-import/ocr-images.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
|
||||||
|
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
|
||||||
|
|
||||||
|
### 4) 최종 JSON 병합
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx tsx scripts/hns-import/merge-data.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
|
||||||
|
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
|
||||||
|
|
||||||
|
### 5) DB 재시드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx tsx src/db/seedHns.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
|
||||||
|
|
||||||
|
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python scripts/hns-import/extract-pdf.py && \
|
||||||
|
npx tsx scripts/hns-import/merge-data.ts && \
|
||||||
|
npx tsx src/db/seedHns.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 현재 데이터 현황 (2024-04 기준)
|
||||||
|
|
||||||
|
| 항목 | 이전 (OCR) | 현재 (PDF) |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| 총 물질 수 | 514종 | 514종 |
|
||||||
|
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
|
||||||
|
| NFPA 코드 있음 | ~150종 | 201종 |
|
||||||
|
| CAS 번호 있음 | ~380종 | 504종 |
|
||||||
|
| 해양거동 있음 | ~0종 | 206종 |
|
||||||
|
| 한국어 유사명 있음 | ~200종 | 449종 |
|
||||||
|
|
||||||
|
## 재실행 안내
|
||||||
|
|
||||||
|
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
|
||||||
|
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
|
||||||
|
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
|
||||||
|
|
||||||
|
## 알려진 이슈
|
||||||
|
|
||||||
|
### 1) PDF 매칭 실패 35종
|
||||||
|
|
||||||
|
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
|
||||||
|
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
|
||||||
|
|
||||||
|
**원인:**
|
||||||
|
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin` ↔ `디이알 383`)
|
||||||
|
- 동일 CAS 충돌 (예: `컨덴세이트`와 `나프타`가 같은 CAS)
|
||||||
|
- 표기 차이 (예: `아이소파-G` ↔ `아이소파 G`)
|
||||||
|
|
||||||
|
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
|
||||||
|
|
||||||
|
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
|
||||||
|
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
|
||||||
|
|
||||||
|
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
|
||||||
|
|
||||||
|
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.
|
||||||
236
backend/scripts/hns-import/extract-excel.py
Normal file
236
backend/scripts/hns-import/extract-excel.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""Excel 메타 시트 → base.json 변환.
|
||||||
|
|
||||||
|
처리 시트:
|
||||||
|
- 화물적부도 화물코드: 1,345개 기본 레코드
|
||||||
|
- 동의어: 215개 한/영 유사명
|
||||||
|
- IBC CODE: IMO IBC 분류
|
||||||
|
|
||||||
|
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts)에 맞춘 JSON 배열.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
SOURCE_XLSX = Path(os.environ.get(
|
||||||
|
'HNS_SOURCE_XLSX',
|
||||||
|
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def norm_key(s: str | None) -> str:
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'\s+', '', str(s)).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def split_synonyms(raw: str | None) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
# 원본은 "·" 또는 "/" 구분, 개행 포함
|
||||||
|
parts = re.split(r'[·/\n]+', str(raw))
|
||||||
|
cleaned = [p.strip() for p in parts if p and p.strip()]
|
||||||
|
return ' / '.join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ''
|
||||||
|
return str(v).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f'[읽기] {SOURCE_XLSX}')
|
||||||
|
if not SOURCE_XLSX.exists():
|
||||||
|
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
|
||||||
|
|
||||||
|
# ────────── 화물적부도 화물코드 ──────────
|
||||||
|
ws = wb['화물적부도 화물코드']
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
|
||||||
|
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
|
||||||
|
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}개')
|
||||||
|
|
||||||
|
# ────────── 동의어 시트 ──────────
|
||||||
|
ws_syn = wb['동의어']
|
||||||
|
syn_rows = list(ws_syn.iter_rows(values_only=True))
|
||||||
|
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
|
||||||
|
syn_map: dict[str, dict] = {}
|
||||||
|
for r in syn_rows[2:]:
|
||||||
|
if not r or r[0] is None:
|
||||||
|
continue
|
||||||
|
name_kr = clean_text(r[1])
|
||||||
|
cas = clean_text(r[3])
|
||||||
|
if not name_kr and not cas:
|
||||||
|
continue
|
||||||
|
key = norm_key(name_kr) or norm_key(cas)
|
||||||
|
syn_map[key] = {
|
||||||
|
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
|
||||||
|
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
|
||||||
|
}
|
||||||
|
print(f'[동의어] {len(syn_map)}개')
|
||||||
|
|
||||||
|
# ────────── IBC CODE 시트 ──────────
|
||||||
|
ws_ibc = wb['IBC CODE']
|
||||||
|
ibc_map: dict[str, dict] = {}
|
||||||
|
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
|
||||||
|
if i < 2:
|
||||||
|
continue # header 2 rows
|
||||||
|
if not r or not r[0]:
|
||||||
|
continue
|
||||||
|
name_en = clean_text(r[0])
|
||||||
|
key = norm_key(name_en)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
ibc_map[key] = {
|
||||||
|
'ibcHazard': clean_text(r[2]), # 위험성 S/P
|
||||||
|
'ibcShipType': clean_text(r[3]), # 선박형식
|
||||||
|
'ibcTankType': clean_text(r[4]), # 탱크형식
|
||||||
|
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
|
||||||
|
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
|
||||||
|
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
|
||||||
|
}
|
||||||
|
print(f'[IBC CODE] {len(ibc_map)}개')
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
# ────────── 통합 레코드 생성 ──────────
|
||||||
|
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
|
||||||
|
groups: dict[str, list] = defaultdict(list)
|
||||||
|
for r in cargo_rows:
|
||||||
|
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
|
||||||
|
# 그룹 키: CAS 우선, 없으면 국문명
|
||||||
|
cas_s = clean_text(cas)
|
||||||
|
group_key = cas_s if cas_s else norm_key(name_kr)
|
||||||
|
groups[group_key].append({
|
||||||
|
'abbreviation': clean_text(abbr),
|
||||||
|
'nameKr': clean_text(name_kr),
|
||||||
|
'nameEn': clean_text(name_en),
|
||||||
|
'synonymsKr': split_synonyms(syn_kr),
|
||||||
|
'synonymsEn': split_synonyms(syn_en),
|
||||||
|
'usage': clean_text(usage),
|
||||||
|
'unNumber': clean_text(un),
|
||||||
|
'casNumber': cas_s,
|
||||||
|
})
|
||||||
|
|
||||||
|
records: list[dict] = []
|
||||||
|
next_id = 1
|
||||||
|
for group_key, entries in groups.items():
|
||||||
|
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
|
||||||
|
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
|
||||||
|
|
||||||
|
name_kr_key = norm_key(primary['nameKr'])
|
||||||
|
name_en_key = norm_key(primary['nameEn'])
|
||||||
|
|
||||||
|
# 동의어 병합
|
||||||
|
syn_extra = syn_map.get(name_kr_key, {})
|
||||||
|
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
|
||||||
|
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
|
||||||
|
|
||||||
|
# IBC 병합 (영문명 기준)
|
||||||
|
ibc = ibc_map.get(name_en_key, {})
|
||||||
|
|
||||||
|
# cargoCodes 집계
|
||||||
|
cargo_codes = [
|
||||||
|
{
|
||||||
|
'code': e['abbreviation'],
|
||||||
|
'name': e['nameEn'] or e['nameKr'],
|
||||||
|
'company': '국제공통',
|
||||||
|
'source': '적부도',
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
if e['abbreviation']
|
||||||
|
]
|
||||||
|
|
||||||
|
record = {
|
||||||
|
'id': next_id,
|
||||||
|
'abbreviation': primary['abbreviation'],
|
||||||
|
'nameKr': primary['nameKr'],
|
||||||
|
'nameEn': primary['nameEn'],
|
||||||
|
'synonymsKr': synonyms_kr,
|
||||||
|
'synonymsEn': synonyms_en,
|
||||||
|
'unNumber': primary['unNumber'],
|
||||||
|
'casNumber': primary['casNumber'],
|
||||||
|
'transportMethod': '',
|
||||||
|
'sebc': '',
|
||||||
|
# 물리·화학 (OCR 단계에서 채움)
|
||||||
|
'usage': primary['usage'],
|
||||||
|
'state': '',
|
||||||
|
'color': '',
|
||||||
|
'odor': '',
|
||||||
|
'flashPoint': '',
|
||||||
|
'autoIgnition': '',
|
||||||
|
'boilingPoint': '',
|
||||||
|
'density': '',
|
||||||
|
'solubility': '',
|
||||||
|
'vaporPressure': '',
|
||||||
|
'vaporDensity': '',
|
||||||
|
'explosionRange': '',
|
||||||
|
# 위험도
|
||||||
|
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
|
||||||
|
'hazardClass': '',
|
||||||
|
'ergNumber': '',
|
||||||
|
'idlh': '',
|
||||||
|
'aegl2': '',
|
||||||
|
'erpg2': '',
|
||||||
|
# 방제
|
||||||
|
'responseDistanceFire': '',
|
||||||
|
'responseDistanceSpillDay': '',
|
||||||
|
'responseDistanceSpillNight': '',
|
||||||
|
'marineResponse': '',
|
||||||
|
'ppeClose': '',
|
||||||
|
'ppeFar': '',
|
||||||
|
# MSDS
|
||||||
|
'msds': {
|
||||||
|
'hazard': '',
|
||||||
|
'firstAid': '',
|
||||||
|
'fireFighting': '',
|
||||||
|
'spillResponse': '',
|
||||||
|
'exposure': '',
|
||||||
|
'regulation': '',
|
||||||
|
},
|
||||||
|
# IBC
|
||||||
|
'ibcHazard': ibc.get('ibcHazard', ''),
|
||||||
|
'ibcShipType': ibc.get('ibcShipType', ''),
|
||||||
|
'ibcTankType': ibc.get('ibcTankType', ''),
|
||||||
|
'ibcDetection': ibc.get('ibcDetection', ''),
|
||||||
|
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
|
||||||
|
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
|
||||||
|
# EmS (OCR에서 채움)
|
||||||
|
'emsCode': '',
|
||||||
|
'emsFire': '',
|
||||||
|
'emsSpill': '',
|
||||||
|
'emsFirstAid': '',
|
||||||
|
# cargoCodes / portFrequency
|
||||||
|
'cargoCodes': cargo_codes,
|
||||||
|
'portFrequency': [],
|
||||||
|
}
|
||||||
|
records.append(record)
|
||||||
|
next_id += 1
|
||||||
|
|
||||||
|
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
out_path = OUT_DIR / 'base.json'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
170
backend/scripts/hns-import/extract-images.py
Normal file
170
backend/scripts/hns-import/extract-images.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
|
||||||
|
|
||||||
|
엑셀 워크시트 → drawing → image 관계 체인을 추적해
|
||||||
|
각 물질 시트의 핵심 이미지만 out/images/{nameKr}.png 로 저장.
|
||||||
|
|
||||||
|
동시에 out/image-map.json 생성 (파일명 ↔ 시트명/국문명 매핑).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
IMG_DIR = OUT_DIR / 'images'
|
||||||
|
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
SOURCE_XLSX = Path(os.environ.get(
|
||||||
|
'HNS_SOURCE_XLSX',
|
||||||
|
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||||
|
))
|
||||||
|
|
||||||
|
NS = {
|
||||||
|
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||||
|
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||||
|
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 메타 시트(데이터 시트)는 스킵
|
||||||
|
SKIP_SHEETS = {
|
||||||
|
'화물적부도 화물코드',
|
||||||
|
'항구별 코드',
|
||||||
|
'동의어',
|
||||||
|
'IBC CODE',
|
||||||
|
'경계선',
|
||||||
|
}
|
||||||
|
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
|
||||||
|
SKIP_PATTERN = re.compile(r'^\d{3}$')
|
||||||
|
|
||||||
|
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
|
||||||
|
MIN_IMAGE_SIZE = 50_000 # 50 KB
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(name: str) -> str:
|
||||||
|
name = name.strip().rstrip(',').strip()
|
||||||
|
name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def norm_path(p: str) -> str:
|
||||||
|
return os.path.normpath(p).replace(os.sep, '/')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f'[읽기] {SOURCE_XLSX}')
|
||||||
|
if not SOURCE_XLSX.exists():
|
||||||
|
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||||
|
|
||||||
|
image_map: dict[str, dict] = {}
|
||||||
|
saved = 0
|
||||||
|
skipped = 0
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
with zipfile.ZipFile(SOURCE_XLSX) as z:
|
||||||
|
# 1) workbook.xml → sheet 목록
|
||||||
|
with z.open('xl/workbook.xml') as f:
|
||||||
|
wb_root = ET.parse(f).getroot()
|
||||||
|
sheets = []
|
||||||
|
for s in wb_root.findall('m:sheets/m:sheet', NS):
|
||||||
|
sheets.append({
|
||||||
|
'name': s.get('name'),
|
||||||
|
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
|
||||||
|
})
|
||||||
|
with z.open('xl/_rels/workbook.xml.rels') as f:
|
||||||
|
rels_root = ET.parse(f).getroot()
|
||||||
|
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
|
||||||
|
for s in sheets:
|
||||||
|
s['target'] = rid_target.get(s['rid'])
|
||||||
|
|
||||||
|
print(f'[시트] 총 {len(sheets)}개')
|
||||||
|
|
||||||
|
for s in sheets:
|
||||||
|
name = s['name']
|
||||||
|
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sheet_file = 'xl/' + s['target']
|
||||||
|
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
|
||||||
|
try:
|
||||||
|
with z.open(rels_file) as f:
|
||||||
|
srels = ET.parse(f).getroot()
|
||||||
|
except KeyError:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 시트 → drawing
|
||||||
|
drawing_rel = None
|
||||||
|
for r in srels.findall('pr:Relationship', NS):
|
||||||
|
t = r.get('Target') or ''
|
||||||
|
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
|
||||||
|
drawing_rel = t
|
||||||
|
break
|
||||||
|
if not drawing_rel:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
|
||||||
|
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
|
||||||
|
try:
|
||||||
|
with z.open(drawing_rels_path) as f:
|
||||||
|
drels = ET.parse(f).getroot()
|
||||||
|
except KeyError:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# drawing → images
|
||||||
|
image_paths: list[str] = []
|
||||||
|
for r in drels.findall('pr:Relationship', NS):
|
||||||
|
t = r.get('Target') or ''
|
||||||
|
if 'image' in t.lower():
|
||||||
|
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
|
||||||
|
image_paths.append(img_path)
|
||||||
|
if not image_paths:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
|
||||||
|
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
|
||||||
|
sized.sort(reverse=True)
|
||||||
|
largest_size, largest_path = sized[0]
|
||||||
|
if largest_size < MIN_IMAGE_SIZE:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
safe = safe_filename(name)
|
||||||
|
ext = os.path.splitext(largest_path)[1].lower() or '.png'
|
||||||
|
out_name = f'{safe}{ext}'
|
||||||
|
out_path = IMG_DIR / out_name
|
||||||
|
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
|
||||||
|
fout.write(fin.read())
|
||||||
|
image_map[out_name] = {
|
||||||
|
'sheetName': name,
|
||||||
|
'nameKr': safe,
|
||||||
|
'source': largest_path,
|
||||||
|
'sizeBytes': largest_size,
|
||||||
|
}
|
||||||
|
saved += 1
|
||||||
|
if saved % 25 == 0:
|
||||||
|
print(f' {saved}개 저장 완료')
|
||||||
|
|
||||||
|
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
|
||||||
|
|
||||||
|
map_path = OUT_DIR / 'image-map.json'
|
||||||
|
with open(map_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(image_map, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f'[완료] 매핑 파일: {map_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
707
backend/scripts/hns-import/extract-pdf.py
Normal file
707
backend/scripts/hns-import/extract-pdf.py
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
"""PDF 물질정보집 → pdf-data.json 변환.
|
||||||
|
|
||||||
|
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
|
||||||
|
해양경찰청 발행 193종 물질 정보
|
||||||
|
|
||||||
|
PDF 구조:
|
||||||
|
- 페이지 1-21: 표지/머리말/목차
|
||||||
|
- 페이지 22-407: 193종 × 2페이지 물질 카드
|
||||||
|
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동 등
|
||||||
|
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
|
||||||
|
- 물질 NO(1-193) → 0-인덱스 시작 페이지: 21 + (NO-1) * 2
|
||||||
|
|
||||||
|
출력: out/pdf-data.json
|
||||||
|
{ [nameKr]: OcrResult } — merge-data.ts 와 동일한 키 구조
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
PDF_PATH = Path(os.environ.get(
|
||||||
|
'HNS_PDF_PATH',
|
||||||
|
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
|
||||||
|
))
|
||||||
|
|
||||||
|
# 전각 문자 → 반각 변환 테이블
|
||||||
|
_FULLWIDTH = str.maketrans(
|
||||||
|
'()tC°℃ ,',
|
||||||
|
'()tC℃℃ ,',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(s: str) -> str:
|
||||||
|
"""텍스트 정리."""
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
s = s.translate(_FULLWIDTH)
|
||||||
|
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
|
||||||
|
s = re.sub(r'(?<=[0-9])\s*[仁七부사 ](?=\s|$|이|이하)', '℃', s)
|
||||||
|
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '℃', s)
|
||||||
|
s = re.sub(r'\s+', ' ', s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def norm_key(s: str) -> str:
|
||||||
|
"""정규화 키: 공백/특수문자 제거 + 소문자."""
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cas(raw: str) -> str:
|
||||||
|
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
# 혼합물
|
||||||
|
if '혼합물' in raw:
|
||||||
|
return ''
|
||||||
|
# 특수 대시 → -
|
||||||
|
s = raw.replace('—', '-').replace('-', '-').replace('–', '-')
|
||||||
|
# OCR 오인식: 이,이, 공백 등 → 0
|
||||||
|
s = re.sub(r'[이oO0]', '0', s)
|
||||||
|
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
|
||||||
|
# CAS 포맷 검증 후 반환
|
||||||
|
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
|
||||||
|
if m:
|
||||||
|
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
|
||||||
|
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
|
||||||
|
s2 = re.sub(r'[^0-9\-]', '', s)
|
||||||
|
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
|
||||||
|
if m2:
|
||||||
|
return m2.group(1).lstrip('0') or '0'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def find_cas_in_text(text: str) -> str:
|
||||||
|
"""텍스트에서 CAS 번호 패턴 검색."""
|
||||||
|
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
|
||||||
|
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
|
||||||
|
for c in candidates:
|
||||||
|
cas = normalize_cas(c)
|
||||||
|
if cas and len(cas) >= 5:
|
||||||
|
return cas
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nfpa(text: str) -> dict | None:
|
||||||
|
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
|
||||||
|
m = re.search(r'건강\s*[::]\s*(\d)\s*화재\s*[::]\s*(\d)\s*반응\s*[::]\s*(\d)', text)
|
||||||
|
if m:
|
||||||
|
return {
|
||||||
|
'health': int(m.group(1)),
|
||||||
|
'fire': int(m.group(2)),
|
||||||
|
'reactivity': int(m.group(3)),
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
# 대안 패턴: 줄바꿈 포함
|
||||||
|
m2 = re.search(r'건강\s*[::]\s*(\d).*?화재\s*[::]\s*(\d).*?반응\s*[::]\s*(\d)', text, re.DOTALL)
|
||||||
|
if m2:
|
||||||
|
return {
|
||||||
|
'health': int(m2.group(1)),
|
||||||
|
'fire': int(m2.group(2)),
|
||||||
|
'reactivity': int(m2.group(3)),
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
|
||||||
|
"""레이블 직후 값 추출 (단순 패턴)."""
|
||||||
|
idx = text.find(label)
|
||||||
|
if idx < 0:
|
||||||
|
return ''
|
||||||
|
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
|
||||||
|
# 첫 비공백 줄 추출
|
||||||
|
lines = snippet.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
v = clean(line)
|
||||||
|
if v and v not in (':', ':', ''):
|
||||||
|
return v[:max_chars]
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_summary_card(text: str, index_entry: dict) -> dict:
|
||||||
|
"""요약 카드(첫 번째 페이지) 파싱."""
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
# 인화점
|
||||||
|
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and '위험' not in val and len(val) < 40:
|
||||||
|
result['flashPoint'] = val
|
||||||
|
|
||||||
|
# 발화점
|
||||||
|
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['autoIgnition'] = val
|
||||||
|
|
||||||
|
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
|
||||||
|
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
# 파편화된 텍스트 제거
|
||||||
|
if val and re.search(r'\d', val) and len(val) < 60:
|
||||||
|
result['vaporPressure'] = val
|
||||||
|
|
||||||
|
# 증기밀도 숫자값
|
||||||
|
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 20:
|
||||||
|
result['vaporDensity'] = val
|
||||||
|
|
||||||
|
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
|
||||||
|
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and '%' in val and len(val) < 30:
|
||||||
|
result['explosionRange'] = val
|
||||||
|
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
|
||||||
|
if not result.get('explosionRange'):
|
||||||
|
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
|
||||||
|
if m:
|
||||||
|
result['explosionRange'] = clean(m.group(1))
|
||||||
|
|
||||||
|
# 화재시 대피거리
|
||||||
|
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val:
|
||||||
|
result['responseDistanceFire'] = val
|
||||||
|
|
||||||
|
# 해양거동
|
||||||
|
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'해양거동\s+([^\n]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 80:
|
||||||
|
result['marineResponse'] = val
|
||||||
|
|
||||||
|
# 상온상태
|
||||||
|
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['state'] = val
|
||||||
|
|
||||||
|
# 냄새
|
||||||
|
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['odor'] = val
|
||||||
|
|
||||||
|
# 비중
|
||||||
|
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['density'] = val
|
||||||
|
|
||||||
|
# 색상
|
||||||
|
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['color'] = val
|
||||||
|
|
||||||
|
# 이격거리 / 방호거리 거리 숫자 추출
|
||||||
|
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
|
||||||
|
if m_hot:
|
||||||
|
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
|
||||||
|
|
||||||
|
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
|
||||||
|
if m_warm:
|
||||||
|
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_detail_card(text: str) -> dict:
|
||||||
|
"""상세 카드(두 번째 페이지) 파싱."""
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
|
||||||
|
# 형식: "001 과산화수소" or "0이 과산화수소"
|
||||||
|
first_lines = text.strip().split('\n')[:4]
|
||||||
|
for line in first_lines:
|
||||||
|
line = line.strip()
|
||||||
|
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
|
||||||
|
m = re.match(r'^[0-9이이아오0-9]{2,3}\s+([\w\s\-,./()()]+)$', line)
|
||||||
|
if m:
|
||||||
|
candidate = clean(m.group(1).strip())
|
||||||
|
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
|
||||||
|
result['nameKr'] = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── 분류 ──────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
|
||||||
|
if m:
|
||||||
|
result['hazardClass'] = clean(m.group(0))
|
||||||
|
|
||||||
|
# ── 물질요약 ───────────────────────────────────────────────────────
|
||||||
|
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
|
||||||
|
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
# 분류값 이후 ~ 유사명 전
|
||||||
|
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
if summary and len(summary) > 15:
|
||||||
|
result['materialSummary'] = summary[:500]
|
||||||
|
|
||||||
|
# ── 유사명 ─────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
# CAS 번호 형태면 제외
|
||||||
|
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
|
||||||
|
result['synonymsKr'] = synonyms_raw
|
||||||
|
|
||||||
|
# ── CAS 번호 ────────────────────────────────────────────────────────
|
||||||
|
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
|
||||||
|
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
|
||||||
|
if m:
|
||||||
|
cas = normalize_cas(m.group(1).strip().split()[0])
|
||||||
|
if cas:
|
||||||
|
result['casNumber'] = cas
|
||||||
|
|
||||||
|
# 2순위: 텍스트 전체에서 CAS 패턴 검색
|
||||||
|
if not result.get('casNumber'):
|
||||||
|
cas = find_cas_in_text(text)
|
||||||
|
if cas:
|
||||||
|
result['casNumber'] = cas
|
||||||
|
|
||||||
|
# ── UN 번호 ─────────────────────────────────────────────────────────
|
||||||
|
# NFPA 코드 이후 줄에 있는 4자리 숫자
|
||||||
|
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
|
||||||
|
if not m:
|
||||||
|
# NFPA 다음 4자리 숫자
|
||||||
|
m = re.search(r'반응\s*[::]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
|
||||||
|
if m:
|
||||||
|
result['unNumber'] = m.group(1).strip()
|
||||||
|
|
||||||
|
# ── NFPA 코드 ───────────────────────────────────────────────────────
|
||||||
|
nfpa = parse_nfpa(text)
|
||||||
|
if nfpa:
|
||||||
|
result['nfpa'] = nfpa
|
||||||
|
|
||||||
|
# ── 용도 ────────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
# GHS 마크(특수문자 블록) 제거
|
||||||
|
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
|
||||||
|
if usage and len(usage) < 200:
|
||||||
|
result['usage'] = usage
|
||||||
|
|
||||||
|
# ── 물질특성 블록 ───────────────────────────────────────────────────
|
||||||
|
props_start = text.find('물질특성')
|
||||||
|
props_end = text.find('인체 유해성')
|
||||||
|
if props_end < 0:
|
||||||
|
props_end = text.find('인체유해성')
|
||||||
|
if props_end < 0:
|
||||||
|
props_end = text.find('흡입노출')
|
||||||
|
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
|
||||||
|
|
||||||
|
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
|
||||||
|
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||||
|
result['flashPoint'] = val
|
||||||
|
|
||||||
|
# 발화점 (상세) — 숫자 포함 값만 허용
|
||||||
|
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||||
|
result['autoIgnition'] = val
|
||||||
|
|
||||||
|
# 끓는점
|
||||||
|
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['boilingPoint'] = val
|
||||||
|
|
||||||
|
# 수용해도
|
||||||
|
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 50:
|
||||||
|
result['solubility'] = val
|
||||||
|
|
||||||
|
# 상온상태 (상세)
|
||||||
|
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1)).strip('()')
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['state'] = val
|
||||||
|
|
||||||
|
# 색상 (상세)
|
||||||
|
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['color'] = val
|
||||||
|
|
||||||
|
# 냄새 (상세)
|
||||||
|
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['odor'] = val
|
||||||
|
|
||||||
|
# 비중 (상세)
|
||||||
|
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['density'] = val
|
||||||
|
|
||||||
|
# 증기압 (상세)
|
||||||
|
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['vaporPressure'] = val
|
||||||
|
|
||||||
|
# 증기밀도 (상세)
|
||||||
|
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 20:
|
||||||
|
result['vaporDensity'] = val
|
||||||
|
|
||||||
|
# 점도
|
||||||
|
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['viscosity'] = val
|
||||||
|
|
||||||
|
# ── 인체유해성 블록 ─────────────────────────────────────────────────
|
||||||
|
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
|
||||||
|
if hazard_start < 0:
|
||||||
|
hazard_start = text.find('급성독성')
|
||||||
|
response_start = text.find('초동대응')
|
||||||
|
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
|
||||||
|
|
||||||
|
# IDLH
|
||||||
|
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['idlh'] = val
|
||||||
|
|
||||||
|
# TWA
|
||||||
|
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['twa'] = val
|
||||||
|
|
||||||
|
# ── 응급조치 ─────────────────────────────────────────────────────────
|
||||||
|
fa_start = text.find('흡입노출')
|
||||||
|
fa_end = text.find('초동대응')
|
||||||
|
if fa_start >= 0:
|
||||||
|
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
|
||||||
|
fa = re.sub(r'\s+', ' ', fa_text).strip()
|
||||||
|
result['msds'] = {
|
||||||
|
'firstAid': fa[:600],
|
||||||
|
'spillResponse': '',
|
||||||
|
'hazard': '',
|
||||||
|
'fireFighting': '',
|
||||||
|
'exposure': '',
|
||||||
|
'regulation': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
|
||||||
|
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceSpillDay'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceSpillNight'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceFire'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
# ── GHS 분류 ─────────────────────────────────────────────────────────
|
||||||
|
ghs_items = re.findall(
|
||||||
|
r'(?:인화성[^\n((]{2,40}|급성독성[^\n((]{2,40}|피부부식[^\n((]{2,40}|'
|
||||||
|
r'눈\s*손상[^\n((]{2,40}|발암성[^\n((]{2,40}|생식독성[^\n((]{2,40}|'
|
||||||
|
r'수생환경[^\n((]{2,40}|특정표적[^\n((]{2,40}|흡인유해[^\n((]{2,40})',
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if ghs_items:
|
||||||
|
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
|
||||||
|
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
|
||||||
|
index: dict[int, dict] = {}
|
||||||
|
|
||||||
|
for page_idx in range(3, 21):
|
||||||
|
page = pdf[page_idx]
|
||||||
|
text = page.get_text()
|
||||||
|
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if not re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
no = int(line)
|
||||||
|
if not (1 <= no <= 193):
|
||||||
|
continue
|
||||||
|
if no in index:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 탐색 창: NO 앞 1~4줄
|
||||||
|
cas, name_en, name_kr = '', '', ''
|
||||||
|
if i >= 1:
|
||||||
|
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
|
||||||
|
raw_cas = lines[i - 1]
|
||||||
|
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
|
||||||
|
if not cas and '혼합물' in raw_cas:
|
||||||
|
cas = '혼합물'
|
||||||
|
|
||||||
|
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
|
||||||
|
if i >= 2:
|
||||||
|
name_en = lines[i - 2]
|
||||||
|
if i >= 3:
|
||||||
|
name_kr = lines[i - 3]
|
||||||
|
elif i >= 2:
|
||||||
|
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
|
||||||
|
raw_cas2 = lines[i - 2] if i >= 2 else ''
|
||||||
|
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
|
||||||
|
if cas or '혼합물' in raw_cas2:
|
||||||
|
name_en = lines[i - 1] if i >= 1 else ''
|
||||||
|
# name_kr는 찾기 어려움
|
||||||
|
|
||||||
|
if not name_kr and i >= 3:
|
||||||
|
# 이름이 공백/짧으면 더 위 줄에서 찾기
|
||||||
|
for j in range(3, min(6, i + 1)):
|
||||||
|
cand = lines[i - j]
|
||||||
|
if re.search(r'[가-힣]', cand) and len(cand) > 1:
|
||||||
|
name_kr = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
index[no] = {
|
||||||
|
'no': no,
|
||||||
|
'nameKr': name_kr,
|
||||||
|
'nameEn': name_en,
|
||||||
|
'casNumber': cas if cas != '혼합물' else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def extract_name_from_summary(text: str) -> tuple[str, str]:
|
||||||
|
"""요약 카드에서 nameKr, nameEn 추출."""
|
||||||
|
name_kr, name_en = '', ''
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
|
||||||
|
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
|
||||||
|
found_header = False
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 제목 줄 건너뜀
|
||||||
|
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
|
||||||
|
found_header = True
|
||||||
|
continue
|
||||||
|
# 3자리 번호 줄 건너뜀
|
||||||
|
if re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
# 한글이 있으면 nameKr 후보
|
||||||
|
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
|
||||||
|
if not name_kr:
|
||||||
|
name_kr = clean(line)
|
||||||
|
|
||||||
|
# 영문명: (영문명) 형태
|
||||||
|
m_en = re.search(r'[((]([A-Za-z][^))]{3,60})[))]', line)
|
||||||
|
if m_en and not name_en:
|
||||||
|
name_en = clean(m_en.group(1))
|
||||||
|
|
||||||
|
if name_kr and name_en:
|
||||||
|
break
|
||||||
|
|
||||||
|
return name_kr, name_en
|
||||||
|
|
||||||
|
|
||||||
|
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
|
||||||
|
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
|
||||||
|
start_idx = 21 + (no - 1) * 2
|
||||||
|
if start_idx + 1 >= pdf.page_count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary_text = pdf[start_idx].get_text()
|
||||||
|
detail_text = pdf[start_idx + 1].get_text()
|
||||||
|
|
||||||
|
summary = parse_summary_card(summary_text, index_entry)
|
||||||
|
detail = parse_detail_card(detail_text)
|
||||||
|
|
||||||
|
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
|
||||||
|
name_kr = index_entry.get('nameKr', '')
|
||||||
|
if not name_kr:
|
||||||
|
name_kr = detail.get('nameKr', '')
|
||||||
|
if not name_kr:
|
||||||
|
name_kr, _ = extract_name_from_summary(summary_text)
|
||||||
|
|
||||||
|
# nameEn
|
||||||
|
name_en = index_entry.get('nameEn', '')
|
||||||
|
|
||||||
|
# 통합: detail 우선, 없으면 summary
|
||||||
|
merged: dict = {
|
||||||
|
'nameKr': name_kr,
|
||||||
|
'nameEn': name_en,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
|
||||||
|
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
|
||||||
|
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
|
||||||
|
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
|
||||||
|
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
|
||||||
|
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
|
||||||
|
detail_val = detail.get(key)
|
||||||
|
summary_val = summary.get(key)
|
||||||
|
if detail_val:
|
||||||
|
merged[key] = detail_val
|
||||||
|
elif summary_val:
|
||||||
|
merged[key] = summary_val
|
||||||
|
|
||||||
|
# CAS: 인덱스 우선
|
||||||
|
if index_entry.get('casNumber') and not merged.get('casNumber'):
|
||||||
|
merged['casNumber'] = index_entry['casNumber']
|
||||||
|
|
||||||
|
# NFPA: detail 우선
|
||||||
|
if 'nfpa' in detail:
|
||||||
|
merged['nfpa'] = detail['nfpa']
|
||||||
|
|
||||||
|
if 'msds' not in merged:
|
||||||
|
merged['msds'] = {
|
||||||
|
'firstAid': '', 'spillResponse': '', 'hazard': '',
|
||||||
|
'fireFighting': '', 'exposure': '', 'regulation': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
merged['_no'] = no
|
||||||
|
merged['_pageIdx'] = start_idx
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not PDF_PATH.exists():
|
||||||
|
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
|
||||||
|
|
||||||
|
print(f'[읽기] {PDF_PATH}')
|
||||||
|
pdf = fitz.open(str(PDF_PATH))
|
||||||
|
print(f'[PDF] 총 {pdf.page_count}페이지')
|
||||||
|
|
||||||
|
# 1. 인덱스 파싱
|
||||||
|
print('[인덱스] 목차 페이지 파싱 중...')
|
||||||
|
index = parse_index_pages(pdf)
|
||||||
|
print(f'[인덱스] {len(index)}개 항목 발견')
|
||||||
|
|
||||||
|
# 2. 물질 카드 파싱
|
||||||
|
results: dict[str, dict] = {}
|
||||||
|
failed: list[int] = []
|
||||||
|
|
||||||
|
for no in range(1, 194):
|
||||||
|
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
|
||||||
|
try:
|
||||||
|
rec = parse_substance(pdf, no, entry)
|
||||||
|
if rec:
|
||||||
|
name_kr = rec.get('nameKr', '')
|
||||||
|
if name_kr:
|
||||||
|
key = name_kr
|
||||||
|
if key in results:
|
||||||
|
key = f'{name_kr}_{no}'
|
||||||
|
results[key] = rec
|
||||||
|
else:
|
||||||
|
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
|
||||||
|
failed.append(no)
|
||||||
|
except Exception as e:
|
||||||
|
print(f' [오류] NO={no}: {e}')
|
||||||
|
failed.append(no)
|
||||||
|
|
||||||
|
pdf.close()
|
||||||
|
|
||||||
|
# 3. 저장
|
||||||
|
out_path = OUT_DIR / 'pdf-data.json'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
size_kb = out_path.stat().st_size / 1024
|
||||||
|
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
|
||||||
|
|
||||||
|
# 4. 통계
|
||||||
|
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
|
||||||
|
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
|
||||||
|
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
|
||||||
|
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
|
||||||
|
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}종')
|
||||||
|
|
||||||
|
# 5. 샘플 출력
|
||||||
|
print('\n[샘플] 주요 항목:')
|
||||||
|
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
|
||||||
|
for k in sample_keys:
|
||||||
|
if k in results:
|
||||||
|
v = results[k]
|
||||||
|
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
23
backend/scripts/hns-import/merge-batch.py
Normal file
23
backend/scripts/hns-import/merge-batch.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""배치 JSON을 ocr.json에 병합."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OCR_PATH = OUT_DIR / 'ocr.json'
|
||||||
|
BATCH_PATH = OUT_DIR / 'batch.json'
|
||||||
|
|
||||||
|
with open(OCR_PATH, encoding='utf-8') as f:
|
||||||
|
ocr = json.load(f)
|
||||||
|
with open(BATCH_PATH, encoding='utf-8') as f:
|
||||||
|
batch = json.load(f)
|
||||||
|
|
||||||
|
added = [k for k in batch if k not in ocr]
|
||||||
|
updated = [k for k in batch if k in ocr]
|
||||||
|
ocr.update(batch)
|
||||||
|
|
||||||
|
with open(OCR_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')
|
||||||
362
backend/scripts/hns-import/merge-data.ts
Normal file
362
backend/scripts/hns-import/merge-data.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* base.json + pdf-data.json + ocr.json → frontend/src/data/hnsSubstanceData.json
|
||||||
|
*
|
||||||
|
* 우선순위: pdf-data (PDF 텍스트 추출, 최고 정확도) > base.json > ocr.json (이미지 OCR, 낮은 정확도)
|
||||||
|
* 매칭 키 순서:
|
||||||
|
* 1. CAS 번호 (가장 신뢰할 수 있는 식별자)
|
||||||
|
* 2. 국문명(nameKr) 정규화 비교
|
||||||
|
* 3. 동의어(synonymsKr) 정규화 비교
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT_DIR = resolve(__dirname, 'out');
|
||||||
|
const BASE_PATH = resolve(OUT_DIR, 'base.json');
|
||||||
|
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
|
||||||
|
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
|
||||||
|
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
|
||||||
|
|
||||||
|
function normalizeName(s: string | undefined): string {
|
||||||
|
if (!s) return '';
|
||||||
|
return s
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/[,.·/\-_()[\]()]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCas(s: string | undefined): string {
|
||||||
|
if (!s) return '';
|
||||||
|
// 앞자리 0 제거 후 정규화
|
||||||
|
return s
|
||||||
|
.replace(/[^0-9\-]/g, '')
|
||||||
|
.replace(/^0+/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NfpaBlock {
|
||||||
|
health: number;
|
||||||
|
fire: number;
|
||||||
|
reactivity: number;
|
||||||
|
special: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsdsBlock {
|
||||||
|
hazard: string;
|
||||||
|
firstAid: string;
|
||||||
|
fireFighting: string;
|
||||||
|
spillResponse: string;
|
||||||
|
exposure: string;
|
||||||
|
regulation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseRecord {
|
||||||
|
id: number;
|
||||||
|
abbreviation: string;
|
||||||
|
nameKr: string;
|
||||||
|
nameEn: string;
|
||||||
|
synonymsEn: string;
|
||||||
|
synonymsKr: string;
|
||||||
|
unNumber: string;
|
||||||
|
casNumber: string;
|
||||||
|
transportMethod: string;
|
||||||
|
sebc: string;
|
||||||
|
usage: string;
|
||||||
|
state: string;
|
||||||
|
color: string;
|
||||||
|
odor: string;
|
||||||
|
flashPoint: string;
|
||||||
|
autoIgnition: string;
|
||||||
|
boilingPoint: string;
|
||||||
|
density: string;
|
||||||
|
solubility: string;
|
||||||
|
vaporPressure: string;
|
||||||
|
vaporDensity: string;
|
||||||
|
explosionRange: string;
|
||||||
|
nfpa: NfpaBlock;
|
||||||
|
hazardClass: string;
|
||||||
|
ergNumber: string;
|
||||||
|
idlh: string;
|
||||||
|
aegl2: string;
|
||||||
|
erpg2: string;
|
||||||
|
responseDistanceFire: string;
|
||||||
|
responseDistanceSpillDay: string;
|
||||||
|
responseDistanceSpillNight: string;
|
||||||
|
marineResponse: string;
|
||||||
|
ppeClose: string;
|
||||||
|
ppeFar: string;
|
||||||
|
msds: MsdsBlock;
|
||||||
|
ibcHazard: string;
|
||||||
|
ibcShipType: string;
|
||||||
|
ibcTankType: string;
|
||||||
|
ibcDetection: string;
|
||||||
|
ibcFireFighting: string;
|
||||||
|
ibcMinRequirement: string;
|
||||||
|
emsCode: string;
|
||||||
|
emsFire: string;
|
||||||
|
emsSpill: string;
|
||||||
|
emsFirstAid: string;
|
||||||
|
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
||||||
|
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfResult {
|
||||||
|
[key: string]: unknown;
|
||||||
|
casNumber?: string;
|
||||||
|
nameKr?: string;
|
||||||
|
nfpa?: Partial<NfpaBlock>;
|
||||||
|
msds?: Partial<MsdsBlock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcrResult {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values: Array<unknown>): string {
|
||||||
|
for (const v of values) {
|
||||||
|
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
|
||||||
|
const n = source.nfpa as Partial<NfpaBlock> | undefined;
|
||||||
|
if (!n || typeof n !== 'object') return null;
|
||||||
|
const h = Number(n.health);
|
||||||
|
const f = Number(n.fire);
|
||||||
|
const r = Number(n.reactivity);
|
||||||
|
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
|
||||||
|
return {
|
||||||
|
health: h,
|
||||||
|
fire: f,
|
||||||
|
reactivity: r,
|
||||||
|
special: typeof n.special === 'string' ? n.special : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMsds(
|
||||||
|
pdf: PdfResult | undefined,
|
||||||
|
ocr: OcrResult | undefined,
|
||||||
|
base: MsdsBlock,
|
||||||
|
): MsdsBlock {
|
||||||
|
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
|
||||||
|
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
|
||||||
|
return {
|
||||||
|
hazard: firstString(base.hazard, p.hazard, o.hazard),
|
||||||
|
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
|
||||||
|
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
|
||||||
|
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
|
||||||
|
exposure: firstString(base.exposure, p.exposure, o.exposure),
|
||||||
|
regulation: firstString(base.regulation, p.regulation, o.regulation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(
|
||||||
|
base: BaseRecord,
|
||||||
|
pdf: PdfResult | undefined,
|
||||||
|
ocr: OcrResult | undefined,
|
||||||
|
): BaseRecord {
|
||||||
|
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
|
||||||
|
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
|
||||||
|
// pdf NFPA 우선, 없으면 ocr, 없으면 base
|
||||||
|
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
// pdf > base > ocr 우선순위
|
||||||
|
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
|
||||||
|
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
|
||||||
|
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
|
||||||
|
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
|
||||||
|
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
|
||||||
|
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
|
||||||
|
state: firstString(pdf?.state, base.state, ocr?.state),
|
||||||
|
color: firstString(pdf?.color, base.color, ocr?.color),
|
||||||
|
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
|
||||||
|
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
|
||||||
|
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
|
||||||
|
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
|
||||||
|
density: firstString(pdf?.density, base.density, ocr?.density),
|
||||||
|
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
|
||||||
|
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
|
||||||
|
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
|
||||||
|
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
|
||||||
|
nfpa,
|
||||||
|
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
|
||||||
|
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
|
||||||
|
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
|
||||||
|
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
|
||||||
|
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
|
||||||
|
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
|
||||||
|
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
|
||||||
|
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
|
||||||
|
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
|
||||||
|
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
|
||||||
|
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
|
||||||
|
msds: pickMsds(pdf, ocr, base.msds),
|
||||||
|
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
|
||||||
|
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
|
||||||
|
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
|
||||||
|
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!existsSync(BASE_PATH)) {
|
||||||
|
console.error(`base.json 없음: ${BASE_PATH}`);
|
||||||
|
console.error('→ extract-excel.py 를 먼저 실행하세요.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
|
||||||
|
|
||||||
|
// PDF 데이터 로드
|
||||||
|
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
|
||||||
|
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// OCR 데이터 로드
|
||||||
|
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
|
||||||
|
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}종`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
|
||||||
|
// 1) nameKr 정규화 인덱스
|
||||||
|
const pdfByName = new Map<string, PdfResult>();
|
||||||
|
// 2) CAS 번호 인덱스
|
||||||
|
const pdfByCas = new Map<string, PdfResult>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(pdfRaw)) {
|
||||||
|
const normKey = normalizeName(key);
|
||||||
|
if (normKey) pdfByName.set(normKey, value);
|
||||||
|
|
||||||
|
const cas = normalizeCas(value.casNumber);
|
||||||
|
if (cas) {
|
||||||
|
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
|
||||||
|
const ocrByName = new Map<string, OcrResult>();
|
||||||
|
const ocrNormToOrig = new Map<string, string>();
|
||||||
|
for (const [key, value] of Object.entries(ocr)) {
|
||||||
|
const normKey = normalizeName(key);
|
||||||
|
if (normKey) {
|
||||||
|
ocrByName.set(normKey, value);
|
||||||
|
ocrNormToOrig.set(normKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 병합 ──────────────────────────────────────────────────────────
|
||||||
|
let pdfMatchedByName = 0;
|
||||||
|
let pdfMatchedByCas = 0;
|
||||||
|
let pdfMatchedBySynonym = 0;
|
||||||
|
let ocrMatched = 0;
|
||||||
|
const pdfUnmatched = new Set(Object.keys(pdfRaw));
|
||||||
|
const ocrUnmatched = new Set(ocrByName.keys());
|
||||||
|
|
||||||
|
const merged = base.map((record) => {
|
||||||
|
let pdfResult: PdfResult | undefined;
|
||||||
|
let ocrResult: OcrResult | undefined;
|
||||||
|
|
||||||
|
// ── PDF 매칭 ────────────────────────────────────────────────────
|
||||||
|
// 1. CAS 번호 매칭 (가장 정확)
|
||||||
|
const baseCas = normalizeCas(record.casNumber);
|
||||||
|
if (baseCas) {
|
||||||
|
pdfResult = pdfByCas.get(baseCas);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedByCas++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. nameKr 정규화 매칭
|
||||||
|
if (!pdfResult) {
|
||||||
|
const normKr = normalizeName(record.nameKr);
|
||||||
|
pdfResult = pdfByName.get(normKr);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedByName++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. synonymsKr 동의어 매칭
|
||||||
|
if (!pdfResult && record.synonymsKr) {
|
||||||
|
const synonyms = record.synonymsKr.split(' / ');
|
||||||
|
for (const syn of synonyms) {
|
||||||
|
const normSyn = normalizeName(syn);
|
||||||
|
if (!normSyn) continue;
|
||||||
|
pdfResult = pdfByName.get(normSyn);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedBySynonym++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
|
||||||
|
const normKr = normalizeName(record.nameKr);
|
||||||
|
const ocrByNameResult = ocrByName.get(normKr);
|
||||||
|
if (ocrByNameResult) {
|
||||||
|
ocrResult = ocrByNameResult;
|
||||||
|
ocrMatched++;
|
||||||
|
ocrUnmatched.delete(normKr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ocrResult && record.synonymsKr) {
|
||||||
|
const synonyms = record.synonymsKr.split(' / ');
|
||||||
|
for (const syn of synonyms) {
|
||||||
|
const normSyn = normalizeName(syn);
|
||||||
|
if (!normSyn) continue;
|
||||||
|
const synOcrResult = ocrByName.get(normSyn);
|
||||||
|
if (synOcrResult) {
|
||||||
|
ocrResult = synOcrResult;
|
||||||
|
ocrMatched++;
|
||||||
|
ocrUnmatched.delete(normSyn);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merge(record, pdfResult, ocrResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 통계 출력 ──────────────────────────────────────────────────────
|
||||||
|
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
|
||||||
|
console.log(
|
||||||
|
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
|
||||||
|
);
|
||||||
|
console.log(`[OCR 매칭] ${ocrMatched}종`);
|
||||||
|
|
||||||
|
if (pdfUnmatched.size > 0) {
|
||||||
|
const unmatchedList = Array.from(pdfUnmatched).sort();
|
||||||
|
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
|
||||||
|
writeFileSync(
|
||||||
|
unmatchedPath,
|
||||||
|
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
|
||||||
|
);
|
||||||
|
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
|
||||||
|
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||||
|
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
|
||||||
|
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
|
||||||
|
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}종`);
|
||||||
|
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}종`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환.
|
||||||
|
*
|
||||||
|
* 입력: out/images/*.png (222개)
|
||||||
|
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
|
||||||
|
*
|
||||||
|
* 환경변수: ANTHROPIC_API_KEY
|
||||||
|
* 모델: claude-sonnet-4-5 (Vision + 비용 효율)
|
||||||
|
* 동시성: 5, 재시도 3회
|
||||||
|
*
|
||||||
|
* 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다.
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve, dirname, basename, extname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT_DIR = resolve(__dirname, 'out');
|
||||||
|
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
|
||||||
|
? resolve(process.env.HNS_OCR_IMG_DIR)
|
||||||
|
: resolve(OUT_DIR, 'images');
|
||||||
|
const OCR_PATH = process.env.HNS_OCR_OUT
|
||||||
|
? resolve(process.env.HNS_OCR_OUT)
|
||||||
|
: resolve(OUT_DIR, 'ocr.json');
|
||||||
|
const FAIL_PATH = process.env.HNS_OCR_FAIL
|
||||||
|
? resolve(process.env.HNS_OCR_FAIL)
|
||||||
|
: resolve(OUT_DIR, 'ocr-failures.json');
|
||||||
|
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
|
||||||
|
const OCR_ONLY = process.env.HNS_OCR_ONLY
|
||||||
|
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const CONCURRENCY = 5;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
|
||||||
|
|
||||||
|
이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다:
|
||||||
|
- 상단: 국문명, 영문명
|
||||||
|
- 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호
|
||||||
|
- 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리
|
||||||
|
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치
|
||||||
|
|
||||||
|
아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null.
|
||||||
|
숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)").
|
||||||
|
NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시).
|
||||||
|
|
||||||
|
응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이).
|
||||||
|
|
||||||
|
스키마:
|
||||||
|
{
|
||||||
|
"transportMethod": "",
|
||||||
|
"state": "",
|
||||||
|
"color": "",
|
||||||
|
"odor": "",
|
||||||
|
"flashPoint": "",
|
||||||
|
"autoIgnition": "",
|
||||||
|
"boilingPoint": "",
|
||||||
|
"density": "",
|
||||||
|
"solubility": "",
|
||||||
|
"vaporPressure": "",
|
||||||
|
"vaporDensity": "",
|
||||||
|
"explosionRange": "",
|
||||||
|
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
|
||||||
|
"hazardClass": "",
|
||||||
|
"ergNumber": "",
|
||||||
|
"idlh": "",
|
||||||
|
"aegl2": "",
|
||||||
|
"erpg2": "",
|
||||||
|
"twa": "",
|
||||||
|
"stel": "",
|
||||||
|
"responseDistanceFire": "",
|
||||||
|
"responseDistanceSpillDay": "",
|
||||||
|
"responseDistanceSpillNight": "",
|
||||||
|
"marineResponse": "",
|
||||||
|
"ppeClose": "",
|
||||||
|
"ppeFar": "",
|
||||||
|
"msds": {
|
||||||
|
"hazard": "",
|
||||||
|
"firstAid": "",
|
||||||
|
"fireFighting": "",
|
||||||
|
"spillResponse": "",
|
||||||
|
"exposure": "",
|
||||||
|
"regulation": ""
|
||||||
|
},
|
||||||
|
"emsCode": "",
|
||||||
|
"emsFire": "",
|
||||||
|
"emsSpill": "",
|
||||||
|
"emsFirstAid": "",
|
||||||
|
"sebc": ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
interface OcrResult {
|
||||||
|
transportMethod?: string;
|
||||||
|
state?: string;
|
||||||
|
color?: string;
|
||||||
|
odor?: string;
|
||||||
|
flashPoint?: string;
|
||||||
|
autoIgnition?: string;
|
||||||
|
boilingPoint?: string;
|
||||||
|
density?: string;
|
||||||
|
solubility?: string;
|
||||||
|
vaporPressure?: string;
|
||||||
|
vaporDensity?: string;
|
||||||
|
explosionRange?: string;
|
||||||
|
nfpa?: { health: number; fire: number; reactivity: number; special: string };
|
||||||
|
hazardClass?: string;
|
||||||
|
ergNumber?: string;
|
||||||
|
idlh?: string;
|
||||||
|
aegl2?: string;
|
||||||
|
erpg2?: string;
|
||||||
|
twa?: string;
|
||||||
|
stel?: string;
|
||||||
|
responseDistanceFire?: string;
|
||||||
|
responseDistanceSpillDay?: string;
|
||||||
|
responseDistanceSpillNight?: string;
|
||||||
|
marineResponse?: string;
|
||||||
|
ppeClose?: string;
|
||||||
|
ppeFar?: string;
|
||||||
|
msds?: {
|
||||||
|
hazard?: string;
|
||||||
|
firstAid?: string;
|
||||||
|
fireFighting?: string;
|
||||||
|
spillResponse?: string;
|
||||||
|
exposure?: string;
|
||||||
|
regulation?: string;
|
||||||
|
};
|
||||||
|
emsCode?: string;
|
||||||
|
emsFire?: string;
|
||||||
|
emsSpill?: string;
|
||||||
|
emsFirstAid?: string;
|
||||||
|
sebc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExisting<T>(path: string, fallback: T): T {
|
||||||
|
if (!existsSync(path)) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(text: string): OcrResult | null {
|
||||||
|
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
|
||||||
|
const firstBrace = cleaned.indexOf('{');
|
||||||
|
const lastBrace = cleaned.lastIndexOf('}');
|
||||||
|
if (firstBrace < 0 || lastBrace < 0) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
|
||||||
|
const imageData = readFileSync(imagePath).toString('base64');
|
||||||
|
const ext = extname(imagePath).slice(1).toLowerCase();
|
||||||
|
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
|
||||||
|
| 'image/png'
|
||||||
|
| 'image/jpeg';
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: SYSTEM_PROMPT,
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: mediaType, data: imageData },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const textBlock = response.content.find((b) => b.type === 'text');
|
||||||
|
if (!textBlock || textBlock.type !== 'text') {
|
||||||
|
throw new Error('응답에 텍스트 블록 없음');
|
||||||
|
}
|
||||||
|
const result = extractJson(textBlock.text);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithRetry(
|
||||||
|
client: Anthropic,
|
||||||
|
imagePath: string,
|
||||||
|
nameKr: string,
|
||||||
|
): Promise<OcrResult> {
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
return await callVision(client, imagePath);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
const wait = 1000 * Math.pow(2, attempt - 1);
|
||||||
|
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
|
||||||
|
await new Promise((r) => setTimeout(r, wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const idx = cursor++;
|
||||||
|
await worker(items[idx], idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new Anthropic();
|
||||||
|
|
||||||
|
if (!existsSync(IMG_DIR)) {
|
||||||
|
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
|
||||||
|
const images = OCR_ONLY
|
||||||
|
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
|
||||||
|
: allImages;
|
||||||
|
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
|
||||||
|
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
|
||||||
|
|
||||||
|
let pending = images.filter((f) => {
|
||||||
|
const nameKr = basename(f, extname(f));
|
||||||
|
return !(nameKr in existing);
|
||||||
|
});
|
||||||
|
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
|
||||||
|
pending = pending.slice(0, OCR_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`);
|
||||||
|
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
|
||||||
|
console.log(`[출력] ${OCR_PATH}`);
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
await runPool(pending, async (file, idx) => {
|
||||||
|
const nameKr = basename(file, extname(file));
|
||||||
|
const path = resolve(IMG_DIR, file);
|
||||||
|
try {
|
||||||
|
const result = await processWithRetry(client, path, nameKr);
|
||||||
|
existing[nameKr] = result;
|
||||||
|
delete failures[nameKr];
|
||||||
|
done++;
|
||||||
|
if (done % 10 === 0 || done === pending.length) {
|
||||||
|
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||||
|
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
failures[nameKr] = String(err).slice(0, 500);
|
||||||
|
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||||
|
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
|
||||||
|
console.log(` OCR 결과: ${OCR_PATH}`);
|
||||||
|
if (Object.keys(failures).length > 0) {
|
||||||
|
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
324
backend/scripts/hns-import/ocr-local.py
Normal file
324
backend/scripts/hns-import/ocr-local.py
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
|
||||||
|
|
||||||
|
전용 venv(.venv)에 설치된 easyocr을 사용한다.
|
||||||
|
|
||||||
|
1. 이미지 → EasyOCR → (bbox, text, conf) 리스트
|
||||||
|
2. y좌표로 행 그룹화 후 각 행 내 x좌표 정렬
|
||||||
|
3. 레이블 키워드 기반 필드 매핑 (정규식)
|
||||||
|
4. 결과를 out/ocr.json 에 누적 저장 (재실행 가능)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
cd backend/scripts/hns-import
|
||||||
|
source .venv/Scripts/activate # Windows Git Bash
|
||||||
|
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
IMG_DIR = OUT_DIR / 'images'
|
||||||
|
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
|
||||||
|
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
|
||||||
|
|
||||||
|
|
||||||
|
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
|
||||||
|
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
|
||||||
|
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
|
||||||
|
LABEL_CANDIDATES: dict[str, list[str]] = {
|
||||||
|
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
|
||||||
|
'unNumber': ['UN번호', 'UNNO', 'UN'],
|
||||||
|
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
|
||||||
|
'usage': ['용도'],
|
||||||
|
'state': ['성상', '상태', '형태'],
|
||||||
|
'color': ['색상', '색'],
|
||||||
|
'odor': ['냄새'],
|
||||||
|
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
|
||||||
|
'autoIgnition': ['발화점', '발회점', '발하점'],
|
||||||
|
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
|
||||||
|
'density': ['비중'],
|
||||||
|
'solubility': ['용해도', '용해'],
|
||||||
|
'vaporPressure': ['증기압', '증기압력'],
|
||||||
|
'vaporDensity': ['증기밀도'],
|
||||||
|
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
|
||||||
|
'idlh': ['IDLH'],
|
||||||
|
'aegl2': ['AEGL-2', 'AEGL2'],
|
||||||
|
'erpg2': ['ERPG-2', 'ERPG2'],
|
||||||
|
'twa': ['TWA'],
|
||||||
|
'stel': ['STEL'],
|
||||||
|
'ergNumber': ['ERG번호', 'ERG'],
|
||||||
|
'hazardClass': ['위험분류', '위험', '분류'],
|
||||||
|
'synonymsKr': ['유사명'],
|
||||||
|
'responseDistanceFire': ['대피거리', '머피거리'],
|
||||||
|
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
|
||||||
|
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
|
||||||
|
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
|
||||||
|
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
|
||||||
|
'marineResponse': ['해상대응', '해상'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_label(s: str) -> str:
|
||||||
|
"""공백/특수문자 제거 후 비교용 정규화."""
|
||||||
|
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
LABEL_INDEX: dict[str, str] = {}
|
||||||
|
for _field, _candidates in LABEL_CANDIDATES.items():
|
||||||
|
for _cand in _candidates:
|
||||||
|
LABEL_INDEX[_norm_label(_cand)] = _field
|
||||||
|
|
||||||
|
# NFPA 셀 값(한 자릿수 0~4) 추출용
|
||||||
|
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
|
||||||
|
|
||||||
|
|
||||||
|
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
|
||||||
|
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
heights = [it['y1'] - it['y0'] for it in items]
|
||||||
|
median_h = sorted(heights)[len(heights) // 2]
|
||||||
|
y_tol = max(8, median_h * y_tolerance_ratio)
|
||||||
|
|
||||||
|
sorted_items = sorted(items, key=lambda it: it['cy'])
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
for it in sorted_items:
|
||||||
|
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
|
||||||
|
rows[-1].append(it)
|
||||||
|
else:
|
||||||
|
rows.append([it])
|
||||||
|
for row in rows:
|
||||||
|
row.sort(key=lambda it: it['cx'])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _match_label(text: str) -> str | None:
|
||||||
|
key = _norm_label(text)
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
# 정확 일치 우선
|
||||||
|
if key in LABEL_INDEX:
|
||||||
|
return LABEL_INDEX[key]
|
||||||
|
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
|
||||||
|
for cand_key, field in LABEL_INDEX.items():
|
||||||
|
if len(cand_key) >= 2 and key.startswith(cand_key):
|
||||||
|
return field
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_card(items: list[dict]) -> dict[str, Any]:
|
||||||
|
"""OCR 결과 목록을 필드 dict로 변환."""
|
||||||
|
rows = group_rows(items)
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# 1) 행 내 "레이블 → 값" 쌍 추출
|
||||||
|
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
|
||||||
|
for row in rows:
|
||||||
|
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
|
||||||
|
idx = 0
|
||||||
|
while idx < len(row):
|
||||||
|
field = _match_label(row[idx]['text'])
|
||||||
|
if field:
|
||||||
|
# 다음 non-label 조각을 값으로 취함
|
||||||
|
value_parts: list[str] = []
|
||||||
|
j = idx + 1
|
||||||
|
while j < len(row):
|
||||||
|
nxt = row[j]
|
||||||
|
if _match_label(nxt['text']):
|
||||||
|
break
|
||||||
|
value_parts.append(nxt['text'])
|
||||||
|
j += 1
|
||||||
|
if value_parts and field not in result:
|
||||||
|
value = ' '.join(value_parts).strip()
|
||||||
|
if value and value not in ('-', '–', 'N/A'):
|
||||||
|
result[field] = value
|
||||||
|
idx = j
|
||||||
|
else:
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
|
||||||
|
nfpa_idx_row: int | None = None
|
||||||
|
for ri, row in enumerate(rows):
|
||||||
|
for cell in row:
|
||||||
|
if re.search(r'NFPA', cell['text']):
|
||||||
|
nfpa_idx_row = ri
|
||||||
|
break
|
||||||
|
if nfpa_idx_row is not None:
|
||||||
|
break
|
||||||
|
if nfpa_idx_row is not None:
|
||||||
|
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
|
||||||
|
candidates: list[int] = []
|
||||||
|
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
|
||||||
|
for cell in rows[ri]:
|
||||||
|
m = NFPA_VALUE_RE.match(cell['text'].strip())
|
||||||
|
if m:
|
||||||
|
candidates.append(int(cell['text'].strip()))
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
break
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
break
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
result['nfpa'] = {
|
||||||
|
'health': candidates[0],
|
||||||
|
'fire': candidates[1],
|
||||||
|
'reactivity': candidates[2],
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) EmS 코드 (F-x / S-x 패턴)
|
||||||
|
all_text = ' '.join(cell['text'] for row in rows for cell in row)
|
||||||
|
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
|
||||||
|
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
|
||||||
|
if f_match or s_match:
|
||||||
|
parts = []
|
||||||
|
if f_match:
|
||||||
|
parts.append(f'F-{f_match.group(1)}')
|
||||||
|
if s_match:
|
||||||
|
parts.append(f'S-{s_match.group(1)}')
|
||||||
|
if parts:
|
||||||
|
result['emsCode'] = ', '.join(parts)
|
||||||
|
|
||||||
|
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
|
||||||
|
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
|
||||||
|
if erg_match:
|
||||||
|
result['ergNumber'] = erg_match.group(1)
|
||||||
|
|
||||||
|
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_image(pil_img, upscale: float = 2.5):
|
||||||
|
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if pil_img.mode != 'RGB':
|
||||||
|
pil_img = pil_img.convert('RGB')
|
||||||
|
|
||||||
|
# 1) 업스케일 (LANCZOS)
|
||||||
|
w, h = pil_img.size
|
||||||
|
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 2) 대비 향상
|
||||||
|
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
|
||||||
|
|
||||||
|
# 3) 샤프닝
|
||||||
|
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
|
||||||
|
|
||||||
|
return np.array(pil_img)
|
||||||
|
|
||||||
|
|
||||||
|
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
|
||||||
|
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as pil:
|
||||||
|
img = _preprocess_image(pil, upscale=upscale)
|
||||||
|
raw = reader.readtext(img, detail=1, paragraph=False)
|
||||||
|
items: list[dict] = []
|
||||||
|
for bbox, text, conf in raw:
|
||||||
|
if not text or not str(text).strip():
|
||||||
|
continue
|
||||||
|
xs = [p[0] for p in bbox]
|
||||||
|
ys = [p[1] for p in bbox]
|
||||||
|
items.append({
|
||||||
|
'text': str(text).strip(),
|
||||||
|
'cx': sum(xs) / 4.0,
|
||||||
|
'cy': sum(ys) / 4.0,
|
||||||
|
'x0': min(xs),
|
||||||
|
'x1': max(xs),
|
||||||
|
'y0': min(ys),
|
||||||
|
'y1': max(ys),
|
||||||
|
'conf': float(conf),
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, fallback):
|
||||||
|
if not path.exists():
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--limit', type=int, default=None)
|
||||||
|
parser.add_argument('--only', type=str, default=None,
|
||||||
|
help='파이프(|)로 구분된 물질명 리스트')
|
||||||
|
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
|
||||||
|
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
|
||||||
|
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
|
||||||
|
parser.add_argument('--debug', action='store_true',
|
||||||
|
help='파싱 중간 결과(row 단위) 함께 출력')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import easyocr # noqa: WPS433
|
||||||
|
|
||||||
|
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
|
||||||
|
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
||||||
|
print('[로딩] 완료')
|
||||||
|
|
||||||
|
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
|
||||||
|
if args.only:
|
||||||
|
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
|
||||||
|
images = [p for p in images if p.stem in only_set]
|
||||||
|
|
||||||
|
existing: dict[str, Any] = load_json(args.out, {})
|
||||||
|
failures: dict[str, str] = load_json(args.fail, {})
|
||||||
|
|
||||||
|
pending = [p for p in images if p.stem not in existing]
|
||||||
|
if args.limit:
|
||||||
|
pending = pending[: args.limit]
|
||||||
|
|
||||||
|
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}개')
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
fail = 0
|
||||||
|
for i, path in enumerate(pending, start=1):
|
||||||
|
name = path.stem
|
||||||
|
try:
|
||||||
|
items = run_ocr(path, reader)
|
||||||
|
parsed = parse_card(items)
|
||||||
|
if args.debug:
|
||||||
|
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
|
||||||
|
for row in group_rows(items):
|
||||||
|
print(' |', ' │ '.join(f'{c["text"]}' for c in row))
|
||||||
|
print(f' → parsed: {parsed}')
|
||||||
|
existing[name] = parsed
|
||||||
|
if name in failures:
|
||||||
|
del failures[name]
|
||||||
|
ok += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
failures[name] = f'{type(e).__name__}: {e}'[:500]
|
||||||
|
fail += 1
|
||||||
|
print(f'[실패] {name}: {e}')
|
||||||
|
|
||||||
|
if i % 10 == 0 or i == len(pending):
|
||||||
|
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
|
||||||
|
|
||||||
|
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
|
||||||
|
print(f' 결과: {args.out}')
|
||||||
|
if failures:
|
||||||
|
print(f' 실패 목록: {args.fail}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
29
backend/scripts/hns-import/requirements.txt
Normal file
29
backend/scripts/hns-import/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# extract-excel.py 용
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
|
||||||
|
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
|
||||||
|
easyocr==1.7.2
|
||||||
|
filelock==3.19.1
|
||||||
|
fsspec==2025.10.0
|
||||||
|
ImageIO==2.37.2
|
||||||
|
Jinja2==3.1.6
|
||||||
|
lazy-loader==0.5
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
mpmath==1.3.0
|
||||||
|
networkx==3.2.1
|
||||||
|
ninja==1.13.0
|
||||||
|
numpy==2.0.2
|
||||||
|
opencv-python-headless==4.13.0.92
|
||||||
|
packaging==26.1
|
||||||
|
pillow==11.3.0
|
||||||
|
pyclipper==1.3.0.post6
|
||||||
|
python-bidi==0.6.7
|
||||||
|
PyYAML==6.0.3
|
||||||
|
scikit-image==0.24.0
|
||||||
|
scipy==1.13.1
|
||||||
|
shapely==2.0.7
|
||||||
|
sympy==1.14.0
|
||||||
|
tifffile==2024.8.30
|
||||||
|
torch==2.8.0
|
||||||
|
torchvision==0.23.0
|
||||||
|
typing_extensions==4.15.0
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
listMedia,
|
listMedia,
|
||||||
createMedia,
|
createMedia,
|
||||||
@ -13,6 +16,10 @@ import {
|
|||||||
requestOilInference,
|
requestOilInference,
|
||||||
checkInferenceHealth,
|
checkInferenceHealth,
|
||||||
stitchImages,
|
stitchImages,
|
||||||
|
listDroneStreams,
|
||||||
|
startDroneStream,
|
||||||
|
stopDroneStream,
|
||||||
|
getHlsDirectory,
|
||||||
} from './aerialService.js';
|
} from './aerialService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||||
@ -20,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
const mediaUpload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
const dir = path.resolve('uploads', 'aerial');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${randomUUID()}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i;
|
||||||
|
if (allowed.test(path.extname(file.originalname))) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('허용되지 않는 파일 형식입니다.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AERIAL_MEDIA 라우트
|
// AERIAL_MEDIA 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -68,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록
|
||||||
|
router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ error: '파일이 필요합니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as {
|
||||||
|
equipTpCd?: string;
|
||||||
|
equipNm?: string;
|
||||||
|
mediaTpCd?: string;
|
||||||
|
acdntSn?: string;
|
||||||
|
memo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVideo = file.mimetype.startsWith('video/');
|
||||||
|
const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진');
|
||||||
|
const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
|
||||||
|
const result = await createMedia({
|
||||||
|
fileNm: file.filename,
|
||||||
|
orgnlNm: file.originalname,
|
||||||
|
filePath: file.path,
|
||||||
|
equipTpCd: equipTpCd ?? 'drone',
|
||||||
|
equipNm: equipNm ?? '기타',
|
||||||
|
mediaTpCd: detectedMediaType,
|
||||||
|
fileSz: fileSzMb,
|
||||||
|
acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined,
|
||||||
|
locDc: memo ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 미디어 업로드 오류:', err);
|
||||||
|
res.status(500).json({ error: '미디어 업로드 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시)
|
||||||
|
router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params['sn'] as string, 10);
|
||||||
|
if (!isValidNumber(sn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await getMediaBySn(sn);
|
||||||
|
if (!media) {
|
||||||
|
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 업로드 파일이면 직접 서빙
|
||||||
|
if (media.filePath) {
|
||||||
|
const absPath = path.resolve(media.filePath);
|
||||||
|
if (existsSync(absPath)) {
|
||||||
|
const ext = path.extname(absPath).toLowerCase();
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||||
|
'.tif': 'image/tiff', '.tiff': 'image/tiff',
|
||||||
|
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||||
|
};
|
||||||
|
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', 'inline');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
res.sendFile(absPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = media.fileNm.substring(0, 36);
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
|
||||||
|
res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await fetchOriginalImage(media.equipNm, fileId);
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg');
|
||||||
|
res.setHeader('Content-Disposition', 'inline');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 이미지 뷰어 오류:', err);
|
||||||
|
res.status(502).json({ error: '이미지 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -121,6 +241,92 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// KBS 재난안전포탈 CCTV HLS 리졸버
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */
|
||||||
|
const kbsHlsCache = new Map<string, { url: string; ts: number }>();
|
||||||
|
const KBS_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시
|
||||||
|
router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cctvId = req.params.cctvId as string;
|
||||||
|
if (!/^\d+$/.test(cctvId)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 cctvId' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let m3u8Url: string | null = null;
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
const cached = kbsHlsCache.get(cctvId);
|
||||||
|
if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) {
|
||||||
|
m3u8Url = cached.url;
|
||||||
|
} else {
|
||||||
|
// 1단계: KBS 팝업 API에서 loomex API URL 추출
|
||||||
|
const popupRes = await fetch(
|
||||||
|
`https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`,
|
||||||
|
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } },
|
||||||
|
);
|
||||||
|
if (!popupRes.ok) {
|
||||||
|
res.status(502).json({ error: 'KBS 팝업 API 응답 실패' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popupHtml = await popupRes.text();
|
||||||
|
const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/);
|
||||||
|
if (!urlMatch) {
|
||||||
|
res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: loomex API에서 실제 m3u8 URL 획득
|
||||||
|
const loomexRes = await fetch(urlMatch[1], {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||||
|
});
|
||||||
|
if (!loomexRes.ok) {
|
||||||
|
res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m3u8Url = (await loomexRes.text()).trim();
|
||||||
|
kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성
|
||||||
|
const upstream = await fetch(m3u8Url, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||||
|
});
|
||||||
|
if (!upstream.ok) {
|
||||||
|
// 캐시 무효화 후 재시도 유도
|
||||||
|
kbsHlsCache.delete(cctvId);
|
||||||
|
res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await upstream.text();
|
||||||
|
const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
|
||||||
|
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
|
||||||
|
|
||||||
|
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
|
||||||
|
if (line.startsWith('http://') || line.startsWith('https://')) {
|
||||||
|
return `${proxyBase}${encodeURIComponent(line)}`;
|
||||||
|
}
|
||||||
|
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
res.send(rewritten);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] KBS HLS 리졸버 오류:', err);
|
||||||
|
res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CCTV HLS 스트림 프록시 (CORS 우회)
|
// CCTV HLS 스트림 프록시 (CORS 우회)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -129,6 +335,7 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
|
|||||||
const ALLOWED_STREAM_HOSTS = [
|
const ALLOWED_STREAM_HOSTS = [
|
||||||
'www.khoa.go.kr',
|
'www.khoa.go.kr',
|
||||||
'kbsapi.loomex.net',
|
'kbsapi.loomex.net',
|
||||||
|
'kbscctv-cache.loomex.net',
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
||||||
@ -201,6 +408,87 @@ router.get('/cctv/stream-proxy', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DRONE STREAM 라우트 (RTSP → HLS)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태
|
||||||
|
router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const streams = listDroneStreams();
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 목록 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 목록 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환)
|
||||||
|
router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = startDroneStream(req.params.id as string);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 시작 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 시작 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지
|
||||||
|
router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = stopDroneStream(req.params.id as string);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 중지 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 중지 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts)
|
||||||
|
router.get('/drone/hls/:id/*', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const hlsDir = getHlsDirectory(id);
|
||||||
|
if (!hlsDir) {
|
||||||
|
res.status(404).json({ error: '스트림을 찾을 수 없습니다' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wildcard: req.params[0] contains the rest of the path
|
||||||
|
// Cast through unknown because @types/express v5 types the wildcard key as string[]
|
||||||
|
const rawParams = req.params as unknown as Record<string, string | string[]>;
|
||||||
|
const wildcardRaw = rawParams['0'] ?? '';
|
||||||
|
const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw;
|
||||||
|
const filePath = path.join(hlsDir, wildcardParam);
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (!filePath.startsWith(hlsDir)) {
|
||||||
|
res.status(403).json({ error: '접근 거부' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl'
|
||||||
|
: ext === '.ts' ? 'video/mp2t'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] HLS 파일 서빙 오류:', err);
|
||||||
|
res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SAT_REQUEST 라우트
|
// SAT_REQUEST 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -262,6 +550,103 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */
|
||||||
|
function generateKoreaSatellitePasses() {
|
||||||
|
const now = new Date();
|
||||||
|
const passes = [
|
||||||
|
{
|
||||||
|
id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical',
|
||||||
|
resolution: '0.5m', color: '#a855f7',
|
||||||
|
startTime: new Date(now.getTime() + 2 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(),
|
||||||
|
maxElevation: 72, direction: 'descending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 },
|
||||||
|
{ lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 },
|
||||||
|
{ lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical',
|
||||||
|
resolution: '0.3m', color: '#06b6d4',
|
||||||
|
startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(),
|
||||||
|
maxElevation: 65, direction: 'ascending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 },
|
||||||
|
{ lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 },
|
||||||
|
{ lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar',
|
||||||
|
resolution: '20m', color: '#f59e0b',
|
||||||
|
startTime: new Date(now.getTime() + 5 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(),
|
||||||
|
maxElevation: 58, direction: 'descending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 },
|
||||||
|
{ lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 },
|
||||||
|
{ lat: 31.0, lon: 128.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical',
|
||||||
|
resolution: '0.31m', color: '#3b82f6',
|
||||||
|
startTime: new Date(now.getTime() + 8 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(),
|
||||||
|
maxElevation: 80, direction: 'descending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 },
|
||||||
|
{ lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 },
|
||||||
|
{ lat: 32.0, lon: 126.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical',
|
||||||
|
resolution: '0.5m', color: '#22c55e',
|
||||||
|
startTime: new Date(now.getTime() + 12 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(),
|
||||||
|
maxElevation: 55, direction: 'ascending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 },
|
||||||
|
{ lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 },
|
||||||
|
{ lat: 40.0, lon: 124.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical',
|
||||||
|
resolution: '10m', color: '#ec4899',
|
||||||
|
startTime: new Date(now.getTime() + 18 * 3600000).toISOString(),
|
||||||
|
endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(),
|
||||||
|
maxElevation: 62, direction: 'descending',
|
||||||
|
orbit: [
|
||||||
|
{ lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 },
|
||||||
|
{ lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비)
|
||||||
|
router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
|
||||||
|
try {
|
||||||
|
// TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체
|
||||||
|
// const token = await getUp42Token()
|
||||||
|
// const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] })
|
||||||
|
const passes = generateKoreaSatellitePasses();
|
||||||
|
res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 위성 패스 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '위성 패스 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// OIL INFERENCE 라우트
|
// OIL INFERENCE 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { wingPool } from '../db/wingDb.js';
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import { existsSync, mkdirSync, rmSync } from 'fs';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AERIAL_MEDIA
|
// AERIAL_MEDIA
|
||||||
@ -364,7 +368,8 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
|||||||
// OIL INFERENCE (GPU 서버 프록시)
|
// OIL INFERENCE (GPU 서버 프록시)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
|
||||||
|
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export interface OilInferenceRegion {
|
export interface OilInferenceRegion {
|
||||||
@ -408,8 +413,9 @@ export async function stitchImages(
|
|||||||
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
|
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${IMAGE_API_URL}/inference`, {
|
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ image: imageBase64 }),
|
body: JSON.stringify({ image: imageBase64 }),
|
||||||
@ -430,7 +436,7 @@ export async function requestOilInference(imageBase64: string): Promise<OilInfer
|
|||||||
/** GPU 추론 서버 헬스체크 */
|
/** GPU 추론 서버 헬스체크 */
|
||||||
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
|
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${IMAGE_API_URL}/health`, {
|
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`status ${response.status}`);
|
if (!response.ok) throw new Error(`status ${response.status}`);
|
||||||
@ -439,3 +445,175 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?:
|
|||||||
return { status: 'unavailable' };
|
return { status: 'unavailable' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DRONE STREAM (RTSP → HLS via FFmpeg)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface DroneStreamConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shipName: string;
|
||||||
|
droneModel: string;
|
||||||
|
ip: string;
|
||||||
|
rtspUrl: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DroneStreamStatus extends DroneStreamConfig {
|
||||||
|
status: 'idle' | 'starting' | 'streaming' | 'error';
|
||||||
|
hlsUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRONE_STREAMS: DroneStreamConfig[] = [
|
||||||
|
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' },
|
||||||
|
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' },
|
||||||
|
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls';
|
||||||
|
const activeProcesses = new Map<string, { process: ChildProcess; status: 'starting' | 'streaming' | 'error'; error: string | null }>();
|
||||||
|
|
||||||
|
function getHlsDir(id: string): string {
|
||||||
|
return path.join(HLS_OUTPUT_DIR, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFfmpeg(): boolean {
|
||||||
|
try {
|
||||||
|
execSync('which ffmpeg', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDroneStreams(): DroneStreamStatus[] {
|
||||||
|
return DRONE_STREAMS.map(ds => {
|
||||||
|
const active = activeProcesses.get(ds.id);
|
||||||
|
return {
|
||||||
|
...ds,
|
||||||
|
status: active?.status ?? 'idle',
|
||||||
|
hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null,
|
||||||
|
error: active?.error ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } {
|
||||||
|
const config = DRONE_STREAMS.find(d => d.id === id);
|
||||||
|
if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' };
|
||||||
|
|
||||||
|
if (activeProcesses.has(id)) {
|
||||||
|
const existing = activeProcesses.get(id)!;
|
||||||
|
if (existing.status === 'streaming' || existing.status === 'starting') {
|
||||||
|
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkFfmpeg()) {
|
||||||
|
return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hlsDir = getHlsDir(id);
|
||||||
|
if (!existsSync(hlsDir)) {
|
||||||
|
mkdirSync(hlsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.join(hlsDir, 'stream.m3u8');
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-rtsp_transport', 'tcp',
|
||||||
|
'-i', config.rtspUrl,
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', '2',
|
||||||
|
'-hls_list_size', '5',
|
||||||
|
'-hls_flags', 'delete_segments',
|
||||||
|
'-y',
|
||||||
|
outputPath,
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null };
|
||||||
|
activeProcesses.set(id, entry);
|
||||||
|
|
||||||
|
// Monitor for m3u8 file creation to confirm streaming
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (existsSync(outputPath)) {
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e && e.status === 'starting') {
|
||||||
|
e.status = 'streaming';
|
||||||
|
}
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Timeout after 15 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e && e.status === 'starting') {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.';
|
||||||
|
ffmpeg.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
let stderrBuf = '';
|
||||||
|
ffmpeg.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderrBuf += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e) {
|
||||||
|
if (e.status !== 'error') {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = code !== 0
|
||||||
|
? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}`
|
||||||
|
: '스트림 종료';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (err) => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e) {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = `FFmpeg 실행 오류: ${err.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopDroneStream(id: string): { success: boolean } {
|
||||||
|
const entry = activeProcesses.get(id);
|
||||||
|
if (!entry) return { success: true };
|
||||||
|
|
||||||
|
entry.process.kill('SIGTERM');
|
||||||
|
activeProcesses.delete(id);
|
||||||
|
|
||||||
|
// Cleanup HLS files
|
||||||
|
const hlsDir = getHlsDir(id);
|
||||||
|
try {
|
||||||
|
if (existsSync(hlsDir)) {
|
||||||
|
rmSync(hlsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHlsDirectory(id: string): string | null {
|
||||||
|
const config = DRONE_STREAMS.find(d => d.id === id);
|
||||||
|
if (!config) return null;
|
||||||
|
const dir = getHlsDir(id);
|
||||||
|
return existsSync(dir) ? dir : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js';
|
import { listOrganizations, getOrganization, listUploadLogs, listInsurance, listNearbyOrganizations } from './assetsService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -22,6 +22,26 @@ router.get('/orgs', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/assets/orgs/nearby — 근처 기관 목록 (PostGIS 반경 검색)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/orgs/nearby', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const lat = parseFloat(req.query.lat as string);
|
||||||
|
const lng = parseFloat(req.query.lng as string);
|
||||||
|
const radius = parseFloat(req.query.radius as string);
|
||||||
|
if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius <= 0) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 좌표 또는 반경입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orgs = await listNearbyOrganizations(lat, lng, radius);
|
||||||
|
res.json(orgs);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[assets] 근처 기관 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '근처 기관 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/assets/orgs/:sn — 기관 상세 (장비 + 담당자)
|
// GET /api/assets/orgs/:sn — 기관 상세 (장비 + 담당자)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -162,6 +162,54 @@ export async function getOrganization(orgSn: number): Promise<OrgDetail | null>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 근처 기관 조회 (PostGIS ST_DWithin)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface NearbyOrgItem extends OrgListItem {
|
||||||
|
distanceNm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNearbyOrganizations(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
radiusNm: number,
|
||||||
|
): Promise<NearbyOrgItem[]> {
|
||||||
|
const radiusMeters = radiusNm * 1852;
|
||||||
|
const sql = `
|
||||||
|
SELECT ORG_SN, ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL,
|
||||||
|
LAT, LNG, PIN_SIZE,
|
||||||
|
VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS,
|
||||||
|
ST_Distance(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) / 1852.0 AS distance_nm
|
||||||
|
FROM wing.ASSET_ORG
|
||||||
|
WHERE USE_YN = 'Y'
|
||||||
|
AND GEOM IS NOT NULL
|
||||||
|
AND ST_DWithin(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3)
|
||||||
|
ORDER BY distance_nm
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [lat, lng, radiusMeters]);
|
||||||
|
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
orgSn: r.org_sn as number,
|
||||||
|
orgTp: r.org_tp as string,
|
||||||
|
jrsdNm: r.jrsd_nm as string,
|
||||||
|
areaNm: r.area_nm as string,
|
||||||
|
orgNm: r.org_nm as string,
|
||||||
|
addr: r.addr as string,
|
||||||
|
tel: r.tel as string,
|
||||||
|
lat: parseFloat(r.lat as string),
|
||||||
|
lng: parseFloat(r.lng as string),
|
||||||
|
pinSize: r.pin_size as string,
|
||||||
|
vesselCnt: r.vessel_cnt as number,
|
||||||
|
skimmerCnt: r.skimmer_cnt as number,
|
||||||
|
pumpCnt: r.pump_cnt as number,
|
||||||
|
vehicleCnt: r.vehicle_cnt as number,
|
||||||
|
sprayerCnt: r.sprayer_cnt as number,
|
||||||
|
totalAssets: r.total_assets as number,
|
||||||
|
distanceNm: parseFloat(r.distance_nm as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 선박보험(유류오염보장계약) 조회
|
// 선박보험(유류오염보장계약) 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -24,8 +24,14 @@ async function seedHnsSubstances() {
|
|||||||
|
|
||||||
let inserted = 0
|
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) {
|
for (const s of HNS_SEARCH_DB) {
|
||||||
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
|
||||||
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
|
|||||||
CAS_NO = EXCLUDED.CAS_NO,
|
CAS_NO = EXCLUDED.CAS_NO,
|
||||||
SEBC = EXCLUDED.SEBC,
|
SEBC = EXCLUDED.SEBC,
|
||||||
DATA = EXCLUDED.DATA`,
|
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++
|
inserted++
|
||||||
|
|||||||
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
|
import { listGscAccidents } from './gscAccidentsService.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const accidents = await listGscAccidents(20);
|
||||||
|
res.json(accidents);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[gsc] 사고 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
44
backend/src/gsc/gscAccidentsService.ts
Normal file
44
backend/src/gsc/gscAccidentsService.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
|
||||||
|
export interface GscAccidentListItem {
|
||||||
|
acdntSn: number;
|
||||||
|
acdntMngNo: string;
|
||||||
|
pollNm: string;
|
||||||
|
pollDate: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ACDNT_SN AS "acdntSn",
|
||||||
|
ACDNT_CD AS "acdntMngNo",
|
||||||
|
ACDNT_NM AS "pollNm",
|
||||||
|
to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
|
||||||
|
LAT AS "lat",
|
||||||
|
LNG AS "lon"
|
||||||
|
FROM wing.ACDNT
|
||||||
|
WHERE ACDNT_NM IS NOT NULL
|
||||||
|
ORDER BY OCCRN_DTM DESC NULLS LAST
|
||||||
|
LIMIT $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await wingPool.query<{
|
||||||
|
acdntSn: number;
|
||||||
|
acdntMngNo: string;
|
||||||
|
pollNm: string;
|
||||||
|
pollDate: string | null;
|
||||||
|
lat: string | null;
|
||||||
|
lon: string | null;
|
||||||
|
}>(sql, [limit]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
acdntSn: row.acdntSn,
|
||||||
|
acdntMngNo: row.acdntMngNo,
|
||||||
|
pollNm: row.pollNm,
|
||||||
|
pollDate: row.pollDate,
|
||||||
|
lat: row.lat != null ? Number(row.lat) : null,
|
||||||
|
lon: row.lon != null ? Number(row.lon) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -12,11 +12,13 @@ const router = express.Router()
|
|||||||
// GET /api/hns/analyses — 분석 목록
|
// GET /api/hns/analyses — 분석 목록
|
||||||
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, substance, search } = req.query
|
const { status, substance, search, acdntSn } = req.query
|
||||||
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
||||||
const items = await listAnalyses({
|
const items = await listAnalyses({
|
||||||
status: status as string | undefined,
|
status: status as string | undefined,
|
||||||
substance: substance as string | undefined,
|
substance: substance as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
})
|
})
|
||||||
res.json(items)
|
res.json(items)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -48,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async
|
|||||||
// POST /api/hns/analyses — 분석 생성
|
// POST /api/hns/analyses — 분석 생성
|
||||||
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
||||||
if (!anlysNm) {
|
if (!anlysNm) {
|
||||||
res.status(400).json({ error: '분석명은 필수입니다.' })
|
res.status(400).json({ error: '분석명은 필수입니다.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined
|
||||||
const result = await createAnalysis({
|
const result = await createAnalysis({
|
||||||
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
|
acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
||||||
})
|
})
|
||||||
res.status(201).json(result)
|
res.status(201).json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) {
|
|||||||
|
|
||||||
interface HnsAnalysisItem {
|
interface HnsAnalysisItem {
|
||||||
hnsAnlysSn: number
|
hnsAnlysSn: number
|
||||||
|
acdntSn: number | null
|
||||||
anlysNm: string
|
anlysNm: string
|
||||||
acdntDtm: string | null
|
acdntDtm: string | null
|
||||||
locNm: string | null
|
locNm: string | null
|
||||||
@ -118,11 +119,13 @@ interface ListAnalysesInput {
|
|||||||
status?: string
|
status?: string
|
||||||
substance?: string
|
substance?: string
|
||||||
search?: string
|
search?: string
|
||||||
|
acdntSn?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||||
return {
|
return {
|
||||||
hnsAnlysSn: r.hns_anlys_sn as number,
|
hnsAnlysSn: r.hns_anlys_sn as number,
|
||||||
|
acdntSn: (r.acdnt_sn as number) ?? null,
|
||||||
anlysNm: r.anlys_nm as string,
|
anlysNm: r.anlys_nm as string,
|
||||||
acdntDtm: r.acdnt_dtm as string | null,
|
acdntDtm: r.acdnt_dtm as string | null,
|
||||||
locNm: r.loc_nm as string | null,
|
locNm: r.loc_nm as string | null,
|
||||||
@ -146,7 +149,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
|||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
||||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
const params: string[] = []
|
const params: (string | number)[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
if (input.status) {
|
if (input.status) {
|
||||||
@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
params.push(input.search)
|
params.push(input.search)
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
|
if (input.acdntSn != null) {
|
||||||
|
conditions.push(`ACDNT_SN = $${idx++}`)
|
||||||
|
params.push(input.acdntSn)
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
RSLT_DATA, REG_DTM
|
RSLT_DATA, REG_DTM
|
||||||
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
|
|
||||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
@ -194,6 +201,7 @@ export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
|||||||
|
|
||||||
export async function createAnalysis(input: {
|
export async function createAnalysis(input: {
|
||||||
anlysNm: string
|
anlysNm: string
|
||||||
|
acdntSn?: number
|
||||||
acdntDtm?: string
|
acdntDtm?: string
|
||||||
locNm?: string
|
locNm?: string
|
||||||
lon?: number
|
lon?: number
|
||||||
@ -213,21 +221,21 @@ export async function createAnalysis(input: {
|
|||||||
}): Promise<{ hnsAnlysSn: number }> {
|
}): Promise<{ hnsAnlysSn: number }> {
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`INSERT INTO HNS_ANALYSIS (
|
`INSERT INTO HNS_ANALYSIS (
|
||||||
ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
GEOM, LOC_DC,
|
GEOM, LOC_DC,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
ANALYST_NM, EXEC_STTS_CD
|
ANALYST_NM, EXEC_STTS_CD
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5::numeric, $6::numeric,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END,
|
||||||
$6, $7, $8, $9, $10, $11,
|
$7, $8, $9, $10, $11, $12,
|
||||||
$12, $13, $14, $15, $16,
|
$13, $14, $15, $16, $17,
|
||||||
$17, 'PENDING'
|
$18, 'PENDING'
|
||||||
) RETURNING HNS_ANLYS_SN`,
|
) RETURNING HNS_ANLYS_SN`,
|
||||||
[
|
[
|
||||||
input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
||||||
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
||||||
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
||||||
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
getIncident,
|
getIncident,
|
||||||
listIncidentPredictions,
|
listIncidentPredictions,
|
||||||
getIncidentWeather,
|
getIncidentWeather,
|
||||||
|
saveIncidentWeather,
|
||||||
getIncidentMedia,
|
getIncidentMedia,
|
||||||
|
getIncidentImageAnalysis,
|
||||||
} from './incidentsService.js';
|
} from './incidentsService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -92,6 +94,24 @@ router.get('/:sn/weather', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/incidents/:sn/weather — 기상정보 저장
|
||||||
|
// ============================================================
|
||||||
|
router.post('/:sn/weather', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weatherSn = await saveIncidentWeather(sn, req.body as Record<string, unknown>);
|
||||||
|
res.json({ weatherSn });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[incidents] 기상정보 저장 오류:', err);
|
||||||
|
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/incidents/:sn/media — 미디어 정보
|
// GET /api/incidents/:sn/media — 미디어 정보
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -114,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
|
||||||
|
// ============================================================
|
||||||
|
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await getIncidentImageAnalysis(sn);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -24,7 +24,11 @@ interface IncidentListItem {
|
|||||||
spilQty: number | null;
|
spilQty: number | null;
|
||||||
spilUnitCd: string | null;
|
spilUnitCd: string | null;
|
||||||
fcstHr: number | null;
|
fcstHr: number | null;
|
||||||
|
hasPredCompleted: boolean;
|
||||||
|
hasHnsCompleted: boolean;
|
||||||
|
hasRescueCompleted: boolean;
|
||||||
mediaCnt: number;
|
mediaCnt: number;
|
||||||
|
hasImgAnalysis: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PredExecItem {
|
interface PredExecItem {
|
||||||
@ -111,11 +115,29 @@ export async function listIncidents(filters: {
|
|||||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||||
|
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.PRED_EXEC pe
|
||||||
|
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
) AS has_pred_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||||
|
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND h.USE_YN = 'Y'
|
||||||
|
) AS has_hns_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.RESCUE_OPS r
|
||||||
|
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND r.STTS_CD = 'RESOLVED'
|
||||||
|
AND r.USE_YN = 'Y'
|
||||||
|
) AS has_rescue_completed,
|
||||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||||
FROM wing.ACDNT a
|
FROM wing.ACDNT a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||||
|
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||||
FROM wing.SPIL_DATA
|
FROM wing.SPIL_DATA
|
||||||
WHERE ACDNT_SN = a.ACDNT_SN
|
WHERE ACDNT_SN = a.ACDNT_SN
|
||||||
ORDER BY SPIL_DATA_SN
|
ORDER BY SPIL_DATA_SN
|
||||||
@ -148,7 +170,11 @@ export async function listIncidents(filters: {
|
|||||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||||
fcstHr: (r.fcst_hr as number) ?? null,
|
fcstHr: (r.fcst_hr as number) ?? null,
|
||||||
|
hasPredCompleted: r.has_pred_completed as boolean,
|
||||||
|
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||||
|
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||||
mediaCnt: Number(r.media_cnt),
|
mediaCnt: Number(r.media_cnt),
|
||||||
|
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,11 +188,29 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
|||||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||||
|
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.PRED_EXEC pe
|
||||||
|
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
) AS has_pred_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||||
|
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND h.USE_YN = 'Y'
|
||||||
|
) AS has_hns_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.RESCUE_OPS r
|
||||||
|
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND r.STTS_CD = 'RESOLVED'
|
||||||
|
AND r.USE_YN = 'Y'
|
||||||
|
) AS has_rescue_completed,
|
||||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||||
FROM wing.ACDNT a
|
FROM wing.ACDNT a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||||
|
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||||
FROM wing.SPIL_DATA
|
FROM wing.SPIL_DATA
|
||||||
WHERE ACDNT_SN = a.ACDNT_SN
|
WHERE ACDNT_SN = a.ACDNT_SN
|
||||||
ORDER BY SPIL_DATA_SN
|
ORDER BY SPIL_DATA_SN
|
||||||
@ -205,7 +249,11 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
|||||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||||
fcstHr: (r.fcst_hr as number) ?? null,
|
fcstHr: (r.fcst_hr as number) ?? null,
|
||||||
|
hasPredCompleted: r.has_pred_completed as boolean,
|
||||||
|
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||||
|
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||||
mediaCnt: Number(r.media_cnt),
|
mediaCnt: Number(r.media_cnt),
|
||||||
|
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||||
predictions,
|
predictions,
|
||||||
weather,
|
weather,
|
||||||
media,
|
media,
|
||||||
@ -254,24 +302,143 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
|
|||||||
const r = rows[0] as Record<string, unknown>;
|
const r = rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locNm: r.loc_nm as string,
|
locNm: (r.loc_nm as string | null) ?? '-',
|
||||||
obsDtm: (r.obs_dtm as Date).toISOString(),
|
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
|
||||||
icon: r.icon as string,
|
icon: (r.icon as string | null) ?? '',
|
||||||
temp: r.temp as string,
|
temp: (r.temp as string | null) ?? '-',
|
||||||
weatherDc: r.weather_dc as string,
|
weatherDc: (r.weather_dc as string | null) ?? '-',
|
||||||
wind: r.wind as string,
|
wind: (r.wind as string | null) ?? '-',
|
||||||
wave: r.wave as string,
|
wave: (r.wave as string | null) ?? '-',
|
||||||
humid: r.humid as string,
|
humid: (r.humid as string | null) ?? '-',
|
||||||
vis: r.vis as string,
|
vis: (r.vis as string | null) ?? '-',
|
||||||
sst: r.sst as string,
|
sst: (r.sst as string | null) ?? '-',
|
||||||
tide: r.tide as string,
|
tide: (r.tide as string | null) ?? '-',
|
||||||
highTide: r.high_tide as string,
|
highTide: (r.high_tide as string | null) ?? '-',
|
||||||
lowTide: r.low_tide as string,
|
lowTide: (r.low_tide as string | null) ?? '-',
|
||||||
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
||||||
impactDc: r.impact_dc as string,
|
impactDc: (r.impact_dc as string | null) ?? '-',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
|
||||||
|
// ============================================================
|
||||||
|
interface WeatherSnapshotPayload {
|
||||||
|
stationName?: string;
|
||||||
|
capturedAt?: string;
|
||||||
|
wind?: {
|
||||||
|
speed?: number;
|
||||||
|
direction?: number;
|
||||||
|
directionLabel?: string;
|
||||||
|
speed_1k?: number;
|
||||||
|
speed_3k?: number;
|
||||||
|
};
|
||||||
|
wave?: {
|
||||||
|
height?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
period?: number;
|
||||||
|
direction?: string;
|
||||||
|
};
|
||||||
|
temperature?: {
|
||||||
|
current?: number;
|
||||||
|
feelsLike?: number;
|
||||||
|
};
|
||||||
|
pressure?: number;
|
||||||
|
visibility?: number;
|
||||||
|
salinity?: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise?: string;
|
||||||
|
sunset?: string;
|
||||||
|
moonrise?: string;
|
||||||
|
moonset?: string;
|
||||||
|
moonPhase?: string;
|
||||||
|
tidalRange?: number;
|
||||||
|
} | null;
|
||||||
|
alert?: string | null;
|
||||||
|
forecast?: unknown[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncidentWeather(
|
||||||
|
acdntSn: number,
|
||||||
|
snapshot: WeatherSnapshotPayload,
|
||||||
|
): Promise<number> {
|
||||||
|
// 팝업 표시용 포맷 문자열
|
||||||
|
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
|
||||||
|
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
|
||||||
|
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
|
||||||
|
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
|
||||||
|
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
|
||||||
|
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
|
||||||
|
const highTideStr = snapshot.astronomy?.tidalRange != null
|
||||||
|
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
|
||||||
|
|
||||||
|
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
|
||||||
|
type ForecastItem = { time?: string; icon?: string; temperature?: number };
|
||||||
|
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
|
||||||
|
hour: f.time ?? '',
|
||||||
|
icon: f.icon ?? '⛅',
|
||||||
|
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
|
||||||
|
})) ?? null;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO wing.ACDNT_WEATHER (
|
||||||
|
ACDNT_SN, LOC_NM, OBS_DTM,
|
||||||
|
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
|
||||||
|
PRESSURE, VIS,
|
||||||
|
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
|
||||||
|
SST, AIR_TEMP, SALINITY,
|
||||||
|
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
|
||||||
|
WEATHER_ALERT, FORECAST,
|
||||||
|
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, NOW(),
|
||||||
|
$3, $4, $5, $6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11, $12, $13,
|
||||||
|
$14, $15, $16,
|
||||||
|
$17, $18, $19, $20, $21, $22,
|
||||||
|
$23, $24,
|
||||||
|
$25, $26, $27, $28, $29, $30
|
||||||
|
)
|
||||||
|
RETURNING WEATHER_SN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [
|
||||||
|
acdntSn,
|
||||||
|
snapshot.stationName ?? null,
|
||||||
|
snapshot.wind?.speed ?? null,
|
||||||
|
snapshot.wind?.direction ?? null,
|
||||||
|
snapshot.wind?.directionLabel ?? null,
|
||||||
|
snapshot.wind?.speed_1k ?? null,
|
||||||
|
snapshot.wind?.speed_3k ?? null,
|
||||||
|
snapshot.pressure ?? null,
|
||||||
|
vis,
|
||||||
|
snapshot.wave?.height ?? null,
|
||||||
|
snapshot.wave?.maxHeight ?? null,
|
||||||
|
snapshot.wave?.period ?? null,
|
||||||
|
snapshot.wave?.direction ?? null,
|
||||||
|
sst,
|
||||||
|
snapshot.temperature?.feelsLike ?? null,
|
||||||
|
snapshot.salinity ?? null,
|
||||||
|
snapshot.astronomy?.sunrise ?? null,
|
||||||
|
snapshot.astronomy?.sunset ?? null,
|
||||||
|
snapshot.astronomy?.moonrise ?? null,
|
||||||
|
snapshot.astronomy?.moonset ?? null,
|
||||||
|
snapshot.astronomy?.moonPhase ?? null,
|
||||||
|
snapshot.astronomy?.tidalRange ?? null,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
|
||||||
|
tempStr,
|
||||||
|
windStr,
|
||||||
|
waveStr,
|
||||||
|
'🌊',
|
||||||
|
highTideStr,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (rows[0] as Record<string, unknown>).weather_sn as number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 미디어 정보 조회
|
// 미디어 정보 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -300,3 +467,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
|
|||||||
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
|
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 이미지 분석 데이터 조회
|
||||||
|
// ============================================================
|
||||||
|
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT IMG_RSLT_DATA
|
||||||
|
FROM wing.SPIL_DATA
|
||||||
|
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
|
||||||
|
ORDER BY SPIL_DATA_SN
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
117
backend/src/map-base/mapBaseRouter.ts
Normal file
117
backend/src/map-base/mapBaseRouter.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
import {
|
||||||
|
listMapBase,
|
||||||
|
getActiveMapTypes,
|
||||||
|
createMapBase,
|
||||||
|
updateMapBase,
|
||||||
|
deleteMapBase,
|
||||||
|
} from './mapBaseService.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// GET /api/map-base/active — 활성 지도 목록 (전체 사용자)
|
||||||
|
router.get('/active', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const types = await getActiveMapTypes()
|
||||||
|
res.json(types)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[map-base] 활성 목록 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/map-base — 전체 목록 (관리자)
|
||||||
|
router.get('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string, 10) || 1
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100)
|
||||||
|
const result = await listMapBase(page, limit)
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[map-base] 목록 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/map-base — 등록 (관리자)
|
||||||
|
router.post('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
|
||||||
|
mapKey?: string;
|
||||||
|
mapNm?: string;
|
||||||
|
mapLevelCd?: string;
|
||||||
|
mapSrc?: string;
|
||||||
|
mapDc?: string;
|
||||||
|
useYn?: string;
|
||||||
|
}
|
||||||
|
if (!mapKey || !mapNm) {
|
||||||
|
res.status(400).json({ error: '지도 키와 이름은 필수입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created = await createMapBase({
|
||||||
|
mapKey,
|
||||||
|
mapNm,
|
||||||
|
mapLevelCd,
|
||||||
|
mapSrc,
|
||||||
|
mapDc,
|
||||||
|
useYn,
|
||||||
|
regId: req.user?.sub,
|
||||||
|
regNm: req.user?.name,
|
||||||
|
})
|
||||||
|
res.json(created)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[map-base] 등록 오류:', err)
|
||||||
|
res.status(500).json({ error: '지도 등록 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/map-base/update — 수정 (관리자)
|
||||||
|
router.post('/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mapSn, mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
|
||||||
|
mapSn?: number;
|
||||||
|
mapKey?: string;
|
||||||
|
mapNm?: string;
|
||||||
|
mapLevelCd?: string | null;
|
||||||
|
mapSrc?: string | null;
|
||||||
|
mapDc?: string | null;
|
||||||
|
useYn?: string;
|
||||||
|
}
|
||||||
|
if (!mapSn) {
|
||||||
|
res.status(400).json({ error: '지도 번호가 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated = await updateMapBase(Number(mapSn), { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn })
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json(updated)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[map-base] 수정 오류:', err)
|
||||||
|
res.status(500).json({ error: '지도 수정 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/map-base/delete — 삭제 (관리자, soft-delete)
|
||||||
|
router.post('/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mapSn } = req.body as { mapSn?: number }
|
||||||
|
if (!mapSn) {
|
||||||
|
res.status(400).json({ error: '지도 번호가 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ok = await deleteMapBase(Number(mapSn))
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[map-base] 삭제 오류:', err)
|
||||||
|
res.status(500).json({ error: '지도 삭제 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
140
backend/src/map-base/mapBaseService.ts
Normal file
140
backend/src/map-base/mapBaseService.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js'
|
||||||
|
|
||||||
|
export interface MapBaseItem {
|
||||||
|
mapSn: number;
|
||||||
|
mapKey: string;
|
||||||
|
mapNm: string;
|
||||||
|
mapLevelCd: string | null;
|
||||||
|
mapSrc: string | null;
|
||||||
|
mapDc: string | null;
|
||||||
|
useYn: string;
|
||||||
|
regId: string | null;
|
||||||
|
regNm: string | null;
|
||||||
|
regDtm: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapTypeItem {
|
||||||
|
mapKey: string;
|
||||||
|
mapNm: string;
|
||||||
|
mapLevelCd: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToItem(r: Record<string, unknown>): MapBaseItem {
|
||||||
|
return {
|
||||||
|
mapSn: r.map_sn as number,
|
||||||
|
mapKey: r.map_key as string,
|
||||||
|
mapNm: r.map_nm as string,
|
||||||
|
mapLevelCd: (r.map_level_cd as string) ?? null,
|
||||||
|
mapSrc: (r.map_src as string) ?? null,
|
||||||
|
mapDc: (r.map_dc as string) ?? null,
|
||||||
|
useYn: r.use_yn as string,
|
||||||
|
regId: (r.reg_id as string) ?? null,
|
||||||
|
regNm: (r.reg_nm as string) ?? null,
|
||||||
|
regDtm: r.reg_dtm ? new Date(r.reg_dtm as string).toISOString().slice(0, 10) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMapBase(
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
): Promise<{ rows: MapBaseItem[]; total: number }> {
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
const countResult = await wingPool.query(`SELECT COUNT(*) AS cnt FROM wing.MAP_BASE_DATA WHERE DEL_YN = 'N'`)
|
||||||
|
const total = parseInt(countResult.rows[0].cnt as string, 10)
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`SELECT MAP_SN, MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC,
|
||||||
|
USE_YN, REG_ID, REG_NM, REG_DTM
|
||||||
|
FROM wing.MAP_BASE_DATA
|
||||||
|
WHERE DEL_YN = 'N'
|
||||||
|
ORDER BY MAP_SN
|
||||||
|
LIMIT $1 OFFSET $2`,
|
||||||
|
[limit, offset]
|
||||||
|
)
|
||||||
|
return { rows: rows.map(rowToItem), total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveMapTypes(): Promise<MapTypeItem[]> {
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`SELECT MAP_KEY, MAP_NM, MAP_LEVEL_CD
|
||||||
|
FROM wing.MAP_BASE_DATA
|
||||||
|
WHERE USE_YN = 'Y' AND DEL_YN = 'N'
|
||||||
|
ORDER BY MAP_SN`
|
||||||
|
)
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
mapKey: r.map_key as string,
|
||||||
|
mapNm: r.map_nm as string,
|
||||||
|
mapLevelCd: (r.map_level_cd as string) ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMapBase(data: {
|
||||||
|
mapKey: string;
|
||||||
|
mapNm: string;
|
||||||
|
mapLevelCd?: string;
|
||||||
|
mapSrc?: string;
|
||||||
|
mapDc?: string;
|
||||||
|
useYn?: string;
|
||||||
|
regId?: string;
|
||||||
|
regNm?: string;
|
||||||
|
}): Promise<MapBaseItem> {
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`INSERT INTO wing.MAP_BASE_DATA
|
||||||
|
(MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC, USE_YN, REG_ID, REG_NM)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.mapKey,
|
||||||
|
data.mapNm,
|
||||||
|
data.mapLevelCd ?? null,
|
||||||
|
data.mapSrc ?? null,
|
||||||
|
data.mapDc ?? null,
|
||||||
|
data.useYn ?? 'Y',
|
||||||
|
data.regId ?? null,
|
||||||
|
data.regNm ?? null,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return rowToItem(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMapBase(
|
||||||
|
mapSn: number,
|
||||||
|
data: {
|
||||||
|
mapKey?: string;
|
||||||
|
mapNm?: string;
|
||||||
|
mapLevelCd?: string | null;
|
||||||
|
mapSrc?: string | null;
|
||||||
|
mapDc?: string | null;
|
||||||
|
useYn?: string;
|
||||||
|
}
|
||||||
|
): Promise<MapBaseItem | null> {
|
||||||
|
const fields: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (data.mapKey !== undefined) { fields.push(`MAP_KEY = $${idx++}`); params.push(data.mapKey) }
|
||||||
|
if (data.mapNm !== undefined) { fields.push(`MAP_NM = $${idx++}`); params.push(data.mapNm) }
|
||||||
|
if (data.mapLevelCd !== undefined) { fields.push(`MAP_LEVEL_CD = $${idx++}`); params.push(data.mapLevelCd) }
|
||||||
|
if (data.mapSrc !== undefined) { fields.push(`MAP_SRC = $${idx++}`); params.push(data.mapSrc) }
|
||||||
|
if (data.mapDc !== undefined) { fields.push(`MAP_DC = $${idx++}`); params.push(data.mapDc) }
|
||||||
|
if (data.useYn !== undefined) { fields.push(`USE_YN = $${idx++}`); params.push(data.useYn) }
|
||||||
|
|
||||||
|
if (fields.length === 0) return null
|
||||||
|
fields.push(`MDFCN_DTM = NOW()`)
|
||||||
|
|
||||||
|
params.push(mapSn)
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`UPDATE wing.MAP_BASE_DATA SET ${fields.join(', ')} WHERE MAP_SN = $${idx} RETURNING *`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToItem(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMapBase(mapSn: number): Promise<boolean> {
|
||||||
|
const { rowCount } = await wingPool.query(
|
||||||
|
`UPDATE wing.MAP_BASE_DATA SET DEL_YN = 'Y', MDFCN_DTM = NOW() WHERE MAP_SN = $1`,
|
||||||
|
[mapSn]
|
||||||
|
)
|
||||||
|
return (rowCount ?? 0) > 0
|
||||||
|
}
|
||||||
@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 본문 크기 제한 (기본 100kb)
|
* JSON 본문 크기 제한 (보고서 지도 캡처 이미지 포함 대응: 5mb)
|
||||||
*/
|
*/
|
||||||
export const BODY_SIZE_LIMIT = '100kb'
|
export const BODY_SIZE_LIMIT = '5mb'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 응답 헤더에서 서버 정보 제거
|
* 응답 헤더에서 서버 정보 제거
|
||||||
|
|||||||
20
backend/src/monitor/monitorRouter.ts
Normal file
20
backend/src/monitor/monitorRouter.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth } from '../auth/authMiddleware.js'
|
||||||
|
import { getNumericalDataStatus } from './monitorService.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
// GET /api/monitor/numerical — 수치예측자료 다운로드 상태 조회
|
||||||
|
router.get('/numerical', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getNumericalDataStatus()
|
||||||
|
res.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[monitor] 수치예측자료 상태 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '수치예측자료 상태를 조회할 수 없습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
121
backend/src/monitor/monitorService.ts
Normal file
121
backend/src/monitor/monitorService.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
export interface NumericalDataStatus {
|
||||||
|
modelName: string;
|
||||||
|
jobName: string;
|
||||||
|
lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN';
|
||||||
|
lastDataDate: string | null; // 데이터 기준일 (YYYY-MM-DD)
|
||||||
|
lastDownloadedAt: string | null; // 마지막 실행 완료 시각 (ISO)
|
||||||
|
nextScheduledAt: string | null; // Quartz 다음 예정 시각 (ISO)
|
||||||
|
durationSec: number | null; // 소요 시간 (초)
|
||||||
|
consecutiveFailures: number; // 연속 실패 횟수
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mock 데이터 (Spring Batch/Quartz DB 연동 전)
|
||||||
|
// DB 연동 준비 완료 후 getMockNumericalDataStatus → getActualNumericalDataStatus 교체
|
||||||
|
// ============================================================
|
||||||
|
const MOCK_DATA: NumericalDataStatus[] = [
|
||||||
|
{
|
||||||
|
modelName: 'HYCOM',
|
||||||
|
jobName: 'downloadHycomJob',
|
||||||
|
lastStatus: 'COMPLETED',
|
||||||
|
lastDataDate: '2026-03-25',
|
||||||
|
lastDownloadedAt: '2026-03-25T06:12:34',
|
||||||
|
nextScheduledAt: '2026-03-25T12:00:00',
|
||||||
|
durationSec: 342,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'GFS',
|
||||||
|
jobName: 'downloadGfsJob',
|
||||||
|
lastStatus: 'COMPLETED',
|
||||||
|
lastDataDate: '2026-03-25',
|
||||||
|
lastDownloadedAt: '2026-03-25T06:48:11',
|
||||||
|
nextScheduledAt: '2026-03-25T12:00:00',
|
||||||
|
durationSec: 518,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'WW3',
|
||||||
|
jobName: 'downloadWw3Job',
|
||||||
|
lastStatus: 'FAILED',
|
||||||
|
lastDataDate: '2026-03-24',
|
||||||
|
lastDownloadedAt: '2026-03-25T07:03:55',
|
||||||
|
nextScheduledAt: '2026-03-25T13:00:00',
|
||||||
|
durationSec: null,
|
||||||
|
consecutiveFailures: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'KOAST POS_WIND',
|
||||||
|
jobName: 'downloadKoastWindJob',
|
||||||
|
lastStatus: 'COMPLETED',
|
||||||
|
lastDataDate: '2026-03-25',
|
||||||
|
lastDownloadedAt: '2026-03-25T07:21:05',
|
||||||
|
nextScheduledAt: '2026-03-25T13:00:00',
|
||||||
|
durationSec: 127,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'KOAST POS_HYDR',
|
||||||
|
jobName: 'downloadKoastHydrJob',
|
||||||
|
lastStatus: 'COMPLETED',
|
||||||
|
lastDataDate: '2026-03-25',
|
||||||
|
lastDownloadedAt: '2026-03-25T07:35:48',
|
||||||
|
nextScheduledAt: '2026-03-25T13:00:00',
|
||||||
|
durationSec: 183,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'KOAST POS_WAVE',
|
||||||
|
jobName: 'downloadKoastWaveJob',
|
||||||
|
lastStatus: 'COMPLETED',
|
||||||
|
lastDataDate: '2026-03-25',
|
||||||
|
lastDownloadedAt: '2026-03-25T07:52:19',
|
||||||
|
nextScheduledAt: '2026-03-25T13:00:00',
|
||||||
|
durationSec: 156,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getNumericalDataStatus(): Promise<NumericalDataStatus[]> {
|
||||||
|
// TODO: Spring Batch + Quartz DB 테이블 생성 후 아래 실제 쿼리로 교체
|
||||||
|
//
|
||||||
|
// import { wingDb } from '../db/wingDb.js'
|
||||||
|
//
|
||||||
|
// -- 각 Job의 최신 실행 결과 조회 (BATCH_JOB_EXECUTION)
|
||||||
|
// SELECT
|
||||||
|
// ji.JOB_NAME,
|
||||||
|
// je.START_TIME, je.END_TIME,
|
||||||
|
// je.STATUS, je.EXIT_CODE, je.EXIT_MESSAGE,
|
||||||
|
// jep.STRING_VAL AS data_date,
|
||||||
|
// EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))::INT AS duration_sec
|
||||||
|
// FROM BATCH_JOB_EXECUTION je
|
||||||
|
// JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
// LEFT JOIN BATCH_JOB_EXECUTION_PARAMS jep
|
||||||
|
// ON je.JOB_EXECUTION_ID = jep.JOB_EXECUTION_ID
|
||||||
|
// AND jep.KEY_NAME = 'data_date'
|
||||||
|
// WHERE je.JOB_EXECUTION_ID IN (
|
||||||
|
// SELECT MAX(je2.JOB_EXECUTION_ID)
|
||||||
|
// FROM BATCH_JOB_EXECUTION je2
|
||||||
|
// GROUP BY je2.JOB_INSTANCE_ID
|
||||||
|
// )
|
||||||
|
// ORDER BY je.START_TIME DESC;
|
||||||
|
//
|
||||||
|
// -- Quartz 다음 실행 예정 시각 (NEXT_FIRE_TIME은 epoch milliseconds)
|
||||||
|
// SELECT JOB_NAME, to_timestamp(NEXT_FIRE_TIME / 1000) AS next_fire_time
|
||||||
|
// FROM QRTZ_TRIGGERS;
|
||||||
|
//
|
||||||
|
// -- 연속 실패 횟수 집계 (최근 실행부터 COMPLETED 전까지 카운트)
|
||||||
|
// SELECT ji.JOB_NAME, COUNT(*) AS consecutive_failures
|
||||||
|
// FROM BATCH_JOB_EXECUTION je
|
||||||
|
// JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
// WHERE je.STATUS = 'FAILED'
|
||||||
|
// AND je.JOB_EXECUTION_ID > (
|
||||||
|
// SELECT COALESCE(MAX(je2.JOB_EXECUTION_ID), 0)
|
||||||
|
// FROM BATCH_JOB_EXECUTION je2
|
||||||
|
// WHERE je2.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID
|
||||||
|
// AND je2.STATUS = 'COMPLETED'
|
||||||
|
// )
|
||||||
|
// GROUP BY ji.JOB_NAME;
|
||||||
|
|
||||||
|
return MOCK_DATA;
|
||||||
|
}
|
||||||
563
backend/src/prediction/backtrackAnalysisService.ts
Normal file
563
backend/src/prediction/backtrackAnalysisService.ts
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
import { getBacktrack } from './predictionService.js';
|
||||||
|
|
||||||
|
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003';
|
||||||
|
const VESSEL_TRACK_API_URL = process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||||
|
const VESSEL_TRACK_COOKIE = process.env.VESSEL_TRACK_COOKIE ?? '';
|
||||||
|
|
||||||
|
// 유종 코드(DB) → OpenDrift 유종 코드 매핑
|
||||||
|
const OIL_TYPE_MAP: Record<string, string> = {
|
||||||
|
'BUNKER_C': 'GENERIC BUNKER C',
|
||||||
|
'DIESEL': 'GENERIC DIESEL',
|
||||||
|
'CRUDE_OIL': 'WEST TEXAS INTERMEDIATE (WTI)',
|
||||||
|
'HEAVY_FUEL_OIL': 'GENERIC HEAVY FUEL OIL',
|
||||||
|
'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)',
|
||||||
|
'GASOLINE': 'GENERIC GASOLINE',
|
||||||
|
};
|
||||||
|
|
||||||
|
// AIS 선박유형 코드 → 위험도 점수 매핑
|
||||||
|
// AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선
|
||||||
|
const VESSEL_TYPE_SCORES: Array<[number, number, number]> = [
|
||||||
|
[80, 89, 1.0], // 유조선 계열
|
||||||
|
[70, 79, 0.5], // 화물선 계열
|
||||||
|
[30, 39, 0.3], // 어선
|
||||||
|
];
|
||||||
|
|
||||||
|
const RANK_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6',
|
||||||
|
'#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b', '#6366f1'];
|
||||||
|
|
||||||
|
interface PythonParticle { lat: number; lon: number }
|
||||||
|
interface PythonTimeStep {
|
||||||
|
particles: PythonParticle[];
|
||||||
|
center_lat?: number;
|
||||||
|
center_lon?: number;
|
||||||
|
remaining_volume_m3: number;
|
||||||
|
weathered_volume_m3: number;
|
||||||
|
pollution_area_km2: number;
|
||||||
|
beached_volume_m3: number;
|
||||||
|
pollution_coast_length_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 선박 항적 API 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface VesselTrackApiRequest {
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
mode: 'SEQUENTIAL';
|
||||||
|
polygons: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinates: [number, number][];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChnPrmShipInfo {
|
||||||
|
imo: number;
|
||||||
|
name: string;
|
||||||
|
callsign: string;
|
||||||
|
vesselType: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
sog: number;
|
||||||
|
cog: number;
|
||||||
|
heading: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
draught: number;
|
||||||
|
destination: string;
|
||||||
|
status: string;
|
||||||
|
messageTimestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VesselTrack {
|
||||||
|
vesselId: string;
|
||||||
|
nationalCode: string;
|
||||||
|
geometry: [number, number][]; // [lon, lat][]
|
||||||
|
speeds: number[]; // knots
|
||||||
|
totalDistance: number;
|
||||||
|
avgSpeed: number;
|
||||||
|
maxSpeed: number;
|
||||||
|
pointCount: number;
|
||||||
|
shipName: string;
|
||||||
|
shipType: string; // vessel type code string (e.g. "74")
|
||||||
|
shipKindCode: string;
|
||||||
|
chnPrmShipInfo: ChnPrmShipInfo | null;
|
||||||
|
timestamps: string[]; // Unix timestamp strings
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HitDetail {
|
||||||
|
polygonId: string;
|
||||||
|
polygonName: string;
|
||||||
|
entryTimestamp: number;
|
||||||
|
exitTimestamp: number;
|
||||||
|
hitPointCount: number;
|
||||||
|
visitIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VesselTrackApiResponse {
|
||||||
|
tracks: VesselTrack[];
|
||||||
|
hitDetails: Record<string, HitDetail[]>;
|
||||||
|
summary: {
|
||||||
|
totalVessels: number;
|
||||||
|
totalPoints: number;
|
||||||
|
mode: string;
|
||||||
|
polygonIds: string[];
|
||||||
|
processingTimeMs: number;
|
||||||
|
cachedDates: string[];
|
||||||
|
totalCachedVessels: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// haversine 거리 계산 (NM)
|
||||||
|
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 3440.065; // NM
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// anlysRange 파싱: '12', '±12시간', '12h' 등 → 숫자
|
||||||
|
function parseAnalysisHours(anlysRange: string): number {
|
||||||
|
const m = anlysRange.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간 포맷: Date → 'HH:MM' 형식 (KST)
|
||||||
|
function toTimeLabel(d: Date): string {
|
||||||
|
const kst = new Date(d.getTime() + 9 * 3600000);
|
||||||
|
return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 파티클 스텝별 탐색 영역 계산
|
||||||
|
function computeParticleSteps(
|
||||||
|
rawResult: PythonTimeStep[],
|
||||||
|
spillTime: Date,
|
||||||
|
anlysHours: number,
|
||||||
|
): Array<{ stepIdx: number; atTime: Date; centroid: { lat: number; lon: number }; radiusNm: number }> {
|
||||||
|
const totalSteps = rawResult.length;
|
||||||
|
const msPerStep = anlysHours * 3600000 / Math.max(totalSteps - 1, 1);
|
||||||
|
|
||||||
|
return rawResult.map((step, idx) => {
|
||||||
|
const atTime = new Date(spillTime.getTime() - idx * msPerStep);
|
||||||
|
const particles = step.particles.filter(p => p.lat != null && p.lon != null);
|
||||||
|
|
||||||
|
let centroid: { lat: number; lon: number };
|
||||||
|
let radiusNm: number;
|
||||||
|
|
||||||
|
if (particles.length === 0) {
|
||||||
|
centroid = { lat: step.center_lat ?? 0, lon: step.center_lon ?? 0 };
|
||||||
|
radiusNm = 5;
|
||||||
|
} else {
|
||||||
|
centroid = {
|
||||||
|
lat: particles.reduce((s, p) => s + p.lat, 0) / particles.length,
|
||||||
|
lon: particles.reduce((s, p) => s + p.lon, 0) / particles.length,
|
||||||
|
};
|
||||||
|
const maxDist = Math.max(...particles.map(p => haversineNm(centroid.lat, centroid.lon, p.lat, p.lon)));
|
||||||
|
radiusNm = Math.max(maxDist * 1.2, 2); // 최소 2 NM
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stepIdx: idx, atTime, centroid, radiusNm };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박유형 점수
|
||||||
|
function getVesselTypeScore(vesselTp: number | null): number {
|
||||||
|
if (vesselTp == null) return 0.3;
|
||||||
|
for (const [min, max, score] of VESSEL_TYPE_SCORES) {
|
||||||
|
if (vesselTp >= min && vesselTp <= max) return score;
|
||||||
|
}
|
||||||
|
return 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 급감속 감지: 속도 배열에서 50% 이상 감소 여부 → 0~1
|
||||||
|
function detectSpeedDrop(speeds: number[]): number {
|
||||||
|
const valid = speeds.filter(s => s > 0);
|
||||||
|
if (valid.length < 2) return 0;
|
||||||
|
let maxDrop = 0;
|
||||||
|
for (let i = 1; i < valid.length; i++) {
|
||||||
|
const drop = (valid[i - 1] - valid[i]) / valid[i - 1];
|
||||||
|
if (drop > maxDrop) maxDrop = drop;
|
||||||
|
}
|
||||||
|
return maxDrop > 0.5 ? Math.min(1, maxDrop) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIS 단절 감지: 타임스탬프 배열에서 평균 간격의 3배 이상 gap 존재 여부
|
||||||
|
function detectAisGapFromTimestamps(timestamps: string[]): boolean {
|
||||||
|
if (timestamps.length < 3) return false;
|
||||||
|
const sorted = timestamps.map(Number).sort((a, b) => a - b);
|
||||||
|
const avgInterval = (sorted[sorted.length - 1] - sorted[0]) / (sorted.length - 1);
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i] - sorted[i - 1] > avgInterval * 3) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vesselTypeToLabel(tp: number | null): string {
|
||||||
|
if (tp == null) return '미분류';
|
||||||
|
if (tp >= 80 && tp <= 89) return '유조선';
|
||||||
|
if (tp >= 70 && tp <= 79) return '화물선';
|
||||||
|
if (tp >= 30 && tp <= 39) return '어선';
|
||||||
|
if (tp >= 60 && tp <= 69) return '여객선';
|
||||||
|
if (tp >= 90 && tp <= 99) return '특수선';
|
||||||
|
return `선박(${tp})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankedVessel {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
imo: string;
|
||||||
|
type: string;
|
||||||
|
flag: string;
|
||||||
|
flagCountry: string;
|
||||||
|
probability: number;
|
||||||
|
closestTime: string;
|
||||||
|
closestDistance: number;
|
||||||
|
speedChange: string;
|
||||||
|
aisStatus: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
mmsi: string;
|
||||||
|
_rawScore: number;
|
||||||
|
_track: VesselTrack;
|
||||||
|
_minDistIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탐색 폴리곤 빌드 (사고위치 + 탐색반경 → 바운딩 박스)
|
||||||
|
function buildSearchPolygon(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
radiusNm: number,
|
||||||
|
): { id: string; name: string; coordinates: [number, number][] } {
|
||||||
|
const latDelta = radiusNm / 60;
|
||||||
|
const lonDelta = radiusNm / (60 * Math.cos(lat * Math.PI / 180));
|
||||||
|
return {
|
||||||
|
id: 'zone_0',
|
||||||
|
name: '역추적 탐색구역',
|
||||||
|
coordinates: [
|
||||||
|
[lon - lonDelta, lat - latDelta],
|
||||||
|
[lon + lonDelta, lat - latDelta],
|
||||||
|
[lon + lonDelta, lat + latDelta],
|
||||||
|
[lon - lonDelta, lat + latDelta],
|
||||||
|
[lon - lonDelta, lat - latDelta],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박 항적 API 호출
|
||||||
|
async function fetchVesselTracks(
|
||||||
|
spillLat: number,
|
||||||
|
spillLon: number,
|
||||||
|
srchRadiusNm: number,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
): Promise<VesselTrackApiResponse> {
|
||||||
|
const polygon = buildSearchPolygon(spillLat, spillLon, srchRadiusNm);
|
||||||
|
const toApiDatetime = (d: Date) => d.toISOString().substring(0, 19);
|
||||||
|
|
||||||
|
const body: VesselTrackApiRequest = {
|
||||||
|
startTime: toApiDatetime(startTime),
|
||||||
|
endTime: toApiDatetime(endTime),
|
||||||
|
mode: 'SEQUENTIAL',
|
||||||
|
polygons: [polygon],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[backtrack] VESSEL_TRACK_API 호출', {
|
||||||
|
url: `${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`,
|
||||||
|
startTime: body.startTime,
|
||||||
|
endTime: body.endTime,
|
||||||
|
srchRadiusNm,
|
||||||
|
polygons: body.polygons.length,
|
||||||
|
hasCookie: !!VESSEL_TRACK_COOKIE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(VESSEL_TRACK_COOKIE ? { 'Cookie': VESSEL_TRACK_COOKIE } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[backtrack] VESSEL_TRACK_API 응답', { status: res.status, ok: res.ok });
|
||||||
|
if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`);
|
||||||
|
const data = await res.json() as VesselTrackApiResponse;
|
||||||
|
console.log('[backtrack] VESSEL_TRACK_API 데이터', { totalVessels: data.summary?.totalVessels ?? 0, tracks: data.tracks?.length ?? 0 });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스코어링 + 순위 (선박 항적 API 응답 기반)
|
||||||
|
function scoreAndRankVesselsFromApi(
|
||||||
|
tracks: VesselTrack[],
|
||||||
|
hitDetails: Record<string, HitDetail[]>,
|
||||||
|
spillLat: number,
|
||||||
|
spillLon: number,
|
||||||
|
srchRadiusNm: number,
|
||||||
|
anlysHours: number,
|
||||||
|
): RankedVessel[] {
|
||||||
|
const anlysWindowSec = anlysHours * 3600;
|
||||||
|
|
||||||
|
const scored = tracks.map(track => {
|
||||||
|
// 1. spatialScore (40%): 사고 지점과의 최근접 거리
|
||||||
|
let minDist = Infinity;
|
||||||
|
let minDistIdx = 0;
|
||||||
|
track.geometry.forEach(([lon, lat], idx) => {
|
||||||
|
const d = haversineNm(lat, lon, spillLat, spillLon);
|
||||||
|
if (d < minDist) { minDist = d; minDistIdx = idx; }
|
||||||
|
});
|
||||||
|
const spatialScore = Math.max(0, 1 - minDist / srchRadiusNm);
|
||||||
|
|
||||||
|
// 2. temporalScore (25%): 탐색구역 체류시간 / 분석 윈도우
|
||||||
|
const hits = hitDetails[track.vesselId] ?? [];
|
||||||
|
const totalTimeInZoneSec = hits.reduce((sum, h) => sum + (h.exitTimestamp - h.entryTimestamp), 0);
|
||||||
|
const temporalScore = Math.min(1, totalTimeInZoneSec / anlysWindowSec);
|
||||||
|
|
||||||
|
// 3. behaviorScore (20%): 급감속 + AIS 단절
|
||||||
|
const speedDrop = detectSpeedDrop(track.speeds);
|
||||||
|
const aisGap = detectAisGapFromTimestamps(track.timestamps);
|
||||||
|
const behaviorScore = Math.min(1, speedDrop * 0.6 + (aisGap ? 0.4 : 0));
|
||||||
|
|
||||||
|
// 4. vesselTypeScore (15%): 선박 유형별 위험도
|
||||||
|
const vesselTpRaw = parseInt(track.shipType ?? '', 10);
|
||||||
|
const vesselTp = isNaN(vesselTpRaw) ? null : vesselTpRaw;
|
||||||
|
const vesselTypeScore = getVesselTypeScore(vesselTp);
|
||||||
|
|
||||||
|
const rawScore = 0.40 * spatialScore + 0.25 * temporalScore + 0.20 * behaviorScore + 0.15 * vesselTypeScore;
|
||||||
|
|
||||||
|
const speedChangeLabel = speedDrop > 0.5 ? '급감속' : speedDrop > 0.2 ? '감속' : '정상';
|
||||||
|
const aisStatusLabel = aisGap ? 'AIS단절' : '정상';
|
||||||
|
|
||||||
|
const closestTs = track.timestamps[minDistIdx];
|
||||||
|
const closestDate = closestTs ? new Date(Number(closestTs) * 1000) : new Date();
|
||||||
|
|
||||||
|
const descParts: string[] = [];
|
||||||
|
if (speedDrop > 0.5) descParts.push(`${toTimeLabel(closestDate)} 급감속 감지`);
|
||||||
|
if (aisGap) descParts.push('AIS 신호 단절 구간 존재');
|
||||||
|
if (minDist < 1) descParts.push(`최근접 ${minDist.toFixed(2)} NM 이내 통과`);
|
||||||
|
|
||||||
|
const imo = track.chnPrmShipInfo?.imo?.toString() ?? '';
|
||||||
|
const vesselName = track.shipName || track.chnPrmShipInfo?.name || `MMSI:${track.vesselId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mmsi: track.vesselId,
|
||||||
|
imo,
|
||||||
|
name: vesselName,
|
||||||
|
type: vesselTypeToLabel(vesselTp),
|
||||||
|
flag: track.nationalCode ?? '',
|
||||||
|
flagCountry: track.nationalCode ?? '',
|
||||||
|
closestTime: toTimeLabel(closestDate),
|
||||||
|
closestDistance: Math.round(minDist * 100) / 100,
|
||||||
|
speedChange: speedChangeLabel,
|
||||||
|
aisStatus: aisStatusLabel,
|
||||||
|
description: descParts.join(' · '),
|
||||||
|
probability: 0,
|
||||||
|
rank: 0,
|
||||||
|
color: '',
|
||||||
|
_rawScore: rawScore,
|
||||||
|
_track: track,
|
||||||
|
_minDistIdx: minDistIdx,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
scored.sort((a, b) => b._rawScore - a._rawScore);
|
||||||
|
const top = scored.slice(0, 10);
|
||||||
|
const maxScore = top[0]?._rawScore ?? 1;
|
||||||
|
|
||||||
|
return top.map((v, i) => ({
|
||||||
|
...v,
|
||||||
|
rank: i + 1,
|
||||||
|
probability: Math.round((v._rawScore / maxScore) * 95 * 10) / 10,
|
||||||
|
color: RANK_COLORS[i] ?? '#888888',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplayShip {
|
||||||
|
vesselName: string;
|
||||||
|
color: string;
|
||||||
|
path: Array<{ lat: number; lon: number }>;
|
||||||
|
speedLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리플레이용 선박 경로 빌드 (상위 5척, API geometry 직접 사용)
|
||||||
|
function buildReplayShipsFromApi(ranked: RankedVessel[]): ReplayShip[] {
|
||||||
|
return ranked.slice(0, 5).map(v => ({
|
||||||
|
vesselName: v.name,
|
||||||
|
color: v.color,
|
||||||
|
path: v._track.geometry.map(([lon, lat]) => ({ lat, lon })),
|
||||||
|
speedLabels: v._track.speeds.map(s => `${s.toFixed(1)} kts`),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollisionEvent {
|
||||||
|
position: { lat: number; lon: number };
|
||||||
|
timeLabel: string;
|
||||||
|
progressPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최고 확률 선박의 최근접 지점 이벤트
|
||||||
|
function findCollisionEventFromApi(
|
||||||
|
ranked: RankedVessel[],
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
): CollisionEvent | null {
|
||||||
|
const top = ranked[0];
|
||||||
|
if (!top) return null;
|
||||||
|
|
||||||
|
const idx = top._minDistIdx;
|
||||||
|
const ts = top._track.timestamps[idx];
|
||||||
|
const pointDate = ts ? new Date(Number(ts) * 1000) : new Date();
|
||||||
|
|
||||||
|
const totalMs = endTime.getTime() - startTime.getTime();
|
||||||
|
const pointMs = pointDate.getTime() - startTime.getTime();
|
||||||
|
const progressPercent = totalMs > 0
|
||||||
|
? Math.max(0, Math.min(100, Math.round((pointMs / totalMs) * 100)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const point = top._track.geometry[idx];
|
||||||
|
const [lon, lat] = point ?? [0, 0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: { lat, lon },
|
||||||
|
timeLabel: toTimeLabel(pointDate) + ' 최근접',
|
||||||
|
progressPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 분석 함수 (외부에서 호출)
|
||||||
|
// ============================================================
|
||||||
|
export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.BACKTRACK SET EXEC_STTS_CD='RUNNING' WHERE BACKTRACK_SN=$1`,
|
||||||
|
[backtrackSn],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bt = await getBacktrack(backtrackSn);
|
||||||
|
if (!bt || bt.lat == null || bt.lon == null || !bt.estSpilDtm) {
|
||||||
|
throw new Error('역추적 레코드 정보 불충분 (lat/lon/estSpilDtm 필요)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const anlysHours = parseAnalysisHours(bt.anlysRange ?? '12');
|
||||||
|
const spillTime = new Date(bt.estSpilDtm);
|
||||||
|
const analysisStart = new Date(spillTime.getTime() - anlysHours * 3600000);
|
||||||
|
|
||||||
|
// SPIL_DATA에서 유출량 및 유종 조회
|
||||||
|
let matVol: number | null = null;
|
||||||
|
let matTy: string | undefined;
|
||||||
|
try {
|
||||||
|
const { rows: spillRows } = await wingPool.query(
|
||||||
|
`SELECT SPIL_QTY, OIL_TP_CD FROM wing.SPIL_DATA WHERE ACDNT_SN=$1 ORDER BY SPIL_DATA_SN ASC LIMIT 1`,
|
||||||
|
[bt.acdntSn],
|
||||||
|
);
|
||||||
|
if (spillRows.length > 0) {
|
||||||
|
const row = spillRows[0] as Record<string, unknown>;
|
||||||
|
matVol = row['spil_qty'] != null ? Number(row['spil_qty']) : null;
|
||||||
|
const oilTpCd = row['oil_tp_cd'] as string | null;
|
||||||
|
matTy = oilTpCd ? (OIL_TYPE_MAP[oilTpCd] ?? oilTpCd) : undefined;
|
||||||
|
}
|
||||||
|
} catch (spillErr) {
|
||||||
|
console.warn('[backtrack] SPIL_DATA 조회 실패, matVol 없이 진행:', spillErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python 역방향 시뮬레이션 실행 (파티클 시각화용)
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 요청', { lat: bt.lat, lon: bt.lon, anlysHours, matVol, matTy, spillTime: spillTime.toISOString() });
|
||||||
|
let rawResult: PythonTimeStep[];
|
||||||
|
try {
|
||||||
|
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lat: bt.lat,
|
||||||
|
lon: bt.lon,
|
||||||
|
startTime: spillTime.toISOString(),
|
||||||
|
runTime: anlysHours,
|
||||||
|
matVol: matVol ?? 1,
|
||||||
|
matTy,
|
||||||
|
name: `BACKTRACK_${backtrackSn}`,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`);
|
||||||
|
const pythonData = await pythonRes.json() as { success: boolean; result: PythonTimeStep[] };
|
||||||
|
rawResult = pythonData.result ?? [];
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 완료 — steps:', rawResult.length);
|
||||||
|
} catch (pyErr) {
|
||||||
|
// Python 미연동 시 폴백: 빈 파티클 스텝 생성
|
||||||
|
console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr);
|
||||||
|
rawResult = Array.from({ length: anlysHours + 1 }, () => ({
|
||||||
|
particles: [],
|
||||||
|
remaining_volume_m3: 0,
|
||||||
|
weathered_volume_m3: 0,
|
||||||
|
pollution_area_km2: 0,
|
||||||
|
beached_volume_m3: 0,
|
||||||
|
pollution_coast_length_m: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = computeParticleSteps(rawResult, spillTime, anlysHours);
|
||||||
|
if (rawResult.every(s => s.particles.length === 0)) {
|
||||||
|
steps.forEach((s, i) => {
|
||||||
|
s.centroid = { lat: bt.lat!, lon: bt.lon! };
|
||||||
|
s.radiusNm = (bt.srchRadiusNm ?? 10) + i * 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박 항적 API 호출
|
||||||
|
const srchRadius = bt.srchRadiusNm ?? 10;
|
||||||
|
console.log('[backtrack] 선박 항적 API 호출 시작', { srchRadius, analysisStart: analysisStart.toISOString(), spillTime: spillTime.toISOString() });
|
||||||
|
const apiResponse = await fetchVesselTracks(
|
||||||
|
bt.lat, bt.lon, srchRadius, analysisStart, spillTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalVessels = apiResponse.summary.totalVessels;
|
||||||
|
console.log('[backtrack] 선박 점수 계산 시작 — totalVessels:', totalVessels);
|
||||||
|
const ranked = scoreAndRankVesselsFromApi(
|
||||||
|
apiResponse.tracks,
|
||||||
|
apiResponse.hitDetails,
|
||||||
|
bt.lat, bt.lon, srchRadius, anlysHours,
|
||||||
|
);
|
||||||
|
console.log('[backtrack] 선박 점수 계산 완료 — ranked:', ranked.length, '/', totalVessels, '| top:', ranked[0]?.name, ranked[0]?.probability);
|
||||||
|
const replayShips = buildReplayShipsFromApi(ranked);
|
||||||
|
const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime);
|
||||||
|
|
||||||
|
const timeRange = {
|
||||||
|
start: analysisStart.toISOString(),
|
||||||
|
end: spillTime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// vessels에서 내부 필드 제거
|
||||||
|
const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v);
|
||||||
|
|
||||||
|
// rawResult 샘플링 (최대 24 스텝, 파티클은 [lon, lat] 형식으로 저장)
|
||||||
|
const MAX_BACKWARD_STEPS = 24;
|
||||||
|
const sampleRate = Math.max(1, Math.ceil(rawResult.length / MAX_BACKWARD_STEPS));
|
||||||
|
const backwardParticles = rawResult
|
||||||
|
.filter((_, i) => i % sampleRate === 0)
|
||||||
|
.slice(0, MAX_BACKWARD_STEPS)
|
||||||
|
.map(step => step.particles.map(p => [p.lon, p.lat] as [number, number]));
|
||||||
|
|
||||||
|
const rsltData = { vessels, replayShips, collisionEvent, timeRange, backwardParticles };
|
||||||
|
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.BACKTRACK
|
||||||
|
SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, TOTAL_VESSELS=$2
|
||||||
|
WHERE BACKTRACK_SN=$3`,
|
||||||
|
[JSON.stringify(rsltData), totalVessels, backtrackSn],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info(`[backtrack] 분석 완료 SN=${backtrackSn}, 후보선박=${ranked.length}/${totalVessels}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[backtrack] 분석 실패:', err);
|
||||||
|
const errMsg = err instanceof Error ? err.message : '알 수 없는 오류';
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.BACKTRACK SET EXEC_STTS_CD='FAILED', RSLT_DATA=$1 WHERE BACKTRACK_SN=$2`,
|
||||||
|
[JSON.stringify({ error: errMsg }), backtrackSn],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
|
|||||||
return { lat, lon, occurredAt };
|
return { lat, lon, occurredAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
|
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
|
||||||
const fileId = crypto.randomUUID();
|
const fileId = crypto.randomUUID();
|
||||||
|
|
||||||
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
||||||
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
|||||||
const volume = firstOil?.volume ?? 0;
|
const volume = firstOil?.volume ?? 0;
|
||||||
|
|
||||||
// ACDNT INSERT
|
// ACDNT INSERT
|
||||||
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
||||||
const acdntRes = await wingPool.query(
|
const acdntRes = await wingPool.query(
|
||||||
`INSERT INTO wing.ACDNT
|
`INSERT INTO wing.ACDNT
|
||||||
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||||
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
|||||||
await wingPool.query(
|
await wingPool.query(
|
||||||
`INSERT INTO wing.SPIL_DATA
|
`INSERT INTO wing.SPIL_DATA
|
||||||
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
||||||
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
|
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
|
||||||
[
|
[
|
||||||
acdntSn,
|
acdntSn,
|
||||||
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import multer from 'multer';
|
|||||||
import {
|
import {
|
||||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||||
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||||
|
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||||
|
getOilSpillSummary,
|
||||||
} from './predictionService.js';
|
} from './predictionService.js';
|
||||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
@ -15,8 +18,11 @@ const router = express.Router();
|
|||||||
// GET /api/prediction/analyses — 분석 목록
|
// GET /api/prediction/analyses — 분석 목록
|
||||||
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search } = req.query;
|
const { search, acdntSn } = req.query;
|
||||||
const items = await listAnalyses({ search: search as string | undefined });
|
const items = await listAnalyses({
|
||||||
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined,
|
||||||
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[prediction] 분석 목록 오류:', err);
|
console.error('[prediction] 분석 목록 오류:', err);
|
||||||
@ -44,7 +50,7 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회
|
// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능)
|
||||||
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
@ -52,7 +58,8 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
|||||||
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await getAnalysisTrajectory(acdntSn);
|
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
||||||
|
const result = await getAnalysisTrajectory(acdntSn, predRunSn);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.json({ trajectory: null, summary: null });
|
res.json({ trajectory: null, summary: null });
|
||||||
return;
|
return;
|
||||||
@ -64,6 +71,91 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
|
||||||
|
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
||||||
|
const result = await getOilSpillSummary(acdntSn, predRunSn);
|
||||||
|
if (!result) {
|
||||||
|
res.json({ primary: null, byModel: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] oil-summary 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: 'oil-summary 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesGeoJsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/spread-particles', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitivity-evaluation', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
||||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -160,7 +252,8 @@ router.post(
|
|||||||
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
|
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
|
||||||
|
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
import { wingPool } from '../db/wingDb.js';
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
|
||||||
|
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
interface PredictionAnalysis {
|
interface PredictionAnalysis {
|
||||||
acdntSn: number;
|
acdntSn: number;
|
||||||
@ -19,6 +30,8 @@ interface PredictionAnalysis {
|
|||||||
analyst: string;
|
analyst: string;
|
||||||
officeName: string;
|
officeName: string;
|
||||||
acdntSttsCd: string;
|
acdntSttsCd: string;
|
||||||
|
predRunSn: number | null;
|
||||||
|
runDtm: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PredictionDetail {
|
interface PredictionDetail {
|
||||||
@ -113,12 +126,18 @@ interface BoomLineItem {
|
|||||||
|
|
||||||
interface ListAnalysesInput {
|
interface ListAnalysesInput {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
||||||
|
|
||||||
|
if (input.acdntSn) {
|
||||||
|
params.push(input.acdntSn);
|
||||||
|
conditions.push(`A.ACDNT_SN = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
params.push(`%${input.search}%`);
|
params.push(`%${input.search}%`);
|
||||||
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
||||||
@ -142,21 +161,31 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
S.SPIL_QTY,
|
S.SPIL_QTY,
|
||||||
S.SPIL_UNIT_CD,
|
S.SPIL_UNIT_CD,
|
||||||
S.FCST_HR,
|
S.FCST_HR,
|
||||||
|
P.PRED_RUN_SN,
|
||||||
|
P.RUN_DTM,
|
||||||
P.KOSPS_STATUS,
|
P.KOSPS_STATUS,
|
||||||
P.POSEIDON_STATUS,
|
P.POSEIDON_STATUS,
|
||||||
P.OPENDRIFT_STATUS,
|
P.OPENDRIFT_STATUS,
|
||||||
B.BACKTRACK_STATUS
|
B.BACKTRACK_STATUS,
|
||||||
|
COALESCE(U.USER_NM, A.ANALYST_NM) AS RESOLVED_ANALYST,
|
||||||
|
COALESCE(O.ORG_NM, A.OFFICE_NM) AS RESOLVED_OFFICE
|
||||||
FROM ACDNT A
|
FROM ACDNT A
|
||||||
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
|
INNER JOIN (
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
SELECT
|
||||||
ACDNT_SN,
|
ACDNT_SN,
|
||||||
|
PRED_RUN_SN,
|
||||||
|
MIN(BGNG_DTM) AS RUN_DTM,
|
||||||
|
MIN(SPIL_DATA_SN) AS SPIL_DATA_SN,
|
||||||
|
MIN(EXEC_USER_ID::TEXT)::UUID AS EXEC_USER_ID,
|
||||||
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
|
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
|
||||||
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
||||||
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
|
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
|
||||||
FROM PRED_EXEC
|
FROM PRED_EXEC
|
||||||
GROUP BY ACDNT_SN
|
GROUP BY ACDNT_SN, PRED_RUN_SN
|
||||||
) P ON P.ACDNT_SN = A.ACDNT_SN
|
) P ON P.ACDNT_SN = A.ACDNT_SN
|
||||||
|
LEFT JOIN SPIL_DATA S ON S.SPIL_DATA_SN = P.SPIL_DATA_SN
|
||||||
|
LEFT JOIN AUTH_USER U ON U.USER_ID = P.EXEC_USER_ID
|
||||||
|
LEFT JOIN AUTH_ORG O ON O.ORG_SN = U.ORG_SN
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
ACDNT_SN,
|
ACDNT_SN,
|
||||||
@ -165,7 +194,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
GROUP BY ACDNT_SN
|
GROUP BY ACDNT_SN
|
||||||
) B ON B.ACDNT_SN = A.ACDNT_SN
|
) B ON B.ACDNT_SN = A.ACDNT_SN
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY A.OCCRN_DTM DESC
|
ORDER BY P.RUN_DTM DESC NULLS LAST, A.OCCRN_DTM DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const { rows } = await wingPool.query(sql, params);
|
const { rows } = await wingPool.query(sql, params);
|
||||||
@ -186,9 +215,11 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
||||||
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
||||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||||
analyst: String(row['analyst_nm'] ?? ''),
|
analyst: String(row['resolved_analyst'] ?? ''),
|
||||||
officeName: String(row['office_nm'] ?? ''),
|
officeName: String(row['resolved_office'] ?? ''),
|
||||||
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
||||||
|
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
|
||||||
|
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,7 +384,7 @@ function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
|||||||
return {
|
return {
|
||||||
backtrackSn: Number(r['backtrack_sn']),
|
backtrackSn: Number(r['backtrack_sn']),
|
||||||
acdntSn: Number(r['acdnt_sn']),
|
acdntSn: Number(r['acdnt_sn']),
|
||||||
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
|
estSpilDtm: r['est_spil_dtm'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : null,
|
||||||
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
||||||
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
||||||
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
|
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
|
||||||
@ -367,15 +398,15 @@ function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
|||||||
|
|
||||||
export async function createBacktrack(
|
export async function createBacktrack(
|
||||||
input: CreateBacktrackInput,
|
input: CreateBacktrackInput,
|
||||||
): Promise<{ backtrackSn: number }> {
|
): Promise<BacktrackResult> {
|
||||||
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3,
|
$1, $2::double precision, $3::double precision,
|
||||||
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
|
ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326),
|
||||||
$3 || ' + ' || $2,
|
$3::text || ' + ' || $2::text,
|
||||||
$4, $5, $6, 'PENDING'
|
$4, $5, $6, 'PENDING'
|
||||||
)
|
)
|
||||||
RETURNING BACKTRACK_SN
|
RETURNING BACKTRACK_SN
|
||||||
@ -385,8 +416,14 @@ export async function createBacktrack(
|
|||||||
acdntSn, lat, lon,
|
acdntSn, lat, lon,
|
||||||
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
||||||
]);
|
]);
|
||||||
|
const backtrackSn = Number((rows[0] as Record<string, unknown>)['backtrack_sn']);
|
||||||
|
|
||||||
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
|
// 동기 분석 (완료까지 대기 후 결과 반환)
|
||||||
|
await runBacktrackAnalysis(backtrackSn);
|
||||||
|
|
||||||
|
const result = await getBacktrack(backtrackSn);
|
||||||
|
if (!result) throw new Error('역추적 결과를 찾을 수 없습니다');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
||||||
@ -432,6 +469,8 @@ interface TrajectoryTimeStep {
|
|||||||
particles: TrajectoryParticle[];
|
particles: TrajectoryParticle[];
|
||||||
remaining_volume_m3: number;
|
remaining_volume_m3: number;
|
||||||
weathered_volume_m3: number;
|
weathered_volume_m3: number;
|
||||||
|
evaporation_volume_m3?: number;
|
||||||
|
dispersion_volume_m3?: number;
|
||||||
pollution_area_km2: number;
|
pollution_area_km2: number;
|
||||||
beached_volume_m3: number;
|
beached_volume_m3: number;
|
||||||
pollution_coast_length_m: number;
|
pollution_coast_length_m: number;
|
||||||
@ -442,21 +481,56 @@ interface TrajectoryTimeStep {
|
|||||||
hydr_grid?: TrajectoryHydrGrid;
|
hydr_grid?: TrajectoryHydrGrid;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrajectoryResult {
|
// ALGO_CD → 프론트엔드 모델명 매핑
|
||||||
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>;
|
const ALGO_CD_TO_MODEL: Record<string, string> = {
|
||||||
|
'OPENDRIFT': 'OpenDrift',
|
||||||
|
'POSEIDON': 'POSEIDON',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SingleModelTrajectoryResult {
|
||||||
|
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
|
||||||
summary: {
|
summary: {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
};
|
};
|
||||||
centerPoints: Array<{ lat: number; lon: number; time: number }>;
|
stepSummaries: Array<{
|
||||||
|
remainingVolume: number;
|
||||||
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
|
pollutionArea: number;
|
||||||
|
beachedVolume: number;
|
||||||
|
pollutionCoastLength: number;
|
||||||
|
}>;
|
||||||
|
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||||
windData: TrajectoryWindPoint[][];
|
windData: TrajectoryWindPoint[][];
|
||||||
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult {
|
interface TrajectoryResult {
|
||||||
|
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
|
||||||
|
summary: {
|
||||||
|
remainingVolume: number;
|
||||||
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
|
pollutionArea: number;
|
||||||
|
beachedVolume: number;
|
||||||
|
pollutionCoastLength: number;
|
||||||
|
};
|
||||||
|
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||||
|
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
|
||||||
|
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
|
||||||
|
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
|
||||||
|
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
|
||||||
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
||||||
step.particles.map((p, i) => ({
|
step.particles.map((p, i) => ({
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
@ -464,12 +538,15 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
|
|||||||
time: stepIdx,
|
time: stepIdx,
|
||||||
particle: i,
|
particle: i,
|
||||||
stranded: p.stranded,
|
stranded: p.stranded,
|
||||||
|
model,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const lastStep = rawResult[rawResult.length - 1];
|
const lastStep = rawResult[rawResult.length - 1];
|
||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -477,28 +554,249 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
|
|||||||
const centerPoints = rawResult
|
const centerPoints = rawResult
|
||||||
.map((step, stepIdx) =>
|
.map((step, stepIdx) =>
|
||||||
step.center_lat != null && step.center_lon != null
|
step.center_lat != null && step.center_lon != null
|
||||||
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
|
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
.filter((p): p is { lat: number; lon: number; time: number } => p !== null);
|
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
|
||||||
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
|
remainingVolume: step.remaining_volume_m3,
|
||||||
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
|
pollutionArea: step.pollution_area_km2,
|
||||||
|
beachedVolume: step.beached_volume_m3,
|
||||||
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
|
}));
|
||||||
const windData = rawResult.map((step) => step.wind_data ?? []);
|
const windData = rawResult.map((step) => step.wind_data ?? []);
|
||||||
const hydrData = rawResult.map((step) =>
|
const hydrData = rawResult.map((step) =>
|
||||||
step.hydr_data && step.hydr_grid
|
step.hydr_data && step.hydr_grid
|
||||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
return { trajectory, summary, centerPoints, windData, hydrData };
|
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise<TrajectoryResult | null> {
|
||||||
|
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
|
||||||
|
// predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과
|
||||||
|
const sql = predRunSn != null
|
||||||
|
? `
|
||||||
|
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND PRED_RUN_SN = $2
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
ORDER BY CMPL_DTM DESC
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
ORDER BY CMPL_DTM DESC
|
||||||
|
`;
|
||||||
|
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
|
||||||
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
// 모든 모델의 파티클을 하나의 배열로 병합
|
||||||
|
let mergedTrajectory: TrajectoryResult['trajectory'] = [];
|
||||||
|
let allCenterPoints: TrajectoryResult['centerPoints'] = [];
|
||||||
|
|
||||||
|
// summary: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준
|
||||||
|
let baseResult: SingleModelTrajectoryResult | null = null;
|
||||||
|
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
|
||||||
|
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
|
||||||
|
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
|
||||||
|
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
|
||||||
|
|
||||||
|
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
|
||||||
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||||
|
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
||||||
|
const baseRow = opendriftRow ?? poseidonRow ?? null;
|
||||||
|
|
||||||
|
for (const row of rows as Array<Record<string, unknown>>) {
|
||||||
|
if (!row['rslt_data']) continue;
|
||||||
|
const algoCd = String(row['algo_cd'] ?? '');
|
||||||
|
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
|
||||||
|
const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName);
|
||||||
|
mergedTrajectory = mergedTrajectory.concat(parsed.trajectory);
|
||||||
|
allCenterPoints = allCenterPoints.concat(parsed.centerPoints);
|
||||||
|
windDataByModel[modelName] = parsed.windData;
|
||||||
|
hydrDataByModel[modelName] = parsed.hydrData;
|
||||||
|
summaryByModel[modelName] = parsed.summary;
|
||||||
|
stepSummariesByModel[modelName] = parsed.stepSummaries;
|
||||||
|
|
||||||
|
if (row === baseRow) {
|
||||||
|
baseResult = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseResult) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trajectory: mergedTrajectory,
|
||||||
|
summary: baseResult.summary,
|
||||||
|
centerPoints: allCenterPoints,
|
||||||
|
windDataByModel,
|
||||||
|
hydrDataByModel,
|
||||||
|
summaryByModel,
|
||||||
|
stepSummariesByModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT RSLT_DATA FROM wing.PRED_EXEC
|
WITH all_wkts AS (
|
||||||
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED'
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
ORDER BY CMPL_DTM DESC LIMIT 1
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.CATEGORY,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
CASE
|
||||||
|
WHEN bool_and(sr.PROPERTIES ? 'area')
|
||||||
|
THEN SUM((sr.PROPERTIES->>'area')::float)
|
||||||
|
ELSE NULL
|
||||||
|
END AS total_area
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
GROUP BY sr.CATEGORY
|
||||||
|
ORDER BY sr.CATEGORY
|
||||||
`;
|
`;
|
||||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
if (rows.length === 0 || !rows[0].rslt_data) return null;
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
|
category: String(r['category'] ?? ''),
|
||||||
|
count: Number(r['count'] ?? 0),
|
||||||
|
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesGeoJsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const sql = `
|
||||||
|
WITH all_wkts AS (
|
||||||
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES,
|
||||||
|
ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
ORDER BY sr.CATEGORY, sr.SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
category: String(r['category'] ?? ''),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitivityEvaluationGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
|
||||||
|
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
||||||
|
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const lat = Number(acdntRows[0]['lat']);
|
||||||
|
const lng = Number(acdntRows[0]['lng']);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT SR_ID, PROPERTIES,
|
||||||
|
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
|
||||||
|
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
|
||||||
|
FROM wing.SENSITIVE_EVALUATION
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
GEOM::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
|
||||||
|
10000
|
||||||
|
)
|
||||||
|
ORDER BY SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [lat, lng]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
area_km2: Number(r['area_km2']),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPredictionParticlesGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
|
||||||
|
const sql = `
|
||||||
|
SELECT ALGO_CD, RSLT_DATA
|
||||||
|
FROM wing.PRED_EXEC
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
|
||||||
|
|
||||||
|
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
|
||||||
|
const features: unknown[] = [];
|
||||||
|
let globalMaxStep = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
|
||||||
|
const steps = row['rslt_data'] as TrajectoryTimeStep[];
|
||||||
|
const maxStep = steps.length - 1;
|
||||||
|
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
|
||||||
|
|
||||||
|
steps.forEach((step, stepIdx) => {
|
||||||
|
step.particles.forEach(p => {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||||
|
properties: {
|
||||||
|
model,
|
||||||
|
time: stepIdx,
|
||||||
|
stranded: p.stranded ?? 0,
|
||||||
|
isLastStep: stepIdx === maxStep,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||||
@ -524,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
|||||||
regDtm: String(r['reg_dtm'] ?? ''),
|
regDtm: String(r['reg_dtm'] ?? ''),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 유출유 확산 요약 (통합조회 분할 패널용) ──────────────
|
||||||
|
export interface OilSpillSummary {
|
||||||
|
model: string;
|
||||||
|
forecastDurationHr: number | null;
|
||||||
|
maxSpreadDistanceKm: number | null;
|
||||||
|
coastArrivalTimeHr: number | null;
|
||||||
|
affectedCoastlineKm: number | null;
|
||||||
|
weatheringRatePct: number | null;
|
||||||
|
remainingVolumeKl: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OilSpillSummaryResponse {
|
||||||
|
primary: OilSpillSummary;
|
||||||
|
byModel: Record<string, OilSpillSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
|
||||||
|
const baseSql = `
|
||||||
|
SELECT pe.ALGO_CD, pe.RSLT_DATA,
|
||||||
|
sd.FCST_HR,
|
||||||
|
ST_Y(a.LOC_GEOM) AS spil_lat,
|
||||||
|
ST_X(a.LOC_GEOM) AS spil_lon
|
||||||
|
FROM wing.PRED_EXEC pe
|
||||||
|
LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN
|
||||||
|
LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN
|
||||||
|
WHERE pe.ACDNT_SN = $1
|
||||||
|
AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND pe.RSLT_DATA IS NOT NULL
|
||||||
|
`;
|
||||||
|
const sql = predRunSn != null
|
||||||
|
? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC'
|
||||||
|
: baseSql + ' ORDER BY pe.CMPL_DTM DESC';
|
||||||
|
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
|
||||||
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const byModel: Record<string, OilSpillSummary> = {};
|
||||||
|
|
||||||
|
// OpenDrift 우선, 없으면 POSEIDON
|
||||||
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||||
|
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
||||||
|
const primaryRow = opendriftRow ?? poseidonRow ?? null;
|
||||||
|
|
||||||
|
for (const row of rows as Array<Record<string, unknown>>) {
|
||||||
|
const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null;
|
||||||
|
if (!rsltData || rsltData.length === 0) continue;
|
||||||
|
|
||||||
|
const algoCd = String(row['algo_cd'] ?? '');
|
||||||
|
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
|
||||||
|
const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null;
|
||||||
|
const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null;
|
||||||
|
const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null;
|
||||||
|
const totalSteps = rsltData.length;
|
||||||
|
const lastStep = rsltData[totalSteps - 1];
|
||||||
|
|
||||||
|
// 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용
|
||||||
|
let maxDist: number | null = null;
|
||||||
|
const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null;
|
||||||
|
const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null;
|
||||||
|
if (originLat != null && originLon != null) {
|
||||||
|
let maxVal = 0;
|
||||||
|
for (const step of rsltData) {
|
||||||
|
for (const p of step.particles) {
|
||||||
|
const d = haversineKm(originLat, originLon, p.lat, p.lon);
|
||||||
|
if (d > maxVal) maxVal = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxDist = maxVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해안 도달 시간 (stranded===1 최초 등장 step)
|
||||||
|
let coastArrivalHr: number | null = null;
|
||||||
|
for (let i = 0; i < totalSteps; i++) {
|
||||||
|
if (rsltData[i].particles.some((p) => p.stranded === 1)) {
|
||||||
|
coastArrivalHr = fcstHr != null && totalSteps > 1
|
||||||
|
? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1))
|
||||||
|
: i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 풍화율
|
||||||
|
const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3;
|
||||||
|
const weatheringPct = totalVol > 0
|
||||||
|
? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
byModel[modelName] = {
|
||||||
|
model: modelName,
|
||||||
|
forecastDurationHr: fcstHr,
|
||||||
|
maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null,
|
||||||
|
coastArrivalTimeHr: coastArrivalHr,
|
||||||
|
affectedCoastlineKm: lastStep.pollution_coast_length_m != null
|
||||||
|
? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1))
|
||||||
|
: null,
|
||||||
|
weatheringRatePct: weatheringPct,
|
||||||
|
remainingVolumeKl: lastStep.remaining_volume_m3 != null
|
||||||
|
? parseFloat(lastStep.remaining_volume_m3.toFixed(1))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryRow) return null;
|
||||||
|
const primaryAlgo = String(primaryRow['algo_cd'] ?? '');
|
||||||
|
const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: byModel[primaryModel] ?? Object.values(byModel)[0],
|
||||||
|
byModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
|
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
const result = await createReport({
|
const result = await createReport({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
@ -101,6 +101,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
|||||||
jrsdCd,
|
jrsdCd,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
authorId: req.user!.sub,
|
authorId: req.user!.sub,
|
||||||
|
step3MapImg,
|
||||||
|
step6MapImg,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
@ -124,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
|||||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
|
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
|
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
|
|||||||
@ -60,8 +60,10 @@ interface ReportListItem {
|
|||||||
sttsCd: string;
|
sttsCd: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
acdntSn: number | null;
|
||||||
regDtm: string;
|
regDtm: string;
|
||||||
mdfcnDtm: string | null;
|
mdfcnDtm: string | null;
|
||||||
|
hasMapCapture: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
@ -74,6 +76,8 @@ interface SectionData {
|
|||||||
interface ReportDetail extends ReportListItem {
|
interface ReportDetail extends ReportListItem {
|
||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: SectionData[];
|
sections: SectionData[];
|
||||||
|
step3MapImg: string | null;
|
||||||
|
step6MapImg: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListReportsInput {
|
interface ListReportsInput {
|
||||||
@ -100,6 +104,8 @@ interface CreateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
|
step3MapImg?: string;
|
||||||
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +114,8 @@ interface UpdateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,7 +264,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM
|
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
|
||||||
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||||
@ -279,8 +290,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
sttsCd: r.stts_cd,
|
sttsCd: r.stts_cd,
|
||||||
authorId: r.author_id,
|
authorId: r.author_id,
|
||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
|
acdntSn: r.acdnt_sn,
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
|
hasMapCapture: r.has_map_capture,
|
||||||
})),
|
})),
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
@ -294,7 +307,10 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM
|
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
|
||||||
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||||
@ -331,6 +347,9 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
|
step3MapImg: r.step3_map_img,
|
||||||
|
step6MapImg: r.step6_map_img,
|
||||||
|
hasMapCapture: r.has_map_capture,
|
||||||
sections: sectRes.rows.map((s) => ({
|
sections: sectRes.rows.map((s) => ({
|
||||||
sectCd: s.sect_cd,
|
sectCd: s.sect_cd,
|
||||||
includeYn: s.include_yn,
|
includeYn: s.include_yn,
|
||||||
@ -350,8 +369,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
const res = await client.query(
|
const res = await client.query(
|
||||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
|
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING REPORT_SN`,
|
RETURNING REPORT_SN`,
|
||||||
[
|
[
|
||||||
input.tmplSn || null,
|
input.tmplSn || null,
|
||||||
@ -361,6 +380,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
input.jrsdCd || null,
|
input.jrsdCd || null,
|
||||||
input.sttsCd || 'DRAFT',
|
input.sttsCd || 'DRAFT',
|
||||||
input.authorId,
|
input.authorId,
|
||||||
|
input.step3MapImg || null,
|
||||||
|
input.step6MapImg || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const reportSn = res.rows[0].report_sn;
|
const reportSn = res.rows[0].report_sn;
|
||||||
@ -432,6 +453,14 @@ export async function updateReport(
|
|||||||
sets.push(`ACDNT_SN = $${idx++}`);
|
sets.push(`ACDNT_SN = $${idx++}`);
|
||||||
params.push(input.acdntSn);
|
params.push(input.acdntSn);
|
||||||
}
|
}
|
||||||
|
if (input.step3MapImg !== undefined) {
|
||||||
|
sets.push(`STEP3_MAP_IMG = $${idx++}`);
|
||||||
|
params.push(input.step3MapImg);
|
||||||
|
}
|
||||||
|
if (input.step6MapImg !== undefined) {
|
||||||
|
sets.push(`STEP6_MAP_IMG = $${idx++}`);
|
||||||
|
params.push(input.step6MapImg);
|
||||||
|
}
|
||||||
|
|
||||||
params.push(reportSn);
|
params.push(reportSn);
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@ -10,11 +10,13 @@ const router = express.Router();
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
|
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sttsCd, acdntTpCd, search } = req.query;
|
const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
|
||||||
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
|
||||||
const items = await listOps({
|
const items = await listOps({
|
||||||
sttsCd: sttsCd as string | undefined,
|
sttsCd: sttsCd as string | undefined,
|
||||||
acdntTpCd: acdntTpCd as string | undefined,
|
acdntTpCd: acdntTpCd as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
});
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -59,6 +59,7 @@ interface ListOpsInput {
|
|||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntTpCd?: string;
|
acdntTpCd?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]
|
|||||||
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
params.push(input.search);
|
params.push(input.search);
|
||||||
}
|
}
|
||||||
|
if (input?.acdntSn != null) {
|
||||||
|
conditions.push(`ACDNT_SN = $${idx++}`);
|
||||||
|
params.push(input.acdntSn);
|
||||||
|
}
|
||||||
|
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
isValidNumber,
|
isValidNumber,
|
||||||
isValidStringLength,
|
isValidStringLength,
|
||||||
} from '../middleware/security.js'
|
} from '../middleware/security.js'
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ interface Layer {
|
|||||||
cmn_cd_nm: string
|
cmn_cd_nm: string
|
||||||
cmn_cd_level: number
|
cmn_cd_level: number
|
||||||
clnm: string | null
|
clnm: string | null
|
||||||
|
data_tbl_nm: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
||||||
@ -26,7 +28,21 @@ const LAYER_COLUMNS = `
|
|||||||
LAYER_FULL_NM AS cmn_cd_full_nm,
|
LAYER_FULL_NM AS cmn_cd_full_nm,
|
||||||
LAYER_NM AS cmn_cd_nm,
|
LAYER_NM AS cmn_cd_nm,
|
||||||
LAYER_LEVEL AS cmn_cd_level,
|
LAYER_LEVEL AS cmn_cd_level,
|
||||||
WMS_LAYER_NM AS clnm
|
WMS_LAYER_NM AS clnm,
|
||||||
|
DATA_TBL_NM AS data_tbl_nm
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
// 조상 중 하나라도 USE_YN='N'이면 제외하는 재귀 CTE
|
||||||
|
// 부모가 비활성화되면 자식도 공개 API에서 제외됨 (상속 방식)
|
||||||
|
const ACTIVE_TREE_CTE = `
|
||||||
|
WITH RECURSIVE active_tree AS (
|
||||||
|
SELECT LAYER_CD FROM LAYER
|
||||||
|
WHERE UP_LAYER_CD IS NULL AND USE_YN = 'Y' AND DEL_YN = 'N'
|
||||||
|
UNION ALL
|
||||||
|
SELECT l.LAYER_CD FROM LAYER l
|
||||||
|
JOIN active_tree a ON l.UP_LAYER_CD = a.LAYER_CD
|
||||||
|
WHERE l.USE_YN = 'Y' AND l.DEL_YN = 'N'
|
||||||
|
)
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
// 모든 라우트에 파라미터 살균 적용
|
// 모든 라우트에 파라미터 살균 적용
|
||||||
@ -36,7 +52,10 @@ router.use(sanitizeParams)
|
|||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -49,7 +68,10 @@ router.get('/', async (_req, res) => {
|
|||||||
router.get('/tree/all', async (_req, res) => {
|
router.get('/tree/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
|
|
||||||
@ -81,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
|
|||||||
router.get('/wms/all', async (_req, res) => {
|
router.get('/wms/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -103,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1
|
||||||
|
ORDER BY LAYER_CD`,
|
||||||
[level]
|
[level]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -127,7 +155,7 @@ router.get('/children/:parentId', async (req, res) => {
|
|||||||
|
|
||||||
const sanitizedId = sanitizeString(parentId)
|
const sanitizedId = sanitizeString(parentId)
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
|
||||||
[sanitizedId]
|
[sanitizedId]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -151,7 +179,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const sanitizedId = sanitizeString(id)
|
const sanitizedId = sanitizeString(id)
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
|
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||||
[sanitizedId]
|
[sanitizedId]
|
||||||
)
|
)
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@ -164,4 +192,352 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 관리자 전용 엔드포인트 ──────────────────────────────────────
|
||||||
|
|
||||||
|
// 전체 레이어 목록 (페이지네이션 + 검색/필터, USE_YN 무관)
|
||||||
|
router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1)
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '10'), 10) || 10))
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
const search = sanitizeString(String(req.query.search ?? '')).trim()
|
||||||
|
const useYnFilter = String(req.query.useYn ?? '')
|
||||||
|
|
||||||
|
// 동적 WHERE 절 구성 (DEL_YN = 'N' 기본 조건)
|
||||||
|
const conditions: string[] = ["DEL_YN = 'N'"]
|
||||||
|
const params: (string | number)[] = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`)
|
||||||
|
const n = params.length
|
||||||
|
conditions.push(`(LAYER_CD ILIKE $${n} OR LAYER_NM ILIKE $${n} OR LAYER_FULL_NM ILIKE $${n})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useYnFilter === 'Y' || useYnFilter === 'N') {
|
||||||
|
params.push(useYnFilter)
|
||||||
|
conditions.push(`USE_YN = $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim()
|
||||||
|
if (rootCd) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' })
|
||||||
|
}
|
||||||
|
params.push(`${rootCd}%`)
|
||||||
|
conditions.push(`LAYER_CD LIKE $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
|
|
||||||
|
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
||||||
|
const dataParams = [...params, limit, offset]
|
||||||
|
const limitIdx = dataParams.length - 1
|
||||||
|
const offsetIdx = dataParams.length
|
||||||
|
|
||||||
|
const [dataResult, countResult] = await Promise.all([
|
||||||
|
wingPool.query(
|
||||||
|
`SELECT
|
||||||
|
t.*,
|
||||||
|
p.USE_YN AS "parentUseYn"
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
LAYER_CD AS "layerCd",
|
||||||
|
UP_LAYER_CD AS "upLayerCd",
|
||||||
|
LAYER_FULL_NM AS "layerFullNm",
|
||||||
|
LAYER_NM AS "layerNm",
|
||||||
|
LAYER_LEVEL AS "layerLevel",
|
||||||
|
WMS_LAYER_NM AS "wmsLayerNm",
|
||||||
|
DATA_TBL_NM AS "dataTblNm",
|
||||||
|
USE_YN AS "useYn",
|
||||||
|
SORT_ORD AS "sortOrd",
|
||||||
|
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||||
|
FROM LAYER
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY LAYER_CD
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||||
|
) t
|
||||||
|
LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N'
|
||||||
|
ORDER BY t."layerCd"`,
|
||||||
|
dataParams
|
||||||
|
),
|
||||||
|
wingPool.query(
|
||||||
|
`SELECT COUNT(*)::int AS total FROM LAYER ${whereClause}`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total: number = countResult.rows[0].total
|
||||||
|
res.json({
|
||||||
|
items: dataResult.rows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: '레이어 목록 조회 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 드롭다운용 레이어 옵션 목록
|
||||||
|
router.get('/admin/options', requireAuth, requireRole('ADMIN'), async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`SELECT LAYER_CD AS "layerCd", LAYER_NM AS "layerNm",
|
||||||
|
LAYER_FULL_NM AS "layerFullNm", LAYER_LEVEL AS "layerLevel"
|
||||||
|
FROM LAYER WHERE DEL_YN = 'N' ORDER BY LAYER_CD`
|
||||||
|
)
|
||||||
|
res.json(rows)
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: '레이어 옵션 조회 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 상위코드 기반 다음 자식 코드 계산
|
||||||
|
router.get('/admin/next-code', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
const upLayerCd = sanitizeString(String(req.query.upLayerCd ?? '')).trim()
|
||||||
|
if (!upLayerCd || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd) || !isValidStringLength(upLayerCd, 50)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`SELECT LAYER_CD AS "layerCd" FROM LAYER
|
||||||
|
WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'
|
||||||
|
ORDER BY LAYER_CD DESC LIMIT 1`,
|
||||||
|
[upLayerCd]
|
||||||
|
)
|
||||||
|
let nextCode: string
|
||||||
|
if (rows.length === 0) {
|
||||||
|
nextCode = upLayerCd + '001'
|
||||||
|
} else {
|
||||||
|
const lastCd = rows[0].layerCd as string
|
||||||
|
const suffix = lastCd.substring(upLayerCd.length)
|
||||||
|
const num = parseInt(suffix, 10)
|
||||||
|
nextCode = isNaN(num)
|
||||||
|
? upLayerCd + '001'
|
||||||
|
: upLayerCd + String(num + 1).padStart(suffix.length, '0')
|
||||||
|
}
|
||||||
|
res.json({ nextCode })
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: '다음 레이어코드 계산 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 레이어 생성
|
||||||
|
router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as {
|
||||||
|
layerCd?: string
|
||||||
|
upLayerCd?: string
|
||||||
|
layerFullNm?: string
|
||||||
|
layerNm?: string
|
||||||
|
layerLevel?: number
|
||||||
|
wmsLayerNm?: string
|
||||||
|
dataTblNm?: string
|
||||||
|
useYn?: string
|
||||||
|
sortOrd?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
|
||||||
|
}
|
||||||
|
if (!layerNm || !isValidStringLength(layerNm, 100)) {
|
||||||
|
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
|
||||||
|
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
|
||||||
|
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 필드 검증
|
||||||
|
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
|
||||||
|
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
|
||||||
|
if (!isValidStringLength(wmsLayerNm, 100)) {
|
||||||
|
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||||
|
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||||
|
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||||
|
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||||
|
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||||
|
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||||
|
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||||
|
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||||
|
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||||
|
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
|
||||||
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
|
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(rows[0])
|
||||||
|
} catch (err) {
|
||||||
|
const pgErr = err as { code?: string }
|
||||||
|
if (pgErr.code === '23505') {
|
||||||
|
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: '레이어 생성 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 레이어 수정
|
||||||
|
router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as {
|
||||||
|
layerCd?: string
|
||||||
|
upLayerCd?: string
|
||||||
|
layerFullNm?: string
|
||||||
|
layerNm?: string
|
||||||
|
layerLevel?: number
|
||||||
|
wmsLayerNm?: string
|
||||||
|
dataTblNm?: string
|
||||||
|
useYn?: string
|
||||||
|
sortOrd?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
|
||||||
|
}
|
||||||
|
if (!layerNm || !isValidStringLength(layerNm, 100)) {
|
||||||
|
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
|
||||||
|
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
|
||||||
|
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 필드 검증
|
||||||
|
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
|
||||||
|
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
|
||||||
|
if (!isValidStringLength(wmsLayerNm, 100)) {
|
||||||
|
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||||
|
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||||
|
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||||
|
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||||
|
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||||
|
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||||
|
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||||
|
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||||
|
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||||
|
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`UPDATE LAYER
|
||||||
|
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
||||||
|
WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9
|
||||||
|
WHERE LAYER_CD = $1
|
||||||
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
|
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||||
|
}
|
||||||
|
res.json(rows[0])
|
||||||
|
} catch (err) {
|
||||||
|
const pgErr = err as { code?: string }
|
||||||
|
if (pgErr.code === '23505') {
|
||||||
|
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: '레이어 수정 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 레이어 삭제
|
||||||
|
router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { layerCd } = req.body as { layerCd?: string }
|
||||||
|
|
||||||
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedCd = sanitizeString(layerCd)
|
||||||
|
|
||||||
|
// 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단)
|
||||||
|
const { rows: childRows } = await wingPool.query(
|
||||||
|
`SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||||
|
[sanitizedCd]
|
||||||
|
)
|
||||||
|
const childCount: number = childRows[0].cnt
|
||||||
|
if (childCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
||||||
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
|
[sanitizedCd]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||||
|
}
|
||||||
|
res.json(rows[0])
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: '레이어 삭제 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// USE_YN 토글
|
||||||
|
router.post('/admin/toggle-use', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { layerCd } = req.body as { layerCd?: string }
|
||||||
|
|
||||||
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 레이어코드' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedCd = sanitizeString(layerCd)
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`UPDATE LAYER
|
||||||
|
SET USE_YN = CASE WHEN USE_YN = 'Y' THEN 'N' ELSE 'Y' END
|
||||||
|
WHERE LAYER_CD = $1
|
||||||
|
RETURNING LAYER_CD AS "layerCd", USE_YN AS "useYn"`,
|
||||||
|
[sanitizedCd]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||||
|
}
|
||||||
|
res.json(rows[0])
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'USE_YN 변경 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
|
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
|
||||||
|
const POSEIDON_API_URL = process.env.POSEIDON_API_URL ?? 'http://localhost:5004'
|
||||||
const POLL_INTERVAL_MS = 3000
|
const POLL_INTERVAL_MS = 3000
|
||||||
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
||||||
|
|
||||||
@ -19,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
|||||||
const OIL_TYPE_MAP: Record<string, string> = {
|
const OIL_TYPE_MAP: Record<string, string> = {
|
||||||
'벙커C유': 'GENERIC BUNKER C',
|
'벙커C유': 'GENERIC BUNKER C',
|
||||||
'경유': 'GENERIC DIESEL',
|
'경유': 'GENERIC DIESEL',
|
||||||
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
|
'원유': 'WEST TEXAS INTERMEDIATE',
|
||||||
'중유': 'GENERIC HEAVY FUEL OIL',
|
'중유': 'GENERIC HEAVY FUEL OIL',
|
||||||
'등유': 'FUEL OIL NO.1 (KEROSENE)',
|
'등유': 'FUEL OIL NO.1 (KEROSENE) ',
|
||||||
'휘발유': 'GENERIC GASOLINE',
|
'휘발유': 'GENERIC GASOLINE',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,20 +72,38 @@ async function rollbackNewRecords(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모델명 → ALGO_CD 매핑
|
||||||
|
const MODEL_ALGO_CD_MAP: Record<string, string> = {
|
||||||
|
'OpenDrift': 'OPENDRIFT',
|
||||||
|
'POSEIDON': 'POSEIDON',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모델명 → API URL 매핑
|
||||||
|
const MODEL_API_URL_MAP: Record<string, string> = {
|
||||||
|
'OpenDrift': PYTHON_API_URL,
|
||||||
|
'POSEIDON': POSEIDON_API_URL,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// POST /api/simulation/run
|
// POST /api/simulation/run
|
||||||
// 확산 시뮬레이션 실행 (OpenDrift)
|
// 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
/**
|
/**
|
||||||
* OpenDrift 확산 시뮬레이션을 실행한다.
|
* 선택된 모델(OpenDrift, POSEIDON)로 확산 시뮬레이션을 실행한다.
|
||||||
* Python FastAPI 서버에 작업을 제출하고 job_id를 받아
|
* 각 모델에 대해 PRED_EXEC 레코드를 별도 생성하고 Python API에 병렬 제출한다.
|
||||||
* 백그라운드에서 폴링하며 결과를 DB에 저장한다.
|
* KOSPS 모델은 PRED_EXEC INSERT(PENDING)만 수행하고 외부 API 연동은 하지 않는다.
|
||||||
* 프론트엔드는 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다.
|
* 프론트엔드는 execSns 배열의 각 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다.
|
||||||
*/
|
*/
|
||||||
router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
|
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
|
||||||
lat, lon, runTime, matTy, matVol, spillTime, startTime } = req.body
|
lat, lon, runTime, matTy, matVol, spillTime, startTime,
|
||||||
|
models: rawModels } = req.body
|
||||||
|
|
||||||
|
// 실행할 모델 목록 (기본값: OpenDrift)
|
||||||
|
const requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0
|
||||||
|
? (rawModels as string[])
|
||||||
|
: ['OpenDrift']
|
||||||
|
|
||||||
// 1. 필수 파라미터 검증
|
// 1. 필수 파라미터 검증
|
||||||
if (lat === undefined || lon === undefined || runTime === undefined) {
|
if (lat === undefined || lon === undefined || runTime === undefined) {
|
||||||
@ -117,6 +136,8 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
||||||
|
// OpenDrift 모델이 포함된 경우에만 check-nc 수행
|
||||||
|
if (requestedModels.includes('OpenDrift')) {
|
||||||
try {
|
try {
|
||||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -133,6 +154,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Python 서버 미기동 — 5번에서 처리
|
// Python 서버 미기동 — 5번에서 처리
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
||||||
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||||
@ -188,41 +210,80 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
||||||
try {
|
try {
|
||||||
const spilRes = await wingPool.query(
|
const spilRes = await wingPool.query(
|
||||||
`SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`,
|
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||||
[resolvedAcdntSn]
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
RETURNING SPIL_DATA_SN`,
|
||||||
|
[
|
||||||
|
resolvedAcdntSn,
|
||||||
|
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
|
||||||
|
matVol ?? 0,
|
||||||
|
UNIT_MAP[spillUnit as string] ?? 'KL',
|
||||||
|
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
|
||||||
|
runTime,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
if (spilRes.rows.length > 0) {
|
|
||||||
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||||
}
|
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.error('[simulation] SPIL_DATA 조회 실패:', dbErr)
|
console.error('[simulation] SPIL_DATA INSERT 실패:', dbErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. PRED_EXEC INSERT (PENDING) — ACDNT_SN 포함 (NOT NULL FK)
|
|
||||||
const execNm = `EXPC_${Date.now()}`
|
|
||||||
let predExecSn: number
|
|
||||||
try {
|
|
||||||
const insertRes = await wingPool.query(
|
|
||||||
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
|
|
||||||
VALUES ($1, $2, 'OPENDRIFT', 'PENDING', $3, NOW())
|
|
||||||
RETURNING PRED_EXEC_SN`,
|
|
||||||
[resolvedAcdntSn, resolvedSpilDataSn, execNm]
|
|
||||||
)
|
|
||||||
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('[simulation] PRED_EXEC INSERT 실패:', dbErr)
|
|
||||||
return res.status(500).json({ error: '분석 기록 생성 실패' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// matTy 변환: 한국어 유종 → OpenDrift 유종 코드
|
// matTy 변환: 한국어 유종 → OpenDrift 유종 코드
|
||||||
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
|
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
|
||||||
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
|
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
|
||||||
|
|
||||||
// 5. Python /run-model 호출
|
// 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬)
|
||||||
|
// KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동)
|
||||||
|
const execNmBase = `EXPC_${Date.now()}`
|
||||||
|
const execSns: Array<{ model: string; execSn: number }> = []
|
||||||
|
|
||||||
|
// KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행
|
||||||
|
if (requestedModels.includes('KOSPS')) {
|
||||||
|
try {
|
||||||
|
const kospsExecNm = `${execNmBase}_KOSPS`
|
||||||
|
const insertRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
|
||||||
|
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW())
|
||||||
|
RETURNING PRED_EXEC_SN`,
|
||||||
|
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, req.user!.sub]
|
||||||
|
)
|
||||||
|
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 연동 모델 필터링 (KOSPS 제외)
|
||||||
|
const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
|
||||||
|
|
||||||
|
// 각 모델에 대해 PRED_EXEC INSERT → /run-model 호출
|
||||||
|
await Promise.all(
|
||||||
|
apiModels.map(async (model) => {
|
||||||
|
const algoCd = MODEL_ALGO_CD_MAP[model]
|
||||||
|
const apiUrl = MODEL_API_URL_MAP[model]
|
||||||
|
const execNm = `${execNmBase}_${algoCd}`
|
||||||
|
|
||||||
|
// PRED_EXEC INSERT (PENDING)
|
||||||
|
let predExecSn: number
|
||||||
|
try {
|
||||||
|
const insertRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
|
||||||
|
VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW())
|
||||||
|
RETURNING PRED_EXEC_SN`,
|
||||||
|
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, req.user!.sub]
|
||||||
|
)
|
||||||
|
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error(`[simulation] ${model} PRED_EXEC INSERT 실패:`, dbErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
execSns.push({ model, execSn: predExecSn })
|
||||||
|
|
||||||
|
// Python /run-model 호출
|
||||||
let jobId: string
|
let jobId: string
|
||||||
try {
|
try {
|
||||||
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, {
|
const pythonRes = await fetch(`${apiUrl}/run-model`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -245,7 +306,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
[errData.error || '분석 서버 포화', predExecSn]
|
[errData.error || '분석 서버 포화', predExecSn]
|
||||||
)
|
)
|
||||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pythonRes.ok) {
|
if (!pythonRes.ok) {
|
||||||
@ -259,23 +320,39 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||||
[predExecSn]
|
[predExecSn]
|
||||||
)
|
)
|
||||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
// 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행)
|
||||||
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
await rollbackNewRecords(predExecSn, null, null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. RUNNING 업데이트
|
// RUNNING 업데이트
|
||||||
await wingPool.query(
|
await wingPool.query(
|
||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
||||||
[predExecSn]
|
[predExecSn]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용)
|
// 백그라운드 폴링 시작
|
||||||
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
|
pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) =>
|
||||||
|
console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err)
|
||||||
// 8. 백그라운드 폴링 시작
|
|
||||||
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
|
|
||||||
console.error('[simulation] pollAndSave 오류:', err)
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백
|
||||||
|
const hasRunning = execSns.some(({ model }) => model !== 'KOSPS')
|
||||||
|
if (!hasRunning && newlyCreatedAcdntSn !== null) {
|
||||||
|
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
|
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 즉시 응답 (하위 호환을 위해 execSn도 포함)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
execSns,
|
||||||
|
execSn: execSns[0]?.execSn ?? 0,
|
||||||
|
acdntSn: resolvedAcdntSn,
|
||||||
|
status: 'RUNNING',
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
|
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
|
||||||
}
|
}
|
||||||
@ -297,7 +374,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await wingPool.query(
|
const result = await wingPool.query(
|
||||||
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR,
|
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, pe.ALGO_CD, sd.FCST_HR,
|
||||||
(
|
(
|
||||||
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
|
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
|
||||||
FROM wing.PRED_EXEC hist
|
FROM wing.PRED_EXEC hist
|
||||||
@ -328,7 +405,9 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
|
|||||||
const status = statusMap[dbStatus] ?? dbStatus
|
const status = statusMap[dbStatus] ?? dbStatus
|
||||||
|
|
||||||
if (status === 'DONE' && row.rslt_data) {
|
if (status === 'DONE' && row.rslt_data) {
|
||||||
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[])
|
const algoCd = String(row.algo_cd ?? '')
|
||||||
|
const modelName = ALGO_CD_TO_MODEL_NAME[algoCd] ?? algoCd
|
||||||
|
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[], modelName)
|
||||||
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
|
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,17 +432,331 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/simulation/run-model (동기 방식)
|
||||||
|
// 예측 완료 후 결과를 직접 반환한다.
|
||||||
|
// ============================================================
|
||||||
|
/**
|
||||||
|
* 선택된 모델로 확산 시뮬레이션을 실행하고 완료될 때까지 대기한 후 결과를 반환한다.
|
||||||
|
* 다중 모델은 병렬로 실행되며, 일부 모델 실패 시 성공한 모델 결과는 포함된다.
|
||||||
|
*/
|
||||||
|
router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
|
||||||
|
lat, lon, runTime, matTy, matVol, spillTime, startTime,
|
||||||
|
models: rawModels } = req.body
|
||||||
|
|
||||||
|
let requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0
|
||||||
|
? (rawModels as string[])
|
||||||
|
: ['OpenDrift']
|
||||||
|
|
||||||
|
// 1. 필수 파라미터 검증
|
||||||
|
if (lat === undefined || lon === undefined || runTime === undefined) {
|
||||||
|
return res.status(400).json({ error: '필수 파라미터 누락', required: ['lat', 'lon', 'runTime'] })
|
||||||
|
}
|
||||||
|
if (!isValidLatitude(lat)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (!isValidLongitude(lon)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (!isValidNumber(runTime, 1, 720)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' })
|
||||||
|
}
|
||||||
|
if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 유출량' })
|
||||||
|
}
|
||||||
|
if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 유종' })
|
||||||
|
}
|
||||||
|
if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) {
|
||||||
|
return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' })
|
||||||
|
}
|
||||||
|
if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) {
|
||||||
|
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NC 파일 존재 여부 확인
|
||||||
|
if (requestedModels.includes('OpenDrift')) {
|
||||||
|
try {
|
||||||
|
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lat, lon, startTime }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!checkRes.ok) {
|
||||||
|
// NC 파일 없으면 OpenDrift만 제외, 나머지 모델(POSEIDON 등)은 계속 진행
|
||||||
|
requestedModels = requestedModels.filter(m => m !== 'OpenDrift')
|
||||||
|
if (requestedModels.length === 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||||
|
message: 'NC 파일이 준비되지 않았습니다.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Python 서버 미기동 — 이후 단계에서 처리
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ACDNT/SPIL_DATA 생성 또는 조회
|
||||||
|
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||||
|
let resolvedSpilDataSn: number | null = null
|
||||||
|
let newlyCreatedAcdntSn: number | null = null
|
||||||
|
let newlyCreatedSpilDataSn: number | null = null
|
||||||
|
|
||||||
|
if (!resolvedAcdntSn && acdntNm) {
|
||||||
|
try {
|
||||||
|
const occrn = startTime ?? new Date().toISOString()
|
||||||
|
const acdntRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.ACDNT
|
||||||
|
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||||
|
VALUES (
|
||||||
|
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
|
||||||
|
LPAD(
|
||||||
|
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
|
||||||
|
FROM wing.ACDNT
|
||||||
|
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
|
||||||
|
4, '0'
|
||||||
|
),
|
||||||
|
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
|
||||||
|
)
|
||||||
|
RETURNING ACDNT_SN`,
|
||||||
|
[acdntNm.trim(), occrn, lat, lon]
|
||||||
|
)
|
||||||
|
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
|
||||||
|
newlyCreatedAcdntSn = resolvedAcdntSn
|
||||||
|
|
||||||
|
const spilRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
RETURNING SPIL_DATA_SN`,
|
||||||
|
[
|
||||||
|
resolvedAcdntSn,
|
||||||
|
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
|
||||||
|
matVol ?? 0,
|
||||||
|
UNIT_MAP[spillUnit as string] ?? 'KL',
|
||||||
|
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
|
||||||
|
runTime,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||||
|
newlyCreatedSpilDataSn = resolvedSpilDataSn
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation/run-model] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
|
||||||
|
return res.status(500).json({ error: '사고 정보 생성 실패' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
||||||
|
try {
|
||||||
|
const spilRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
RETURNING SPIL_DATA_SN`,
|
||||||
|
[
|
||||||
|
resolvedAcdntSn,
|
||||||
|
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
|
||||||
|
matVol ?? 0,
|
||||||
|
UNIT_MAP[spillUnit as string] ?? 'KL',
|
||||||
|
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
|
||||||
|
runTime,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation/run-model] SPIL_DATA INSERT 실패:', dbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
|
||||||
|
const execNmBase = `EXPC_${Date.now()}`
|
||||||
|
|
||||||
|
// 이번 예측 실행을 식별하는 그룹 SN 생성
|
||||||
|
let predRunSn: number
|
||||||
|
try {
|
||||||
|
const runSnRes = await wingPool.query("SELECT nextval('wing.PRED_RUN_SN_SEQ') AS pred_run_sn")
|
||||||
|
predRunSn = runSnRes.rows[0].pred_run_sn as number
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation/run-model] PRED_RUN_SN_SEQ 조회 실패:', dbErr)
|
||||||
|
return res.status(500).json({ error: '실행 SN 생성 실패' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// KOSPS: PRED_EXEC INSERT(PENDING)만 수행
|
||||||
|
const execSns: Array<{ model: string; execSn: number }> = []
|
||||||
|
if (requestedModels.includes('KOSPS')) {
|
||||||
|
try {
|
||||||
|
const kospsExecNm = `${execNmBase}_KOSPS`
|
||||||
|
const insertRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
|
||||||
|
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, $5, NOW())
|
||||||
|
RETURNING PRED_EXEC_SN`,
|
||||||
|
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn, req.user!.sub]
|
||||||
|
)
|
||||||
|
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation/run-model] KOSPS PRED_EXEC INSERT 실패:', dbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. API 연동 모델 시작 및 완료 대기 (병렬)
|
||||||
|
const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
|
||||||
|
|
||||||
|
interface SyncModelResult {
|
||||||
|
model: string
|
||||||
|
execSn: number
|
||||||
|
status: 'DONE' | 'ERROR'
|
||||||
|
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
||||||
|
summary?: ReturnType<typeof transformResult>['summary']
|
||||||
|
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
|
||||||
|
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
||||||
|
windData?: ReturnType<typeof transformResult>['windData']
|
||||||
|
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelResults = await Promise.all(
|
||||||
|
apiModels.map(async (model): Promise<SyncModelResult> => {
|
||||||
|
const algoCd = MODEL_ALGO_CD_MAP[model]
|
||||||
|
const apiUrl = MODEL_API_URL_MAP[model]
|
||||||
|
const execNm = `${execNmBase}_${algoCd}`
|
||||||
|
|
||||||
|
// PRED_EXEC INSERT
|
||||||
|
let predExecSn: number
|
||||||
|
try {
|
||||||
|
const insertRes = await wingPool.query(
|
||||||
|
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
|
||||||
|
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6, NOW())
|
||||||
|
RETURNING PRED_EXEC_SN`,
|
||||||
|
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn, req.user!.sub]
|
||||||
|
)
|
||||||
|
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error(`[simulation/run-model] ${model} PRED_EXEC INSERT 실패:`, dbErr)
|
||||||
|
return { model, execSn: 0, status: 'ERROR', error: 'DB 오류' }
|
||||||
|
}
|
||||||
|
|
||||||
|
execSns.push({ model, execSn: predExecSn })
|
||||||
|
|
||||||
|
// Python /run-model 호출
|
||||||
|
let jobId: string | undefined
|
||||||
|
try {
|
||||||
|
const pythonRes = await fetch(`${apiUrl}/run-model`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lat, lon, startTime, runTime, matTy: odMatTy, matVol, spillTime, name: execNm }),
|
||||||
|
signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pythonRes.status === 503) {
|
||||||
|
const errData = await pythonRes.json() as { error?: string }
|
||||||
|
const errMsg = errData.error || '분석 서버 포화'
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[errMsg, predExecSn]
|
||||||
|
)
|
||||||
|
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pythonRes.ok) {
|
||||||
|
throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonData = await pythonRes.json() as {
|
||||||
|
success?: boolean;
|
||||||
|
result?: PythonTimeStep[];
|
||||||
|
job_id?: string;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
error_code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동기 성공 응답 (OpenDrift & POSEIDON 공통)
|
||||||
|
if (Array.isArray(pythonData.result)) {
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC
|
||||||
|
SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1,
|
||||||
|
CMPL_DTM=NOW(), REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
|
||||||
|
WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[JSON.stringify(pythonData.result), predExecSn]
|
||||||
|
)
|
||||||
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
|
||||||
|
transformResult(pythonData.result, model)
|
||||||
|
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비동기 응답 (하위 호환)
|
||||||
|
if (pythonData.job_id) {
|
||||||
|
jobId = pythonData.job_id
|
||||||
|
} else {
|
||||||
|
// 오류 응답 (success: false, HTTP 200)
|
||||||
|
const errMsg = pythonData.error || pythonData.message || '분석 오류'
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[errMsg, predExecSn]
|
||||||
|
)
|
||||||
|
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
const errMsg = 'Python 분석 서버에 연결할 수 없습니다.'
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[errMsg, predExecSn]
|
||||||
|
)
|
||||||
|
return { model, execSn: predExecSn, status: 'ERROR', error: errMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
// RUNNING 업데이트 (비동기 폴링 경로)
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
||||||
|
[predExecSn]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 결과 동기 대기
|
||||||
|
try {
|
||||||
|
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
||||||
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
||||||
|
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
|
} catch (syncErr) {
|
||||||
|
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 모든 모델이 실패하고 신규 생성한 ACDNT가 있으면 롤백
|
||||||
|
const hasSuccess = modelResults.some((r) => r.status === 'DONE')
|
||||||
|
if (!hasSuccess && newlyCreatedAcdntSn !== null) {
|
||||||
|
for (const r of modelResults) {
|
||||||
|
if (r.execSn) await rollbackNewRecords(r.execSn, null, null)
|
||||||
|
}
|
||||||
|
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
|
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
acdntSn: resolvedAcdntSn,
|
||||||
|
predRunSn,
|
||||||
|
execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))],
|
||||||
|
results: modelResults,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 백그라운드 폴링
|
// 백그라운드 폴링
|
||||||
// ============================================================
|
// ============================================================
|
||||||
async function pollAndSave(jobId: string, execSn: number): Promise<void> {
|
async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, algoCode: string): Promise<void> {
|
||||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
|
const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
})
|
})
|
||||||
if (!pollRes.ok) continue
|
if (!pollRes.ok) continue
|
||||||
@ -402,6 +795,57 @@ async function pollAndSave(jobId: string, execSn: number): Promise<void> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 동기 폴링: Python 결과 대기 후 반환
|
||||||
|
// ============================================================
|
||||||
|
async function runModelSync(jobId: string, execSn: number, apiUrl: string): Promise<PythonTimeStep[]> {
|
||||||
|
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||||
|
|
||||||
|
let data: PythonStatusResponse
|
||||||
|
try {
|
||||||
|
const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!pollRes.ok) continue
|
||||||
|
data = await pollRes.json() as PythonStatusResponse
|
||||||
|
} catch {
|
||||||
|
// 네트워크 오류 — 재시도
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'DONE' && data.result) {
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC
|
||||||
|
SET EXEC_STTS_CD='COMPLETED',
|
||||||
|
RSLT_DATA=$1,
|
||||||
|
CMPL_DTM=NOW(),
|
||||||
|
REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
|
||||||
|
WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[JSON.stringify(data.result), execSn]
|
||||||
|
)
|
||||||
|
return data.result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'ERROR') {
|
||||||
|
const errMsg = data.error ?? '분석 오류'
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||||
|
[errMsg, execSn]
|
||||||
|
)
|
||||||
|
throw new Error(errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||||
|
[execSn]
|
||||||
|
)
|
||||||
|
throw new Error('분석 시간 초과 (30분)')
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 타입 및 결과 변환
|
// 타입 및 결과 변환
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -430,6 +874,8 @@ interface PythonTimeStep {
|
|||||||
particles: PythonParticle[]
|
particles: PythonParticle[]
|
||||||
remaining_volume_m3: number
|
remaining_volume_m3: number
|
||||||
weathered_volume_m3: number
|
weathered_volume_m3: number
|
||||||
|
evaporation_m3?: number
|
||||||
|
dispersion_m3?: number
|
||||||
pollution_area_km2: number
|
pollution_area_km2: number
|
||||||
beached_volume_m3: number
|
beached_volume_m3: number
|
||||||
pollution_coast_length_m: number
|
pollution_coast_length_m: number
|
||||||
@ -446,7 +892,13 @@ interface PythonStatusResponse {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformResult(rawResult: PythonTimeStep[]) {
|
// ALGO_CD → 프론트엔드 모델명 매핑
|
||||||
|
const ALGO_CD_TO_MODEL_NAME: Record<string, string> = {
|
||||||
|
'OPENDRIFT': 'OpenDrift',
|
||||||
|
'POSEIDON': 'POSEIDON',
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformResult(rawResult: PythonTimeStep[], model: string) {
|
||||||
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
||||||
step.particles.map((p, i) => ({
|
step.particles.map((p, i) => ({
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
@ -460,6 +912,8 @@ function transformResult(rawResult: PythonTimeStep[]) {
|
|||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -467,17 +921,26 @@ function transformResult(rawResult: PythonTimeStep[]) {
|
|||||||
const centerPoints = rawResult
|
const centerPoints = rawResult
|
||||||
.map((step, stepIdx) =>
|
.map((step, stepIdx) =>
|
||||||
step.center_lat != null && step.center_lon != null
|
step.center_lat != null && step.center_lon != null
|
||||||
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
|
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
.filter((p): p is { lat: number; lon: number; time: number } => p !== null)
|
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null)
|
||||||
const windData = rawResult.map((step) => step.wind_data ?? [])
|
const windData = rawResult.map((step) => step.wind_data ?? [])
|
||||||
const hydrData = rawResult.map((step) =>
|
const hydrData = rawResult.map((step) =>
|
||||||
step.hydr_data && step.hydr_grid
|
step.hydr_data && step.hydr_grid
|
||||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
return { trajectory, summary, centerPoints, windData, hydrData }
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
|
remainingVolume: step.remaining_volume_m3,
|
||||||
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
|
pollutionArea: step.pollution_area_km2,
|
||||||
|
beachedVolume: step.beached_volume_m3,
|
||||||
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
|
}))
|
||||||
|
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
128
backend/src/routes/tiles.ts
Normal file
128
backend/src/routes/tiles.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const VWORLD_API_KEY = process.env.VWORLD_API_KEY || '';
|
||||||
|
const ENC_UPSTREAM = 'https://tiles.gcnautical.com';
|
||||||
|
|
||||||
|
// ─── 공통 프록시 헬퍼 ───
|
||||||
|
|
||||||
|
async function proxyUpstream(upstreamUrl: string, res: import('express').Response, fallbackContentType = 'application/octet-stream') {
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(upstreamUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
res.status(upstream.status).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstream.headers.get('content-type') || fallbackContentType;
|
||||||
|
const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400';
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', cacheControl);
|
||||||
|
|
||||||
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
res.end(Buffer.from(buffer));
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: '타일 서버 연결 실패' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VWorld 위성타일 ───
|
||||||
|
|
||||||
|
// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회)
|
||||||
|
// VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계
|
||||||
|
router.get('/vworld/:z/:y/:x', async (req, res) => {
|
||||||
|
const { z, y } = req.params;
|
||||||
|
const x = req.params.x.replace(/\.jpeg$/i, '');
|
||||||
|
|
||||||
|
// z/y/x 정수 검증 (SSRF 방지)
|
||||||
|
if (!/^\d+$/.test(z) || !/^\d+$/.test(y) || !/^\d+$/.test(x)) {
|
||||||
|
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VWORLD_API_KEY) {
|
||||||
|
res.status(503).json({ error: 'VWorld API 키가 설정되지 않았습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`;
|
||||||
|
await proxyUpstream(tileUrl, res, 'image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SR 민감자원 벡터타일 ───
|
||||||
|
|
||||||
|
// GET /api/tiles/sr/tilejson — SR TileJSON 프록시 (source-layer 메타데이터)
|
||||||
|
router.get('/sr/tilejson', async (_req, res) => {
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/sr`, res, 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/sr/style — SR 스타일 JSON 프록시 (레이어별 type/paint/layout 정의)
|
||||||
|
router.get('/sr/style', async (_req, res) => {
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/style/sr`, res, 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/sr/:z/:x/:y — SR(민감자원) 벡터타일 프록시
|
||||||
|
router.get('/sr/:z/:x/:y', async (req, res) => {
|
||||||
|
const { z, x, y } = req.params;
|
||||||
|
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||||
|
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/sr/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── S-57 전자해도 (ENC) ───
|
||||||
|
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/style — 공식 style.json 프록시
|
||||||
|
router.get('/enc/style', async (_req, res) => {
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/sprite/:file — sprite JSON/PNG 프록시 (sprite.json, sprite.png, sprite@2x.json, sprite@2x.png)
|
||||||
|
router.get('/enc/sprite/:file', async (req, res) => {
|
||||||
|
const { file } = req.params;
|
||||||
|
if (!/^sprite(@2x)?\.(json|png)$/.test(file)) {
|
||||||
|
res.status(400).json({ error: '잘못된 sprite 파일명' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json';
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시
|
||||||
|
router.get('/enc/font/:fontstack/:range', async (req, res) => {
|
||||||
|
const { fontstack, range } = req.params;
|
||||||
|
if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) {
|
||||||
|
res.status(400).json({ error: '잘못된 폰트 요청' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/font/${fontstack}/${range}`, res, 'application/x-protobuf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/globe/:z/:x/:y — globe 벡터타일 프록시 (저줌 레벨용)
|
||||||
|
router.get('/enc/globe/:z/:x/:y', async (req, res) => {
|
||||||
|
const { z, x, y } = req.params;
|
||||||
|
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||||
|
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/globe/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/:z/:x/:y — ENC 벡터타일 프록시 (표준 XYZ 순서)
|
||||||
|
router.get('/enc/:z/:x/:y', async (req, res) => {
|
||||||
|
const { z, x, y } = req.params;
|
||||||
|
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||||
|
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/enc/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -1,15 +1,43 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import { listZones, listSections, getSection } from './scatService.js';
|
import { listOffices, listJurisdictions, listZones, listSections, getSection } from './scatService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/offices — 관할청 목록
|
||||||
|
// ============================================================
|
||||||
|
router.get('/offices', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const offices = await listOffices();
|
||||||
|
res.json(offices);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 관할청 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '관할청 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/jurisdictions — 관할서 목록
|
||||||
|
// ============================================================
|
||||||
|
router.get('/jurisdictions', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { officeCd } = req.query as { officeCd?: string };
|
||||||
|
const jurisdictions = await listJurisdictions(officeCd);
|
||||||
|
res.json(jurisdictions);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 관할서 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '관할서 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/scat/zones — 조사구역 목록
|
// GET /api/scat/zones — 조사구역 목록
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/zones', requireAuth, async (_req, res) => {
|
router.get('/zones', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const zones = await listZones();
|
const { jurisdiction, officeCd } = req.query as { jurisdiction?: string; officeCd?: string };
|
||||||
|
const zones = await listZones({ jurisdiction, officeCd });
|
||||||
res.json(zones);
|
res.json(zones);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
||||||
@ -22,14 +50,15 @@ router.get('/zones', requireAuth, async (_req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/sections', requireAuth, async (req, res) => {
|
router.get('/sections', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
|
const { zone, status, sensitivity, jurisdiction, search, officeCd } = req.query as {
|
||||||
zone?: string;
|
zone?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
sensitivity?: string;
|
sensitivity?: string;
|
||||||
jurisdiction?: string;
|
jurisdiction?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
officeCd?: string;
|
||||||
};
|
};
|
||||||
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
|
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search, officeCd });
|
||||||
res.json(sections);
|
res.json(sections);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scat] 해안구간 목록 조회 오류:', err);
|
console.error('[scat] 해안구간 목록 조회 오류:', err);
|
||||||
|
|||||||
@ -60,22 +60,76 @@ interface SectionDetail {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 관할청 목록 조회
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function listOffices(): Promise<string[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT DISTINCT OFFICE_CD
|
||||||
|
FROM wing.CST_SRVY_ZONE
|
||||||
|
WHERE USE_YN = 'Y' AND OFFICE_CD IS NOT NULL
|
||||||
|
ORDER BY OFFICE_CD
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql);
|
||||||
|
return rows.map((r: Record<string, unknown>) => r.office_cd as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 관할서 목록 조회
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function listJurisdictions(officeCd?: string): Promise<string[]> {
|
||||||
|
const conditions: string[] = ["USE_YN = 'Y'", 'JRSD_NM IS NOT NULL'];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
if (officeCd) {
|
||||||
|
conditions.push(`OFFICE_CD = $${idx++}`);
|
||||||
|
params.push(officeCd);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT DISTINCT JRSD_NM
|
||||||
|
FROM wing.CST_SRVY_ZONE
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY JRSD_NM
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
return rows.map((r: Record<string, unknown>) => r.jrsd_nm as string);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 조사구역 목록 조회
|
// 조사구역 목록 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function listZones(): Promise<ZoneItem[]> {
|
export async function listZones(filters?: {
|
||||||
|
jurisdiction?: string;
|
||||||
|
officeCd?: string;
|
||||||
|
}): Promise<ZoneItem[]> {
|
||||||
|
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filters?.jurisdiction) {
|
||||||
|
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
|
params.push(filters.jurisdiction);
|
||||||
|
}
|
||||||
|
if (filters?.officeCd) {
|
||||||
|
conditions.push(`OFFICE_CD = $${idx++}`);
|
||||||
|
params.push(filters.officeCd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
||||||
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
||||||
FROM wing.CST_SRVY_ZONE
|
FROM wing.CST_SRVY_ZONE
|
||||||
WHERE USE_YN = 'Y'
|
${where}
|
||||||
ORDER BY CST_SRVY_ZONE_SN
|
ORDER BY CST_SRVY_ZONE_SN
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const { rows } = await wingPool.query(sql);
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
|
||||||
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
|
|
||||||
return rows.map((r: Record<string, unknown>) => ({
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
||||||
zoneCd: r.zone_cd as string,
|
zoneCd: r.zone_cd as string,
|
||||||
@ -99,6 +153,7 @@ export async function listSections(filters: {
|
|||||||
sensitivity?: string;
|
sensitivity?: string;
|
||||||
jurisdiction?: string;
|
jurisdiction?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
officeCd?: string;
|
||||||
}): Promise<SectionListItem[]> {
|
}): Promise<SectionListItem[]> {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
@ -124,6 +179,10 @@ export async function listSections(filters: {
|
|||||||
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
|
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
params.push(filters.search);
|
params.push(filters.search);
|
||||||
}
|
}
|
||||||
|
if (filters.officeCd) {
|
||||||
|
conditions.push(`z.OFFICE_CD = $${idx++}`);
|
||||||
|
params.push(filters.officeCd);
|
||||||
|
}
|
||||||
|
|
||||||
conditions.push("s.USE_YN = 'Y'");
|
conditions.push("s.USE_YN = 'Y'");
|
||||||
conditions.push("z.USE_YN = 'Y'");
|
conditions.push("z.USE_YN = 'Y'");
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser'
|
|||||||
import { testWingDbConnection } from './db/wingDb.js'
|
import { testWingDbConnection } from './db/wingDb.js'
|
||||||
import layersRouter from './routes/layers.js'
|
import layersRouter from './routes/layers.js'
|
||||||
import simulationRouter from './routes/simulation.js'
|
import simulationRouter from './routes/simulation.js'
|
||||||
|
import tilesRouter from './routes/tiles.js'
|
||||||
import authRouter from './auth/authRouter.js'
|
import authRouter from './auth/authRouter.js'
|
||||||
import userRouter from './users/userRouter.js'
|
import userRouter from './users/userRouter.js'
|
||||||
import roleRouter from './roles/roleRouter.js'
|
import roleRouter from './roles/roleRouter.js'
|
||||||
@ -18,10 +19,15 @@ import hnsRouter from './hns/hnsRouter.js'
|
|||||||
import reportsRouter from './reports/reportsRouter.js'
|
import reportsRouter from './reports/reportsRouter.js'
|
||||||
import assetsRouter from './assets/assetsRouter.js'
|
import assetsRouter from './assets/assetsRouter.js'
|
||||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||||
|
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||||
import scatRouter from './scat/scatRouter.js'
|
import scatRouter from './scat/scatRouter.js'
|
||||||
import predictionRouter from './prediction/predictionRouter.js'
|
import predictionRouter from './prediction/predictionRouter.js'
|
||||||
import aerialRouter from './aerial/aerialRouter.js'
|
import aerialRouter from './aerial/aerialRouter.js'
|
||||||
import rescueRouter from './rescue/rescueRouter.js'
|
import rescueRouter from './rescue/rescueRouter.js'
|
||||||
|
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||||
|
import monitorRouter from './monitor/monitorRouter.js'
|
||||||
|
import vesselRouter from './vessels/vesselRouter.js'
|
||||||
|
import { startVesselScheduler } from './vessels/vesselScheduler.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -75,6 +81,7 @@ const allowedOrigins = [
|
|||||||
...(process.env.NODE_ENV !== 'production' ? [
|
...(process.env.NODE_ENV !== 'production' ? [
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
'http://localhost:5174',
|
'http://localhost:5174',
|
||||||
|
'http://localhost:5175',
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
] : []),
|
] : []),
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
@ -102,7 +109,8 @@ const generalLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skip: (req) => {
|
skip: (req) => {
|
||||||
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
||||||
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
|
return req.path.startsWith('/api/aerial/cctv/stream-proxy') ||
|
||||||
|
req.path.startsWith('/api/tiles/');
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
error: '요청 횟수 초과',
|
error: '요청 횟수 초과',
|
||||||
@ -163,10 +171,15 @@ app.use('/api/hns', hnsRouter)
|
|||||||
app.use('/api/reports', reportsRouter)
|
app.use('/api/reports', reportsRouter)
|
||||||
app.use('/api/assets', assetsRouter)
|
app.use('/api/assets', assetsRouter)
|
||||||
app.use('/api/incidents', incidentsRouter)
|
app.use('/api/incidents', incidentsRouter)
|
||||||
|
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||||
app.use('/api/scat', scatRouter)
|
app.use('/api/scat', scatRouter)
|
||||||
app.use('/api/prediction', predictionRouter)
|
app.use('/api/prediction', predictionRouter)
|
||||||
app.use('/api/aerial', aerialRouter)
|
app.use('/api/aerial', aerialRouter)
|
||||||
app.use('/api/rescue', rescueRouter)
|
app.use('/api/rescue', rescueRouter)
|
||||||
|
app.use('/api/map-base', mapBaseRouter)
|
||||||
|
app.use('/api/monitor', monitorRouter)
|
||||||
|
app.use('/api/tiles', tilesRouter)
|
||||||
|
app.use('/api/vessels', vesselRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
@ -202,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
|||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||||
|
|
||||||
|
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
|
||||||
|
startVesselScheduler()
|
||||||
|
|
||||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||||
const connected = await testWingDbConnection()
|
const connected = await testWingDbConnection()
|
||||||
if (connected) {
|
if (connected) {
|
||||||
|
|||||||
@ -93,6 +93,7 @@ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
|
|||||||
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
|
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
|
||||||
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
|
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
|
||||||
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
|
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
|
||||||
|
{ id: 'monitor', label: '실시간 상황관리', icon: '🛰', enabled: true, order: 11 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
|
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
|
||||||
@ -103,18 +104,23 @@ export async function getMenuConfig(): Promise<MenuConfigItem[]> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(val) as MenuConfigItem[]
|
const parsed = JSON.parse(val) as MenuConfigItem[]
|
||||||
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
|
const dbMap = new Map(
|
||||||
|
parsed
|
||||||
return parsed
|
|
||||||
.filter(item => VALID_MENU_IDS.includes(item.id))
|
.filter(item => VALID_MENU_IDS.includes(item.id))
|
||||||
.map(item => {
|
.map(item => [item.id, item])
|
||||||
const defaults = defaultMap.get(item.id)!
|
)
|
||||||
|
|
||||||
|
// DEFAULT 기준으로 머지 (DB에 없는 항목은 기본값 사용)
|
||||||
|
return DEFAULT_MENU_CONFIG
|
||||||
|
.map(defaultItem => {
|
||||||
|
const dbItem = dbMap.get(defaultItem.id)
|
||||||
|
if (!dbItem) return defaultItem
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: dbItem.id,
|
||||||
label: item.label || defaults.label,
|
label: dbItem.label || defaultItem.label,
|
||||||
icon: item.icon || defaults.icon,
|
icon: dbItem.icon || defaultItem.icon,
|
||||||
enabled: item.enabled,
|
enabled: dbItem.enabled,
|
||||||
order: item.order,
|
order: dbItem.order,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|||||||
@ -307,7 +307,6 @@ export async function listOrgs(): Promise<OrgItem[]> {
|
|||||||
const { rows } = await authPool.query(
|
const { rows } = await authPool.query(
|
||||||
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
||||||
FROM AUTH_ORG
|
FROM AUTH_ORG
|
||||||
WHERE USE_YN = 'Y'
|
|
||||||
ORDER BY ORG_SN`
|
ORDER BY ORG_SN`
|
||||||
)
|
)
|
||||||
return rows.map((r: Record<string, unknown>) => ({
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
|||||||
33
backend/src/vessels/vesselRouter.ts
Normal file
33
backend/src/vessels/vesselRouter.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
|
||||||
|
import type { BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const vesselRouter = Router();
|
||||||
|
|
||||||
|
// POST /api/vessels/in-area
|
||||||
|
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
|
||||||
|
vesselRouter.post('/in-area', (req, res) => {
|
||||||
|
const { bounds } = req.body as { bounds?: BoundingBox };
|
||||||
|
|
||||||
|
if (
|
||||||
|
!bounds ||
|
||||||
|
typeof bounds.minLon !== 'number' ||
|
||||||
|
typeof bounds.minLat !== 'number' ||
|
||||||
|
typeof bounds.maxLon !== 'number' ||
|
||||||
|
typeof bounds.maxLat !== 'number'
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessels = getVesselsInBounds(bounds);
|
||||||
|
res.json(vessels);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
|
||||||
|
vesselRouter.get('/status', (_req, res) => {
|
||||||
|
const status = getCacheStatus();
|
||||||
|
res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default vesselRouter;
|
||||||
96
backend/src/vessels/vesselScheduler.ts
Normal file
96
backend/src/vessels/vesselScheduler.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { updateVesselCache } from './vesselService.js';
|
||||||
|
import type { VesselPosition } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TRACK_API_URL =
|
||||||
|
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
|
||||||
|
function buildVesselCookie(): string {
|
||||||
|
const entries: [string, string | undefined][] = [
|
||||||
|
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
|
||||||
|
['g_state', process.env.VESSEL_COOKIE_G_STATE],
|
||||||
|
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
|
||||||
|
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
|
||||||
|
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
|
||||||
|
];
|
||||||
|
const parts = entries
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k, v]) => `${k}=${v}`);
|
||||||
|
|
||||||
|
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
|
||||||
|
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
|
||||||
|
return process.env.VESSEL_TRACK_COOKIE;
|
||||||
|
}
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
|
||||||
|
const KOREA_WATERS_POLYGON = [
|
||||||
|
[120, 31],
|
||||||
|
[132, 31],
|
||||||
|
[132, 41],
|
||||||
|
[120, 41],
|
||||||
|
[120, 31],
|
||||||
|
];
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function pollVesselSignals(): Promise<void> {
|
||||||
|
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
|
||||||
|
const body = {
|
||||||
|
minutes: 5,
|
||||||
|
coordinates: KOREA_WATERS_POLYGON,
|
||||||
|
polygonFilter: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookie = buildVesselCookie();
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as VesselPosition[];
|
||||||
|
updateVesselCache(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) return;
|
||||||
|
|
||||||
|
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
|
||||||
|
pollVesselSignals();
|
||||||
|
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/vessels/vesselService.ts
Normal file
55
backend/src/vessels/vesselService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
|
||||||
|
|
||||||
|
const cachedVessels = new Map<string, VesselPosition>();
|
||||||
|
let lastUpdated: Date | null = null;
|
||||||
|
|
||||||
|
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
|
||||||
|
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
|
||||||
|
function evictStale(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [mmsi, vessel] of cachedVessels) {
|
||||||
|
const ts = Date.parse(vessel.lastUpdate);
|
||||||
|
if (Number.isNaN(ts)) continue;
|
||||||
|
if (now - ts > VESSEL_TTL_MS) {
|
||||||
|
cachedVessels.delete(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVesselCache(vessels: VesselPosition[]): void {
|
||||||
|
for (const vessel of vessels) {
|
||||||
|
if (!vessel.mmsi) continue;
|
||||||
|
cachedVessels.set(vessel.mmsi, vessel);
|
||||||
|
}
|
||||||
|
evictStale();
|
||||||
|
lastUpdated = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
|
||||||
|
const result: VesselPosition[] = [];
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat
|
||||||
|
) {
|
||||||
|
result.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCacheStatus(): {
|
||||||
|
count: number;
|
||||||
|
bangjeCount: number;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
} {
|
||||||
|
let bangjeCount = 0;
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
|
||||||
|
}
|
||||||
|
return { count: cachedVessels.size, bangjeCount, lastUpdated };
|
||||||
|
}
|
||||||
26
backend/src/vessels/vesselTypes.ts
Normal file
26
backend/src/vessels/vesselTypes.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface VesselPosition {
|
||||||
|
mmsi: string;
|
||||||
|
imo?: number;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
sog?: number;
|
||||||
|
cog?: number;
|
||||||
|
heading?: number;
|
||||||
|
shipNm?: string;
|
||||||
|
shipTy?: string;
|
||||||
|
shipKindCode?: string;
|
||||||
|
nationalCode?: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
status?: string;
|
||||||
|
destination?: string;
|
||||||
|
length?: number;
|
||||||
|
width?: number;
|
||||||
|
draught?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
minLon: number;
|
||||||
|
minLat: number;
|
||||||
|
maxLon: number;
|
||||||
|
maxLat: number;
|
||||||
|
}
|
||||||
@ -278,7 +278,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
|
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
|
||||||
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
||||||
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||||
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'),
|
||||||
|
(1, 'monitor', 'READ', 'Y');
|
||||||
|
|
||||||
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
@ -292,7 +293,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
||||||
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
|
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
|
||||||
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
|
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
|
||||||
(2, 'admin', 'READ', 'N');
|
(2, 'admin', 'READ', 'N'),
|
||||||
|
(2, 'monitor', 'READ', 'Y');
|
||||||
|
|
||||||
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
|
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
@ -306,7 +308,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
|
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
|
||||||
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
||||||
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
||||||
(3, 'admin', 'READ', 'N');
|
(3, 'admin', 'READ', 'N'),
|
||||||
|
(3, 'monitor', 'READ', 'Y');
|
||||||
|
|
||||||
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
@ -320,7 +323,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
|
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
|
||||||
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
||||||
(4, 'weather', 'READ', 'Y'),
|
(4, 'weather', 'READ', 'Y'),
|
||||||
(4, 'admin', 'READ', 'N');
|
(4, 'admin', 'READ', 'N'),
|
||||||
|
(4, 'monitor', 'READ', 'Y');
|
||||||
|
|
||||||
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
|
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
@ -334,7 +338,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(5, 'incidents', 'READ', 'Y'),
|
(5, 'incidents', 'READ', 'Y'),
|
||||||
(5, 'board', 'READ', 'Y'),
|
(5, 'board', 'READ', 'Y'),
|
||||||
(5, 'weather', 'READ', 'Y'),
|
(5, 'weather', 'READ', 'Y'),
|
||||||
(5, 'admin', 'READ', 'N');
|
(5, 'admin', 'READ', 'N'),
|
||||||
|
(5, 'monitor', 'READ', 'Y');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
|
|||||||
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
||||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
||||||
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
||||||
SPIL_QTY NUMERIC(12,2), -- 유출량
|
SPIL_QTY NUMERIC(14,10), -- 유출량
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
||||||
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
||||||
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
||||||
|
|||||||
@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS LAYER (
|
|||||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||||
|
DEL_YN CHAR(1) DEFAULT 'N' NOT NULL,
|
||||||
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
||||||
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD),
|
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD),
|
||||||
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))
|
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))
|
||||||
|
|||||||
@ -77,6 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
|||||||
USE_YN CHAR(1) DEFAULT 'Y',
|
USE_YN CHAR(1) DEFAULT 'Y',
|
||||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||||
MDFCN_DTM TIMESTAMPTZ,
|
MDFCN_DTM TIMESTAMPTZ,
|
||||||
|
STEP3_MAP_IMG TEXT,
|
||||||
|
STEP6_MAP_IMG TEXT,
|
||||||
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
|
|||||||
SPIL_DATA_SN SERIAL NOT NULL,
|
SPIL_DATA_SN SERIAL NOT NULL,
|
||||||
ACDNT_SN INTEGER NOT NULL,
|
ACDNT_SN INTEGER NOT NULL,
|
||||||
OIL_TP_CD VARCHAR(50) NOT NULL,
|
OIL_TP_CD VARCHAR(50) NOT NULL,
|
||||||
SPIL_QTY NUMERIC(12,2),
|
SPIL_QTY NUMERIC(14,10),
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||||
SPIL_TP_CD VARCHAR(20),
|
SPIL_TP_CD VARCHAR(20),
|
||||||
FCST_HR INTEGER,
|
FCST_HR INTEGER,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
|
|||||||
ZONE_CD VARCHAR(10) NOT NULL UNIQUE,
|
ZONE_CD VARCHAR(10) NOT NULL UNIQUE,
|
||||||
ZONE_NM VARCHAR(100) NOT NULL,
|
ZONE_NM VARCHAR(100) NOT NULL,
|
||||||
JRSD_NM VARCHAR(20),
|
JRSD_NM VARCHAR(20),
|
||||||
|
OFFICE_CD VARCHAR(20),
|
||||||
SECT_CNT INTEGER DEFAULT 0,
|
SECT_CNT INTEGER DEFAULT 0,
|
||||||
LAT_CENTER NUMERIC(9,6),
|
LAT_CENTER NUMERIC(9,6),
|
||||||
LNG_CENTER NUMERIC(9,6),
|
LNG_CENTER NUMERIC(9,6),
|
||||||
@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
|
|||||||
CREATE TABLE IF NOT EXISTS CST_SECT (
|
CREATE TABLE IF NOT EXISTS CST_SECT (
|
||||||
CST_SECT_SN SERIAL PRIMARY KEY,
|
CST_SECT_SN SERIAL PRIMARY KEY,
|
||||||
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN),
|
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN),
|
||||||
SECT_CD VARCHAR(20) NOT NULL UNIQUE,
|
SECT_CD VARCHAR(30) NOT NULL UNIQUE,
|
||||||
SECT_NM VARCHAR(200),
|
SECT_NM VARCHAR(200),
|
||||||
CST_TP_CD VARCHAR(30),
|
CST_TP_CD VARCHAR(100),
|
||||||
ESI_CD VARCHAR(5),
|
ESI_CD VARCHAR(5),
|
||||||
ESI_NUM SMALLINT,
|
ESI_NUM SMALLINT,
|
||||||
LEN_M NUMERIC(8,1),
|
LEN_M NUMERIC(8,1),
|
||||||
|
|||||||
@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
|
|||||||
SBST_NM VARCHAR(100),
|
SBST_NM VARCHAR(100),
|
||||||
UN_NO VARCHAR(10),
|
UN_NO VARCHAR(10),
|
||||||
CAS_NO VARCHAR(20),
|
CAS_NO VARCHAR(20),
|
||||||
SPIL_QTY NUMERIC(10,2),
|
SPIL_QTY NUMERIC(14,10),
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||||
SPIL_TP_CD VARCHAR(20),
|
SPIL_TP_CD VARCHAR(20),
|
||||||
FCST_HR INTEGER,
|
FCST_HR INTEGER,
|
||||||
|
|||||||
@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
|
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
|
||||||
|
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
|
||||||
|
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
|
||||||
|
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
|
||||||
|
-- - 유출 모델링: 파공부 유출률 변화
|
||||||
|
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO RESCUE_SCENARIO (
|
INSERT INTO RESCUE_SCENARIO (
|
||||||
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
||||||
) VALUES
|
) VALUES
|
||||||
|
-- S-01: 사고 발생 (Initial Impact)
|
||||||
|
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
|
||||||
(
|
(
|
||||||
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
||||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||||
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
|
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
'[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]',
|
||||||
1
|
1
|
||||||
),
|
),
|
||||||
|
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
|
||||||
|
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
|
||||||
(
|
(
|
||||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
|
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
|
||||||
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
|
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
|
||||||
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
|
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
|
'[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]',
|
||||||
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
|
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||||
2
|
2
|
||||||
),
|
),
|
||||||
|
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
|
||||||
|
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
|
||||||
(
|
(
|
||||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
|
||||||
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
|
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
|
||||||
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
|
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
|
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]',
|
||||||
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
|
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
|
||||||
3
|
3
|
||||||
),
|
),
|
||||||
|
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
|
||||||
|
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
|
||||||
(
|
(
|
||||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
|
||||||
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
|
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
|
||||||
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
|
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
|
'[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]',
|
||||||
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
|
'[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]',
|
||||||
4
|
4
|
||||||
),
|
),
|
||||||
|
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
|
||||||
|
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
|
||||||
|
(
|
||||||
|
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
|
||||||
|
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
|
||||||
|
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]',
|
||||||
|
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
|
||||||
|
5
|
||||||
|
),
|
||||||
|
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
|
||||||
|
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
|
||||||
|
(
|
||||||
|
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||||
|
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
|
||||||
|
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
|
||||||
|
'[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]',
|
||||||
|
6
|
||||||
|
),
|
||||||
|
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
|
||||||
|
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
|
||||||
|
(
|
||||||
|
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
|
||||||
|
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
|
||||||
|
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
|
||||||
|
7
|
||||||
|
),
|
||||||
|
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
|
||||||
|
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
|
||||||
|
(
|
||||||
|
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||||
|
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
|
||||||
|
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
|
||||||
|
8
|
||||||
|
),
|
||||||
|
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
|
||||||
|
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
|
||||||
|
(
|
||||||
|
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
|
||||||
|
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
|
||||||
|
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||||
|
'[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]',
|
||||||
|
9
|
||||||
|
),
|
||||||
|
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
|
||||||
|
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
|
||||||
(
|
(
|
||||||
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
||||||
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
||||||
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
|
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
|
||||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
|
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
|
||||||
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
|
'[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]',
|
||||||
5
|
10
|
||||||
);
|
);
|
||||||
|
|||||||
39
database/migration/021_kbs_cctv_stream_urls.sql
Normal file
39
database/migration/021_kbs_cctv_stream_urls.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-- KBS 재난안전포탈 CCTV 스트림 URL 추가 마이그레이션
|
||||||
|
-- 기존 KBS 카메라 6건 streamUrl + 좌표 업데이트 + 신규 15건 INSERT
|
||||||
|
-- URL: 백엔드 KBS HLS 리졸버 경유 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8)
|
||||||
|
-- 좌표: KBS API (d.kbs.co.kr/special/cctv/list) 정확 좌표 반영
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
-- 기존 KBS 카메라 streamUrl + 좌표 업데이트 (KBS API 정확 좌표)
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9981/stream.m3u8', LON = 126.5986, LAT = 37.4541, GEOM = ST_SetSRID(ST_MakePoint(126.5986, 37.4541), 4326), CAMERA_NM = '인천 연안부두' WHERE CCTV_SN = 100;
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9994/stream.m3u8', LON = 127.7557, LAT = 34.7410, GEOM = ST_SetSRID(ST_MakePoint(127.7557, 34.7410), 4326), CAMERA_NM = '여수 오동도 앞' WHERE CCTV_SN = 97;
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9984/stream.m3u8', LON = 126.7489, LAT = 34.3209, GEOM = ST_SetSRID(ST_MakePoint(126.7489, 34.3209), 4326) WHERE CCTV_SN = 108;
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9986/stream.m3u8', LON = 128.6001, LAT = 38.2134, GEOM = ST_SetSRID(ST_MakePoint(128.6001, 38.2134), 4326), CAMERA_NM = '속초 등대전망대' WHERE CCTV_SN = 113;
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9957/stream.m3u8', LON = 131.8686, LAT = 37.2394, GEOM = ST_SetSRID(ST_MakePoint(131.8686, 37.2394), 4326) WHERE CCTV_SN = 115;
|
||||||
|
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9982/stream.m3u8', LON = 126.2684, LAT = 33.1139, GEOM = ST_SetSRID(ST_MakePoint(126.2684, 33.1139), 4326) WHERE CCTV_SN = 116;
|
||||||
|
|
||||||
|
-- 신규 KBS 재난안전포탈 CCTV 추가 (15건) — KBS API 정확 좌표
|
||||||
|
INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES
|
||||||
|
-- 서해
|
||||||
|
(200, '연평도', '서해', 125.6945, 37.6620, ST_SetSRID(ST_MakePoint(125.6945, 37.6620), 4326), '인천 옹진군 연평면', '37.66°N 125.69°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9958/stream.m3u8'),
|
||||||
|
(201, '군산 비응항', '서해', 126.5265, 35.9353, ST_SetSRID(ST_MakePoint(126.5265, 35.9353), 4326), '전북 군산시 비응도동', '35.94°N 126.53°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9979/stream.m3u8'),
|
||||||
|
(202, '태안 신진항', '서해', 126.1365, 36.6779, ST_SetSRID(ST_MakePoint(126.1365, 36.6779), 4326), '충남 태안군 근흥면', '36.68°N 126.14°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9980/stream.m3u8'),
|
||||||
|
-- 남해
|
||||||
|
(203, '창원 마산항', '남해', 128.5760, 35.1979, ST_SetSRID(ST_MakePoint(128.5760, 35.1979), 4326), '경남 창원시 마산합포구', '35.20°N 128.58°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9985/stream.m3u8'),
|
||||||
|
(204, '부산 민락항', '남해', 129.1312, 35.1538, ST_SetSRID(ST_MakePoint(129.1312, 35.1538), 4326), '부산 수영구 민락동', '35.15°N 129.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9991/stream.m3u8'),
|
||||||
|
(205, '목포 북항', '남해', 126.3652, 34.8042, ST_SetSRID(ST_MakePoint(126.3652, 34.8042), 4326), '전남 목포시 죽교동', '34.80°N 126.37°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9992/stream.m3u8'),
|
||||||
|
(206, '신안 가거도', '남해', 125.1293, 34.0529, ST_SetSRID(ST_MakePoint(125.1293, 34.0529), 4326), '전남 신안군 흑산면', '34.05°N 125.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9983/stream.m3u8'),
|
||||||
|
(207, '여수 거문도', '남해', 127.3074, 34.0232, ST_SetSRID(ST_MakePoint(127.3074, 34.0232), 4326), '전남 여수시 삼산면', '34.02°N 127.31°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9993/stream.m3u8'),
|
||||||
|
-- 동해
|
||||||
|
(208, '강릉 용강동', '동해', 128.8912, 37.7521, ST_SetSRID(ST_MakePoint(128.8912, 37.7521), 4326), '강원 강릉시 용강동', '37.75°N 128.89°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9952/stream.m3u8'),
|
||||||
|
(209, '강릉 주문진방파제', '동해', 128.8335, 37.8934, ST_SetSRID(ST_MakePoint(128.8335, 37.8934), 4326), '강원 강릉시 주문진읍', '37.89°N 128.83°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9995/stream.m3u8'),
|
||||||
|
(210, '대관령', '동해', 128.7553, 37.6980, ST_SetSRID(ST_MakePoint(128.7553, 37.6980), 4326), '강원 평창군 대관령면', '37.70°N 128.76°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9989/stream.m3u8'),
|
||||||
|
(211, '울릉 저동항', '동해', 130.9122, 37.4913, ST_SetSRID(ST_MakePoint(130.9122, 37.4913), 4326), '경북 울릉군 울릉읍', '37.49°N 130.91°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9987/stream.m3u8'),
|
||||||
|
(212, '포항 두호동 해안로', '동해', 129.3896, 36.0627, ST_SetSRID(ST_MakePoint(129.3896, 36.0627), 4326), '경북 포항시 북구 두호동', '36.06°N 129.39°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9988/stream.m3u8'),
|
||||||
|
(213, '울산 달동', '동해', 129.3265, 35.5442, ST_SetSRID(ST_MakePoint(129.3265, 35.5442), 4326), '울산 남구 달동', '35.54°N 129.33°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9955/stream.m3u8'),
|
||||||
|
-- 제주
|
||||||
|
(214, '제주 도남동', '제주', 126.5195, 33.4891, ST_SetSRID(ST_MakePoint(126.5195, 33.4891), 4326), '제주시 도남동', '33.49°N 126.52°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9954/stream.m3u8');
|
||||||
|
|
||||||
|
-- 시퀀스 리셋
|
||||||
|
SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera));
|
||||||
19
database/migration/022_aerial_spectral_perm.sql
Normal file
19
database/migration/022_aerial_spectral_perm.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- aerial:spectral (AI 탐지/분석) 서브탭 권한 추가
|
||||||
|
-- 기존 aerial 서브탭(satellite) 뒤, cctv 앞에 배치 (SORT_ORD = 6)
|
||||||
|
|
||||||
|
-- 기존 cctv, theory 순서 밀기
|
||||||
|
UPDATE AUTH_PERM_TREE SET SORT_ORD = 7 WHERE RSRC_CD = 'aerial:cctv';
|
||||||
|
UPDATE AUTH_PERM_TREE SET SORT_ORD = 8 WHERE RSRC_CD = 'aerial:theory';
|
||||||
|
|
||||||
|
-- spectral 리소스 추가
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
|
||||||
|
VALUES ('aerial:spectral', 'aerial', 'AI 탐지/분석', 1, 6)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- 기존 역할에 spectral READ 권한 부여 (aerial READ 권한이 있는 역할)
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, USE_YN)
|
||||||
|
SELECT ap.ROLE_SN, 'aerial:spectral', ap.OPER_CD, ap.USE_YN
|
||||||
|
FROM AUTH_PERM ap
|
||||||
|
WHERE ap.RSRC_CD = 'aerial'
|
||||||
|
AND ap.USE_YN = 'Y'
|
||||||
|
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;
|
||||||
43
database/migration/022_map_base.sql
Normal file
43
database/migration/022_map_base.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 022_map_base.sql — 지도 백데이터 관리 테이블
|
||||||
|
-- ============================================================
|
||||||
|
-- 관리자가 등록한 지도 유형(S-57, S-101, 3D 등)을 DB로 관리하며,
|
||||||
|
-- USE_YN 으로 활성/비활성을 제어해 TopBar 햄버거 메뉴 노출을 조정한다.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. 지도 백데이터 마스터
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS MAP_BASE_DATA (
|
||||||
|
MAP_SN SERIAL PRIMARY KEY,
|
||||||
|
MAP_KEY VARCHAR(30) NOT NULL UNIQUE, -- 지도 식별 키 (s57, s101, threeD, satellite 등)
|
||||||
|
MAP_NM VARCHAR(100) NOT NULL, -- 지도 표시명 (예: S-57 전자해도)
|
||||||
|
MAP_LEVEL_CD VARCHAR(20), -- 지도 레벨 코드: S-52 | S-57 | S-101 | 3D | SAT | 기타
|
||||||
|
MAP_SRC TEXT, -- 타일 URL 또는 파일 경로
|
||||||
|
MAP_DC TEXT, -- 상세 설명
|
||||||
|
USE_YN CHAR(1) DEFAULT 'Y', -- 사용 여부: Y(사용중) / N(미사용)
|
||||||
|
DEL_YN CHAR(1) DEFAULT 'N', -- 삭제 여부: Y(삭제됨) / N(정상)
|
||||||
|
REG_ID VARCHAR(50), -- 등록자 ID
|
||||||
|
REG_NM VARCHAR(50), -- 등록자 이름
|
||||||
|
REG_DTM TIMESTAMPTZ DEFAULT NOW(), -- 등록 일시
|
||||||
|
MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() -- 수정 일시
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. 인덱스
|
||||||
|
-- ============================================================
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_USE ON MAP_BASE_DATA(USE_YN);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_KEY ON MAP_BASE_DATA(MAP_KEY);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_DEL ON MAP_BASE_DATA(DEL_YN);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. 초기 데이터 — 기존 하드코딩 4개 지도 유형
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO MAP_BASE_DATA (MAP_KEY, MAP_NM, MAP_LEVEL_CD, USE_YN) VALUES
|
||||||
|
('s57', 'S-57 전자해도', 'S-57', 'Y'),
|
||||||
|
('s101', 'S-101 전자해도', 'S-101', 'Y'),
|
||||||
|
('threeD', '3D 지도', '3D', 'Y'),
|
||||||
|
('satellite', '위성 영상', 'SAT', 'Y')
|
||||||
|
ON CONFLICT (MAP_KEY) DO NOTHING;
|
||||||
10
database/migration/023_layer_del_yn.sql
Normal file
10
database/migration/023_layer_del_yn.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- 023_layer_del_yn.sql
|
||||||
|
-- LAYER 테이블에 소프트 삭제 플래그 추가
|
||||||
|
|
||||||
|
ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DEL_YN CHAR(1) DEFAULT 'N' NOT NULL;
|
||||||
|
|
||||||
|
-- 기존 데이터 초기화
|
||||||
|
UPDATE LAYER SET DEL_YN = 'N' WHERE DEL_YN IS NULL;
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_LAYER_DEL ON LAYER (DEL_YN);
|
||||||
57
database/migration/024_admin_perm_tree.sql
Normal file
57
database/migration/024_admin_perm_tree.sql
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
-- 관리자 권한 트리 확장: 게시판관리, 기준정보, 연계관리 섹션 추가
|
||||||
|
-- AdminView.tsx의 adminMenuConfig.ts에 정의된 전체 메뉴 구조를 AUTH_PERM_TREE에 반영
|
||||||
|
|
||||||
|
-- Level 1 섹션 노드 (3개)
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:board-mgmt', 'admin', '게시판관리', 1, 5),
|
||||||
|
('admin:reference', 'admin', '기준정보', 1, 6),
|
||||||
|
('admin:external', 'admin', '연계관리', 1, 7)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 2 그룹/리프 노드
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:notice', 'admin:board-mgmt', '공지사항', 2, 1),
|
||||||
|
('admin:board', 'admin:board-mgmt', '게시판', 2, 2),
|
||||||
|
('admin:qna', 'admin:board-mgmt', 'QNA', 2, 3),
|
||||||
|
('admin:map-mgmt', 'admin:reference', '지도관리', 2, 1),
|
||||||
|
('admin:sensitive-map', 'admin:reference', '민감자원지도', 2, 2),
|
||||||
|
('admin:coast-guard-assets', 'admin:reference', '해경자산', 2, 3),
|
||||||
|
('admin:collection', 'admin:external', '수집자료', 2, 1),
|
||||||
|
('admin:monitoring', 'admin:external', '연계모니터링', 2, 2)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 3 리프 노드
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:map-base', 'admin:map-mgmt', '지도백데이터', 3, 1),
|
||||||
|
('admin:map-layer', 'admin:map-mgmt', '레이어', 3, 2),
|
||||||
|
('admin:env-ecology', 'admin:sensitive-map', '환경/생태', 3, 1),
|
||||||
|
('admin:social-economy', 'admin:sensitive-map', '사회/경제', 3, 2),
|
||||||
|
('admin:cleanup-equip', 'admin:coast-guard-assets', '방제장비', 3, 1),
|
||||||
|
('admin:asset-upload', 'admin:coast-guard-assets', '자산현행화', 3, 2),
|
||||||
|
('admin:dispersant-zone', 'admin:coast-guard-assets', '유처리제 제한구역', 3, 3),
|
||||||
|
('admin:vessel-materials', 'admin:coast-guard-assets', '방제선 보유자재', 3, 4),
|
||||||
|
('admin:collect-vessel-signal', 'admin:collection', '선박신호', 3, 1),
|
||||||
|
('admin:collect-hr', 'admin:collection', '인사정보', 3, 2),
|
||||||
|
('admin:monitor-realtime', 'admin:monitoring', '실시간 관측자료', 3, 1),
|
||||||
|
('admin:monitor-forecast', 'admin:monitoring', '수치예측자료', 3, 2),
|
||||||
|
('admin:monitor-vessel', 'admin:monitoring', '선박위치정보', 3, 3),
|
||||||
|
('admin:monitor-hr', 'admin:monitoring', '인사', 3, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- AUTH_PERM: 신규 섹션/그룹 노드에 권한 복사
|
||||||
|
-- admin 권한이 있는 역할에 동일하게 부여 (permResolver의 parent READ gate 충족)
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ap.ROLE_SN, nc.RSRC_CD, ap.OPER_CD, ap.GRANT_YN
|
||||||
|
FROM AUTH_PERM ap
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('admin:board-mgmt'),
|
||||||
|
('admin:reference'),
|
||||||
|
('admin:external'),
|
||||||
|
('admin:map-mgmt'),
|
||||||
|
('admin:sensitive-map'),
|
||||||
|
('admin:coast-guard-assets'),
|
||||||
|
('admin:collection'),
|
||||||
|
('admin:monitoring')
|
||||||
|
) AS nc(RSRC_CD)
|
||||||
|
WHERE ap.RSRC_CD = 'admin'
|
||||||
|
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;
|
||||||
44
database/migration/025_weather_columns.sql
Normal file
44
database/migration/025_weather_columns.sql
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
|
||||||
|
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
|
||||||
|
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
|
||||||
|
|
||||||
|
ALTER TABLE wing.ACDNT_WEATHER
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
|
||||||
|
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
|
||||||
|
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';
|
||||||
41
database/migration/026_sensitive_resources.sql
Normal file
41
database/migration/026_sensitive_resources.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 027: 민감자원 테이블 생성
|
||||||
|
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
|
||||||
|
-- properties는 JSONB로 유연하게 저장
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 민감자원 테이블
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
|
||||||
|
SR_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
|
||||||
|
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
|
||||||
|
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
|
||||||
|
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MOD_DT TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 공간 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
|
||||||
|
|
||||||
|
-- 카테고리 인덱스 (유형별 필터링)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
|
||||||
|
|
||||||
|
-- JSONB 인덱스 (properties 내부 검색용)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
|
||||||
|
|
||||||
|
-- 카테고리 + 공간 복합 조회 최적화
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';
|
||||||
27
database/migration/027_sensitivity_evaluation.sql
Normal file
27
database/migration/027_sensitivity_evaluation.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 027: 통합민감도 평가 테이블 생성
|
||||||
|
-- 계절별 민감도 평가 그리드 데이터 저장
|
||||||
|
-- properties 구조: { ID, FA_G, SM_G, SP_G, WT_G, MAX_G, GRID_LEVEL }
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS SENSITIVE_EVALUATION (
|
||||||
|
SR_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
CATEGORY VARCHAR(50) NOT NULL DEFAULT '민감도평가',
|
||||||
|
GEOM public.geometry(Geometry, 4326) NOT NULL,
|
||||||
|
PROPERTIES JSONB NOT NULL DEFAULT '{}',
|
||||||
|
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MOD_DT TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SE_GEOM ON SENSITIVE_EVALUATION USING GIST(GEOM);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SE_PROPERTIES ON SENSITIVE_EVALUATION USING GIN(PROPERTIES);
|
||||||
|
|
||||||
|
COMMENT ON TABLE SENSITIVE_EVALUATION IS '통합민감도 평가 그리드 테이블';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.SR_ID IS '민감도 평가 ID';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.CATEGORY IS '카테고리 (기본값: 민감도평가)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.PROPERTIES IS '계절별 민감도 값 { SP_G, SM_G, FA_G, WT_G, MAX_G, GRID_LEVEL }';
|
||||||
25
database/migration/028_pred_run_sn.sql
Normal file
25
database/migration/028_pred_run_sn.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- Migration 028: PRED_EXEC에 실행 그룹 식별자(PRED_RUN_SN) 추가
|
||||||
|
-- 같은 시점에 여러 모델로 실행된 PRED_EXEC 레코드를 하나의 "예측 실행"으로 묶는다.
|
||||||
|
-- 목록 화면에서 사고당 예측 실행 횟수만큼 행을 표시하기 위한 기반 구조.
|
||||||
|
|
||||||
|
-- 1. 컬럼 추가
|
||||||
|
ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS PRED_RUN_SN INTEGER;
|
||||||
|
|
||||||
|
-- 2. 기존 데이터 마이그레이션
|
||||||
|
-- 같은 ACDNT_SN + 시작 시각 60초 이내의 레코드를 동일 실행 그룹으로 묶는다.
|
||||||
|
-- MIN(PRED_EXEC_SN)을 그룹 대표 키로 사용한다.
|
||||||
|
UPDATE wing.PRED_EXEC pe1
|
||||||
|
SET PRED_RUN_SN = (
|
||||||
|
SELECT MIN(pe2.PRED_EXEC_SN)
|
||||||
|
FROM wing.PRED_EXEC pe2
|
||||||
|
WHERE pe2.ACDNT_SN = pe1.ACDNT_SN
|
||||||
|
AND ABS(EXTRACT(EPOCH FROM (
|
||||||
|
COALESCE(pe2.BGNG_DTM, NOW()) - COALESCE(pe1.BGNG_DTM, NOW())
|
||||||
|
))) < 60
|
||||||
|
)
|
||||||
|
WHERE pe1.PRED_RUN_SN IS NULL;
|
||||||
|
|
||||||
|
-- 3. 시퀀스 생성 (신규 실행용 — 기존 최대값보다 충분히 높은 값에서 시작)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS wing.PRED_RUN_SN_SEQ
|
||||||
|
START WITH 10000
|
||||||
|
INCREMENT BY 1;
|
||||||
3
database/migration/029_pred_exec_user.sql
Normal file
3
database/migration/029_pred_exec_user.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 029_pred_exec_user.sql
|
||||||
|
-- PRED_EXEC 테이블에 예측 실행자 ID 컬럼 추가
|
||||||
|
ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS EXEC_USER_ID UUID;
|
||||||
19
database/migration/030_ais_track.sql
Normal file
19
database/migration/030_ais_track.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- AIS 선박 위치 이력 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS wing.AIS_TRACK (
|
||||||
|
AIS_TRACK_SN SERIAL PRIMARY KEY,
|
||||||
|
MMSI VARCHAR(12) NOT NULL,
|
||||||
|
IMO VARCHAR(12),
|
||||||
|
VESSEL_NM VARCHAR(100),
|
||||||
|
VESSEL_TP SMALLINT,
|
||||||
|
LAT NUMERIC(9,6),
|
||||||
|
LON NUMERIC(10,6),
|
||||||
|
SPEED NUMERIC(5,1),
|
||||||
|
COURSE NUMERIC(5,1),
|
||||||
|
NAV_STATUS SMALLINT,
|
||||||
|
OBS_DTM TIMESTAMPTZ NOT NULL,
|
||||||
|
GEOM GEOMETRY(Point, 4326),
|
||||||
|
SRC_CD VARCHAR(20) DEFAULT 'API'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ais_track_mmsi ON wing.AIS_TRACK(MMSI);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ais_track_obs_dtm ON wing.AIS_TRACK(OBS_DTM);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ais_track_geom ON wing.AIS_TRACK USING GIST(GEOM);
|
||||||
7
database/migration/031_spil_qty_precision.sql
Normal file
7
database/migration/031_spil_qty_precision.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
|
||||||
|
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
|
||||||
|
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
|
||||||
|
-- 정수부 최대 4자리, 소수부 10자리
|
||||||
|
|
||||||
|
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||||
|
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||||
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 목적
|
||||||
|
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
|
||||||
|
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
|
||||||
|
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
|
||||||
|
--
|
||||||
|
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
|
||||||
|
-- - acdnt_asort_code IN (12개 코드)
|
||||||
|
-- - acdnt_title IS NOT NULL
|
||||||
|
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
|
||||||
|
-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건)
|
||||||
|
--
|
||||||
|
-- ACDNT_CD 생성 규칙
|
||||||
|
-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리)
|
||||||
|
-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을
|
||||||
|
-- 구해 이어서 증가시킨다.
|
||||||
|
--
|
||||||
|
-- 중복 방지
|
||||||
|
-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외.
|
||||||
|
-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용.
|
||||||
|
--
|
||||||
|
-- ACDNT_TP_CD
|
||||||
|
-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code)
|
||||||
|
-- 매핑 누락 시 원본 코드값으로 폴백.
|
||||||
|
--
|
||||||
|
-- 사전 확인 쿼리 (실행 전 참고)
|
||||||
|
-- SELECT COUNT(DISTINCT a.acdnt_mng_no)
|
||||||
|
-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no)
|
||||||
|
-- WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||||
|
-- '055001001','055001002','055001003','055001004','055001005','055001006',
|
||||||
|
-- '055003001','055003002','055003003','055003004','055003005','055004003'
|
||||||
|
-- ]::varchar[])
|
||||||
|
-- AND a.acdnt_title IS NOT NULL
|
||||||
|
-- AND a.rcept_dt >= '2026-04-10';
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
WITH src AS (
|
||||||
|
SELECT DISTINCT ON (a.acdnt_mng_no)
|
||||||
|
a.acdnt_mng_no,
|
||||||
|
a.acdnt_title,
|
||||||
|
a.acdnt_asort_code,
|
||||||
|
a.rcept_dt,
|
||||||
|
b.la,
|
||||||
|
b.lo
|
||||||
|
FROM gsc.tgs_acdnt_info AS a
|
||||||
|
JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no
|
||||||
|
WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||||
|
'055001001','055001002','055001003','055001004','055001005','055001006',
|
||||||
|
'055003001','055003002','055003003','055003004','055003005','055004003'
|
||||||
|
]::varchar[])
|
||||||
|
AND a.acdnt_title IS NOT NULL
|
||||||
|
AND a.rcept_dt >= '2026-04-10'::timestamptz
|
||||||
|
AND b.la IS NOT NULL AND b.lo IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM wing.ACDNT w
|
||||||
|
WHERE w.ACDNT_NM = a.acdnt_title
|
||||||
|
AND w.OCCRN_DTM = a.rcept_dt
|
||||||
|
)
|
||||||
|
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
|
||||||
|
),
|
||||||
|
numbered AS (
|
||||||
|
SELECT
|
||||||
|
src.*,
|
||||||
|
EXTRACT(YEAR FROM src.rcept_dt)::int AS yr,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY EXTRACT(YEAR FROM src.rcept_dt)
|
||||||
|
ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC
|
||||||
|
) AS rn_in_year
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
year_max AS (
|
||||||
|
SELECT
|
||||||
|
(split_part(ACDNT_CD, '-', 2))::int AS yr,
|
||||||
|
MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq
|
||||||
|
FROM wing.ACDNT
|
||||||
|
WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$'
|
||||||
|
GROUP BY split_part(ACDNT_CD, '-', 2)
|
||||||
|
)
|
||||||
|
INSERT INTO wing.ACDNT (
|
||||||
|
ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD,
|
||||||
|
LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'INC-' || lpad(n.yr::text, 4, '0') || '-' ||
|
||||||
|
lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD,
|
||||||
|
n.acdnt_title AS ACDNT_NM,
|
||||||
|
COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD,
|
||||||
|
'ACTIVE' AS ACDNT_STTS_CD,
|
||||||
|
n.la::numeric AS LAT,
|
||||||
|
n.lo::numeric AS LNG,
|
||||||
|
ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM,
|
||||||
|
NULL AS LOC_DC,
|
||||||
|
n.rcept_dt AS OCCRN_DTM,
|
||||||
|
NOW(), NOW()
|
||||||
|
FROM numbered n
|
||||||
|
LEFT JOIN year_max ym ON ym.yr = n.yr
|
||||||
|
LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code
|
||||||
|
ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 사후 검증 (필요 시 주석 해제 실행)
|
||||||
|
-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10';
|
||||||
|
--
|
||||||
|
-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM
|
||||||
|
-- FROM wing.ACDNT
|
||||||
|
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||||
|
-- ORDER BY ACDNT_CD DESC
|
||||||
|
-- LIMIT 20;
|
||||||
|
--
|
||||||
|
-- SELECT ACDNT_TP_CD, COUNT(*)
|
||||||
|
-- FROM wing.ACDNT
|
||||||
|
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||||
|
-- GROUP BY 1
|
||||||
|
-- ORDER BY 2 DESC;
|
||||||
|
-- ============================================================
|
||||||
@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
|||||||
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/board/services/boardApi.ts
|
// frontend/src/components/board/services/boardApi.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// 인터페이스 정의
|
// 인터페이스 정의
|
||||||
@ -490,7 +490,7 @@ interface MenuConfigItem {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/common/store/newStore.ts (공통) 또는
|
// frontend/src/common/store/newStore.ts (공통) 또는
|
||||||
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
|
// frontend/src/components/{탭}/store/newStore.ts (탭 전용)
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface MyState {
|
interface MyState {
|
||||||
@ -514,7 +514,7 @@ export const useMyStore = create<MyState>((set) => ({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';
|
import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi';
|
||||||
|
|
||||||
// 조회 (캐싱 + 자동 리페치)
|
// 조회 (캐싱 + 자동 리페치)
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 작성 패턴
|
### 작성 패턴
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
// frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 기본 구조
|
### 기본 구조
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
// frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -1376,7 +1376,7 @@ export default router;
|
|||||||
### 4단계: 프론트엔드 API 서비스
|
### 4단계: 프론트엔드 API 서비스
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/assets/services/equipmentApi.ts
|
// frontend/src/components/assets/services/equipmentApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
|||||||
565
docs/DESIGN-SYSTEM.md
Normal file
565
docs/DESIGN-SYSTEM.md
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
# WING-OPS 디자인 시스템
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
|
||||||
|
시맨틱 토큰 기반 다크/라이트 테마 전환을 지원한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 버전 히스토리
|
||||||
|
|
||||||
|
### v1.1 — 시맨틱 토큰 & 테마 시스템 (2026-03-28~)
|
||||||
|
|
||||||
|
> 브랜치: `feature/predict-develop`, `feature/design-system-font`
|
||||||
|
|
||||||
|
v1.0의 축약형 토큰 시스템을 시맨틱 네이밍으로 전면 전환하고, 다크/라이트 테마 전환 기능을 도입한 구조적 리팩토링.
|
||||||
|
|
||||||
|
#### 변경된 점
|
||||||
|
|
||||||
|
| 항목 | v1.0 | v1.1 |
|
||||||
|
|------|------|------|
|
||||||
|
| 토큰 네이밍 | 축약형 (`--bg0`, `--t1`, `--cyan`) | 시맨틱 (`--bg-base`, `--fg-default`, `--color-accent`) |
|
||||||
|
| 폰트 | Outfit + Noto Sans KR + JetBrains Mono (3종) | PretendardGOV 단일 폰트 (4웨이트) |
|
||||||
|
| 테마 | 다크 모드 전용 (하드코딩) | 다크/라이트 전환 지원 (`data-theme` 속성) |
|
||||||
|
| 프리미티브 팔레트 | 7그룹 11단계 (Navy/Cyan/Blue/Red/Green/Orange/Yellow, 00~100) | 6그룹 10단계 (Gray/Blue/Green/Yellow/Red/Purple, 100~1000) |
|
||||||
|
| 텍스트 대비 | `--t2: #b0b8cc`, `--t3: #8690a6` | `--fg-sub: #c0c8dc`, `--fg-disabled: #9ba3b8` (대비 향상) |
|
||||||
|
| 버튼 스타일 | `.prd-btn.pri` 그라데이션 (cyan→blue) | 아웃라인/고스트 스타일 |
|
||||||
|
| Tailwind 컬러 키 | 하드코딩 hex (`bg.0`, `text.1`, `primary.cyan`) | CSS 변수 참조 (`bg.base`, `fg.DEFAULT`, `color.accent`) |
|
||||||
|
| 폰트 유틸리티 | 하드코딩 (`font-family: 'JetBrains Mono'`) | CSS 변수 경유 (`font-family: var(--font-mono)`) |
|
||||||
|
|
||||||
|
#### 토큰 마이그레이션 매핑
|
||||||
|
|
||||||
|
| v1.0 | v1.1 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--bg0` | `--bg-base` | 페이지 배경 |
|
||||||
|
| `--bg1` | `--bg-surface` | 사이드바, 패널 |
|
||||||
|
| `--bg2` | `--bg-elevated` | 테이블 헤더, 상위 요소 |
|
||||||
|
| `--bg3` | `--bg-card` | 카드 배경 |
|
||||||
|
| `--bgH` | `--bg-surface-hover` | 호버 상태 |
|
||||||
|
| `--bd` | `--stroke-default` | 기본 구분선 |
|
||||||
|
| `--bdL` | `--stroke-light` | 연한 구분선 |
|
||||||
|
| `--t1` | `--fg-default` | 기본 텍스트 |
|
||||||
|
| `--t2` | `--fg-sub` | 보조 텍스트 |
|
||||||
|
| `--t3` | `--fg-disabled` | 비활성 텍스트 |
|
||||||
|
| `--cyan` | `--color-accent` | 주요 강조 |
|
||||||
|
| `--blue` | `--color-info` | 정보, 링크 |
|
||||||
|
| `--red` | `--color-danger` | 위험, 삭제 |
|
||||||
|
| `--orange` | `--color-warning` | 주의 |
|
||||||
|
| `--yellow` | `--color-caution` | 경고 |
|
||||||
|
| `--green` | `--color-success` | 성공, 정상 |
|
||||||
|
| `--purple` | `--color-tertiary` | 3차 강조 |
|
||||||
|
| `--boom` | `--color-boom` | 오일붐 전용 |
|
||||||
|
| `--fK` | `--font-korean` | 한국어 폰트 스택 |
|
||||||
|
| `--fM` | `--font-mono` | 모노 폰트 스택 |
|
||||||
|
| `--rS` | `--radius-sm` | 소형 border radius |
|
||||||
|
| `--rM` | `--radius-md` | 중형 border radius |
|
||||||
|
|
||||||
|
#### 추가된 기능
|
||||||
|
|
||||||
|
- **다크/라이트 테마 전환**: `themeStore.ts` (Zustand) + `data-theme` DOM 속성 + FOUC 방지 인라인 스크립트
|
||||||
|
- **시맨틱 오버레이 토큰**: `--hover-overlay`, `--dropdown-bg` (테마별 불투명도 차별화)
|
||||||
|
- **Navy 액센트 토큰**: `--color-navy`, `--color-navy-hover`, `--color-accent-muted`
|
||||||
|
- **정적 컬러 토큰**: `--static-black`, `--static-white` (테마 무관 고정값)
|
||||||
|
- **타이포그래피 스케일 (17개 토큰)**: Display 3종, Heading 3종, Title 6종, Body 2종, Label 2종, Caption 1종
|
||||||
|
- **Letter-spacing 토큰 5종**: `--letter-spacing-display` (0.06em) ~ `--letter-spacing-label` (0.04em)
|
||||||
|
- **Font weight 토큰 4종**: thin(300) / regular(400) / medium(500) / bold(700)
|
||||||
|
- **Line height 토큰 4종**: tight(1.3) / snug(1.4) / normal(1.5) / relaxed(1.6)
|
||||||
|
- **Tailwind tracking 유틸리티**: `tracking-display`, `tracking-heading`, `tracking-body`, `tracking-navigation`, `tracking-label`
|
||||||
|
- **라이트 테마 컴포넌트 오버라이드**: CCTV 팝업, 날짜피커, 타임라인, 콤보박스, 좌표 표시 등
|
||||||
|
- **폰트 렌더링 최적화**: `-webkit-font-smoothing: antialiased`, `text-rendering: optimizeLegibility`
|
||||||
|
|
||||||
|
#### 업데이트된 부분
|
||||||
|
|
||||||
|
- 92개+ 컴포넌트 파일의 토큰 마이그레이션 (축약형 → 시맨틱)
|
||||||
|
- Stitch MCP 프로젝트 참조 제거 (독립 토큰 시스템으로 전환)
|
||||||
|
- DESIGN-SYSTEM.md 문서 전면 재작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v1.0 — 초기 디자인 시스템 (2026-03-24~25)
|
||||||
|
|
||||||
|
> 브랜치: `feature/stitch-mcp` | 도구: Google Stitch MCP
|
||||||
|
|
||||||
|
WING-OPS 전용 디자인 시스템의 첫 구축. CSS 변수 기반 토큰 시스템, `.wing-*` 컴포넌트 클래스, 라이브 카탈로그 뷰어를 도입.
|
||||||
|
|
||||||
|
#### 컬러 시스템
|
||||||
|
|
||||||
|
- **프리미티브 팔레트**: 7개 그룹 (Navy/Cyan/Blue/Red/Green/Orange/Yellow), 각 11단계 스케일 (00~100)
|
||||||
|
- **시맨틱 컬러**: 축약형 CSS 변수 — Background(`--bg0`~`--bgH`), Text(`--t1`~`--t3`), Border(`--bd`, `--bdL`)
|
||||||
|
- **액센트 컬러**: `--cyan`(#06b6d4), `--blue`(#3b82f6), `--red`, `--green`, `--orange`, `--yellow`, `--purple`
|
||||||
|
- **특수 컬러**: `--boom`(#f59e0b) — 오일붐 전용 Amber
|
||||||
|
|
||||||
|
#### 타이포그래피
|
||||||
|
|
||||||
|
- **3종 폰트 패밀리**: Outfit (영문 본문), Noto Sans KR (한국어), JetBrains Mono (좌표/수치)
|
||||||
|
- **10개 `.wing-*` 타이포 클래스**: `.wing-title`(15px) ~ `.wing-badge`(9px)
|
||||||
|
|
||||||
|
#### 컴포넌트 클래스
|
||||||
|
|
||||||
|
| 카테고리 | 클래스 |
|
||||||
|
|----------|--------|
|
||||||
|
| 레이아웃 셸 | `.wing-panel`, `.wing-panel-scroll`, `.wing-header-bar`, `.wing-sidebar` |
|
||||||
|
| 컨테이너 | `.wing-card`, `.wing-card-sm`, `.wing-section` |
|
||||||
|
| 버튼 | `.wing-btn`, `.wing-btn-primary` (cyan→blue 그라데이션), `-secondary`, `-outline`, `-pdf`, `-danger` |
|
||||||
|
| 입력 | `.wing-input` (cyan 포커스 링) |
|
||||||
|
| 테이블 | `.wing-table`, `.wing-th`, `.wing-td`, `.wing-tr-hover` |
|
||||||
|
| 탭 | `.wing-tab-bar`, `.wing-tab` (cyan 틴트 + 보더 활성) |
|
||||||
|
| 모달 | `.wing-overlay` (블러 백드롭), `.wing-modal`, `.wing-modal-header` |
|
||||||
|
| 유틸리티 | `.wing-divider`, `.wing-kv-row`, `.wing-kv-label`, `.wing-kv-value` |
|
||||||
|
| 뱃지/아이콘 | `.wing-badge`, `.wing-icon-badge`, `.wing-icon-badge-sm` |
|
||||||
|
|
||||||
|
#### 특수 컴포넌트
|
||||||
|
|
||||||
|
| 영역 | 클래스 접두사 | 설명 |
|
||||||
|
|------|--------------|------|
|
||||||
|
| 예측 패널 | `.prd-*` | 폼 입력, 버튼, 맵 버튼 |
|
||||||
|
| 콤보박스 | `.combo-*` | 커스텀 드롭다운 (검색 + 리스트) |
|
||||||
|
| 타임라인 | `.tlb`, `.tlt`, `.tlr`, `.tlth` | 지도 하단 재생 컨트롤 |
|
||||||
|
| 레이어 트리 | `.lyr-*` | 3단계 접이식 레이어 (색상 스와치 + 투명도) |
|
||||||
|
| 오일붐 | `.boom-*` | 오일붐 드로잉 인디케이터 |
|
||||||
|
| 역추적 | `.bt-*` | 역추적 리플레이 마커 + 속도 버튼 |
|
||||||
|
| HNS | `.hns-scn-card` | HNS 시나리오 선택 카드 |
|
||||||
|
| 모델 칩 | `.prd-mc` | 모델 선택 칩 (활성 `✓` 인디케이터) |
|
||||||
|
|
||||||
|
#### 레이아웃
|
||||||
|
|
||||||
|
- **데스크톱 전용** (≥ 1280px), Tablet/Mobile 미지원
|
||||||
|
- **7단계 z-index 레이어**: Base(0) ~ Tooltip(60)
|
||||||
|
- **Spacing**: Tailwind 기본 스케일 사용 (`gap-1`~`gap-8`)
|
||||||
|
|
||||||
|
#### Border Radius
|
||||||
|
|
||||||
|
- `rounded-sm`: **6px** (커스텀 오버라이드)
|
||||||
|
- `rounded-md`: **10px** (커스텀 오버라이드)
|
||||||
|
- 나머지 Tailwind 기본값 유지
|
||||||
|
|
||||||
|
#### 애니메이션
|
||||||
|
|
||||||
|
`fadeIn`, `fadeSlideDown`, `pulse-dot`, `pulse-border`, `comboIn`, `lyrPopIn`, `bt-collision-pulse`
|
||||||
|
|
||||||
|
#### 디자인 카탈로그
|
||||||
|
|
||||||
|
`/design` 라우트에 라이브 카탈로그 뷰어 배포:
|
||||||
|
- **Foundations 탭**: Color Palette, Typography, Radius, Layout, Overview
|
||||||
|
- **Components 탭**: Button, TextField, Overview (Button Catalog, Card, Icon Badge 섹션)
|
||||||
|
- **다크/라이트 모드 토글** 내장
|
||||||
|
- **테마 엔진**: `designTheme.ts` — 타입 안전한 `DesignTheme` 인터페이스
|
||||||
|
|
||||||
|
#### SVG 아이콘 에셋
|
||||||
|
|
||||||
|
23개 커스텀 아이콘 (`wing-` 접두사): `wing-anchor`, `wing-cargo`, `wing-chart-bar`, `wing-color-palette`, `wing-documentation`, `wing-elevation`, `wing-foundations`, `wing-layout-grid`, `wing-notification`, `wing-pdf-file`, `wing-settings`, `wing-typography`, `wing-wave-graph` 등
|
||||||
|
|
||||||
|
#### Stitch MCP 연동
|
||||||
|
|
||||||
|
Google Stitch 프로젝트 7개 스크린 참조:
|
||||||
|
- Design Tokens, Component Catalog (Buttons/Badges), Form Components
|
||||||
|
- Table & List Patterns, Modal Catalog, Operational Shell (Layout)
|
||||||
|
- Container & Navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테마 (Theme)
|
||||||
|
|
||||||
|
### 테마 전환 메커니즘
|
||||||
|
|
||||||
|
다크(기본)/라이트 2벌 테마를 CSS 변수 오버라이드 방식으로 지원한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[플래시 방지] index.html 인라인 스크립트
|
||||||
|
↓ localStorage → <html data-theme="dark|light">
|
||||||
|
[상태 관리] themeStore.ts (Zustand)
|
||||||
|
↓ toggleTheme() / setTheme()
|
||||||
|
[CSS 적용] base.css :root (dark) / [data-theme="light"] (light)
|
||||||
|
↓ CSS 변수 오버라이드
|
||||||
|
[UI 반영] 모든 컴포넌트가 var(--*) 참조 → 즉시 전환
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1단계 — FOUC 방지 (`index.html`)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'data-theme',
|
||||||
|
localStorage.getItem('wing-theme') || 'dark'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML 파싱 즉시 `data-theme` 속성을 설정하여 테마 깜빡임을 방지한다.
|
||||||
|
|
||||||
|
#### 2단계 — Zustand 스토어 (`themeStore.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ThemeMode = 'dark' | 'light';
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: ThemeMode;
|
||||||
|
toggleTheme: () => void; // dark ↔ light 토글
|
||||||
|
setTheme: (mode: ThemeMode) => void; // 직접 지정
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 초기값: `localStorage.getItem('wing-theme') || 'dark'`
|
||||||
|
- `toggleTheme()`: localStorage 갱신 → DOM 속성 변경 → Zustand 상태 갱신
|
||||||
|
|
||||||
|
#### 3단계 — CSS 변수 오버라이드 (`base.css`)
|
||||||
|
|
||||||
|
- `:root` — 다크 테마 (기본값)
|
||||||
|
- `[data-theme="light"]` — 라이트 테마 오버라이드
|
||||||
|
|
||||||
|
시맨틱 토큰(`--bg-*`, `--fg-*`, `--stroke-*`)만 테마별 오버라이드하고, 프리미티브 토큰(`--gray-*`, `--blue-*` 등)과 액센트 컬러(`--color-*`)는 테마 간 동일 값을 유지한다.
|
||||||
|
|
||||||
|
#### UI 진입점
|
||||||
|
|
||||||
|
TopBar 퀵메뉴에서 `toggleTheme()` 호출로 전환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Foundations
|
||||||
|
|
||||||
|
### 색상 (Color Palette)
|
||||||
|
|
||||||
|
#### 토큰 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
Primitive Tokens (정적) Semantic Tokens (테마 반응형)
|
||||||
|
────────────────────── ────────────────────────────
|
||||||
|
--gray-100 ~ --gray-1000 --bg-base, --bg-surface, ...
|
||||||
|
--blue-100 ~ --blue-1000 --fg-default, --fg-sub, ...
|
||||||
|
--red-100 ~ --red-1000 --stroke-default, --stroke-light
|
||||||
|
--green-100 ~ --green-1000 --hover-overlay, --dropdown-bg
|
||||||
|
--yellow-100 ~ --yellow-1000
|
||||||
|
--purple-100 ~ --purple-1000
|
||||||
|
Accent Tokens (테마 불변)
|
||||||
|
─────────────────────────
|
||||||
|
--color-accent, --color-info, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Semantic Colors — Background
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||||
|
|----------|----------------|------|-------|------|
|
||||||
|
| `--bg-base` | `bg-bg-base` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
|
||||||
|
| `--bg-surface` | `bg-bg-surface` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
|
||||||
|
| `--bg-elevated` | `bg-bg-elevated` | `#121929` | `#f1f5f9` | 테이블 헤더, 상위 요소 |
|
||||||
|
| `--bg-card` | `bg-bg-card` | `#1a2236` | `#ffffff` | 카드 배경 |
|
||||||
|
| `--bg-surface-hover` | `bg-bg-surface-hover` | `#1e2844` | `#e2e8f0` | 호버 상태 |
|
||||||
|
|
||||||
|
#### Semantic Colors — Foreground (Text)
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||||
|
|----------|----------------|------|-------|------|
|
||||||
|
| `--fg-default` | `text-fg` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 |
|
||||||
|
| `--fg-sub` | `text-fg-sub` | `#c0c8dc` | `#475569` | 보조 텍스트 |
|
||||||
|
| `--fg-disabled` | `text-fg-disabled` | `#9ba3b8` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||||
|
|
||||||
|
#### Semantic Colors — Border (Stroke)
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind 클래스 | Dark | Light | 용도 |
|
||||||
|
|----------|----------------|------|-------|------|
|
||||||
|
| `--stroke-default` | `border-stroke` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
|
||||||
|
| `--stroke-light` | `border-stroke-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
|
||||||
|
|
||||||
|
#### Semantic Colors — Overlay
|
||||||
|
|
||||||
|
| CSS 변수 | Dark | Light | 용도 |
|
||||||
|
|----------|------|-------|------|
|
||||||
|
| `--hover-overlay` | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.04)` | 호버 오버레이 |
|
||||||
|
| `--dropdown-bg` | `rgba(18,25,41,0.97)` | `rgba(255,255,255,0.97)` | 드롭다운 배경 |
|
||||||
|
|
||||||
|
#### Accent Colors (테마 불변)
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind 클래스 | Hex | 용도 |
|
||||||
|
|----------|----------------|-----|------|
|
||||||
|
| `--color-accent` | `text-color-accent` | `#06b6d4` | 주요 강조 (Cyan) |
|
||||||
|
| `--color-accent-muted` | `bg-color-accent-muted` | `#0e7490` / `#0891b2`(light) | 차분한 강조 (버튼 배경 등) |
|
||||||
|
| `--color-info` | `text-color-info` | `#3b82f6` | 정보, 링크 (Blue) |
|
||||||
|
| `--color-tertiary` | `text-color-tertiary` | `#a855f7` | 3차 강조 (Purple) |
|
||||||
|
| `--color-danger` | `text-color-danger` | `#ef4444` | 위험, 삭제 (Red) |
|
||||||
|
| `--color-warning` | `text-color-warning` | `#f97316` | 주의 (Orange) |
|
||||||
|
| `--color-caution` | `text-color-caution` | `#eab308` | 경고 (Yellow) |
|
||||||
|
| `--color-success` | `text-color-success` | `#22c55e` | 성공, 정상 (Green) |
|
||||||
|
| `--color-boom` | `text-color-boom` | `#f59e0b` | 오일붐 전용 (Amber) |
|
||||||
|
| `--color-boom-hover` | — | `#fbbf24` | 오일붐 호버 |
|
||||||
|
| `--color-navy` | `bg-color-navy` | `#1e40af` / `#1d4ed8`(light) | Navy 버튼 배경 |
|
||||||
|
| `--color-navy-hover` | `bg-color-navy-hover` | `#1d4ed8` / `#2563eb`(light) | Navy 호버 |
|
||||||
|
|
||||||
|
#### Static Colors
|
||||||
|
|
||||||
|
| CSS 변수 | Hex | 용도 |
|
||||||
|
|----------|-----|------|
|
||||||
|
| `--static-black` | `#131415` | 테마 무관 고정 검정 |
|
||||||
|
| `--static-white` | `#ffffff` | 테마 무관 고정 흰색 |
|
||||||
|
|
||||||
|
#### Primitive Colors
|
||||||
|
|
||||||
|
UI 전반에서 직접 참조하거나 시맨틱 토큰의 원천으로 사용하는 기본 팔레트. 100~1000 (10단계).
|
||||||
|
|
||||||
|
**Gray**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#f1f5f9` | `#e2e8f0` | `#cbd5e1` | `#94a3b8` | `#64748b` | `#475569` | `#334155` | `#1e293b` | `#0f172a` | `#020617` |
|
||||||
|
|
||||||
|
**Blue**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
|
||||||
|
|
||||||
|
**Green**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
|
||||||
|
|
||||||
|
**Yellow**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
|
||||||
|
|
||||||
|
**Red**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
|
||||||
|
|
||||||
|
**Purple**
|
||||||
|
|
||||||
|
| 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 |
|
||||||
|
|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|
|
||||||
|
| `#f3e8ff` | `#e9d5ff` | `#d8b4fe` | `#c084fc` | `#a855f7` | `#9333ea` | `#7e22ce` | `#6b21a8` | `#581c87` | `#3b0764` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 타이포그래피 (Typography)
|
||||||
|
|
||||||
|
#### Font Family
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind 클래스 | 용도 |
|
||||||
|
|----------|----------------|------|
|
||||||
|
| `--font-korean` | `font-korean` | 기본 UI 텍스트, 한국어/영문 콘텐츠 |
|
||||||
|
| `--font-mono` | `font-mono` | 좌표, 수치, 데이터 값 |
|
||||||
|
| — | `font-sans` | body 기본 (PretendardGOV) |
|
||||||
|
|
||||||
|
> 모든 폰트 패밀리가 `PretendardGOV` 우선 스택으로 통일.
|
||||||
|
> `@font-face`: Regular(400), Medium(500), SemiBold(600), Bold(700) — `/fonts/PretendardGOV-*.otf`
|
||||||
|
|
||||||
|
#### Typography Categories
|
||||||
|
|
||||||
|
5가지 용도 카테고리로 타이포그래피를 구성합니다.
|
||||||
|
|
||||||
|
| 카테고리 | 토큰 | 설명 |
|
||||||
|
|----------|------|------|
|
||||||
|
| **Display** | Display 1, Display 2, Display 3 | 배너, 마케팅 등 최대 크기 텍스트 |
|
||||||
|
| **Heading** | Heading 1, Heading 2, Heading 3 | 페이지/모듈 단위 제목, 계층 설정 |
|
||||||
|
| **Body** | Body 1, Body 2, Caption | 본문/콘텐츠 텍스트 |
|
||||||
|
| **Navigation** | Title 1~6 | 사이트 내 이정표 역할 (패널 제목, 탭 버튼, 메뉴 항목 등) |
|
||||||
|
| **Label** | Label 1, Label 2 | 컴포넌트 label, placeholder, 버튼 텍스트 |
|
||||||
|
|
||||||
|
> Navigation 카테고리의 토큰은 CSS 변수명 `--font-size-title-*` / Tailwind `text-title-*`을 유지합니다.
|
||||||
|
|
||||||
|
#### Font Size Tokens
|
||||||
|
|
||||||
|
CSS 변수와 Tailwind 유틸리티가 1:1 매핑된 타이포그래피 스케일. `text-*` 클래스 사용 시 font-size, line-height, letter-spacing이 함께 적용됩니다.
|
||||||
|
|
||||||
|
**Display** — 배너, 마케팅, 랜딩 영역
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||||
|
|----------|---------|-----|--------|--------|---------|
|
||||||
|
| `--font-size-display-1` | `text-display-1` | 60px | 700 | 1.3 | 0.06em |
|
||||||
|
| `--font-size-display-2` | `text-display-2` | 40px | 700 | 1.3 | 0.06em |
|
||||||
|
| `--font-size-display-3` | `text-display-3` | 36px | 500 | 1.4 | 0.06em |
|
||||||
|
|
||||||
|
**Heading** — 페이지/모듈 제목
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||||
|
|----------|---------|-----|--------|--------|---------|
|
||||||
|
| `--font-size-heading-1` | `text-heading-1` | 32px | 700 | 1.4 | 0.02em |
|
||||||
|
| `--font-size-heading-2` | `text-heading-2` | 24px | 700 | 1.4 | 0.02em |
|
||||||
|
| `--font-size-heading-3` | `text-heading-3` | 22px | 500 | 1.4 | 0.02em |
|
||||||
|
|
||||||
|
**Body** — 본문/콘텐츠
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||||
|
|----------|---------|-----|--------|--------|---------|
|
||||||
|
| `--font-size-body-1` | `text-body-1` | 14px | 400 | 1.6 | 0em |
|
||||||
|
| `--font-size-body-2` | `text-body-2` | 13px | 400 | 1.6 | 0em |
|
||||||
|
| `--font-size-caption` | `text-caption` | 11px | 400 | 1.5 | 0em |
|
||||||
|
|
||||||
|
**Navigation** — 패널 제목, 탭 버튼, 메뉴 항목, 소형 네비게이션
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||||
|
|----------|---------|-----|--------|--------|---------|
|
||||||
|
| `--font-size-title-1` | `text-title-1` | 18px | 700 | 1.5 | 0.02em |
|
||||||
|
| `--font-size-title-2` | `text-title-2` | 16px | 500 | 1.5 | 0.02em |
|
||||||
|
| `--font-size-title-3` | `text-title-3` | 14px | 500 | 1.5 | 0.02em |
|
||||||
|
| `--font-size-title-4` | `text-title-4` | 13px | 500 | 1.5 | 0.02em |
|
||||||
|
| `--font-size-title-5` | `text-title-5` | 12px | 500 | 1.5 | 0.02em |
|
||||||
|
| `--font-size-title-6` | `text-title-6` | 11px | 500 | 1.5 | 0.02em |
|
||||||
|
|
||||||
|
**Label** — 레이블, 플레이스홀더, 버튼
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | px | Weight | Line-H | Spacing |
|
||||||
|
|----------|---------|-----|--------|--------|---------|
|
||||||
|
| `--font-size-label-1` | `text-label-1` | 12px | 500 | 1.5 | 0.04em |
|
||||||
|
| `--font-size-label-2` | `text-label-2` | 11px | 500 | 1.5 | 0.04em |
|
||||||
|
|
||||||
|
#### Font Weight Tokens
|
||||||
|
|
||||||
|
| CSS 변수 | 값 | 용도 |
|
||||||
|
|----------|-----|------|
|
||||||
|
| `--font-weight-thin` | 300 | 얇은 텍스트 |
|
||||||
|
| `--font-weight-regular` | 400 | 본문 기본 |
|
||||||
|
| `--font-weight-medium` | 500 | 중간 강조 |
|
||||||
|
| `--font-weight-bold` | 700 | 제목, 강조 |
|
||||||
|
|
||||||
|
#### Line Height Tokens
|
||||||
|
|
||||||
|
| CSS 변수 | 값 | 용도 |
|
||||||
|
|----------|-----|------|
|
||||||
|
| `--line-height-tight` | 1.3 | Display |
|
||||||
|
| `--line-height-snug` | 1.4 | Heading |
|
||||||
|
| `--line-height-normal` | 1.5 | Navigation, Label, Caption |
|
||||||
|
| `--line-height-relaxed` | 1.6 | Body |
|
||||||
|
|
||||||
|
#### Letter Spacing Tokens
|
||||||
|
|
||||||
|
카테고리별 자간 토큰. `text-*` 클래스에 자동 포함되며, `tracking-*` 클래스로 개별 사용도 가능합니다.
|
||||||
|
|
||||||
|
| CSS 변수 | Tailwind | 값 | 카테고리 |
|
||||||
|
|----------|---------|-----|----------|
|
||||||
|
| `--letter-spacing-display` | `tracking-display` | 0.06em | Display |
|
||||||
|
| `--letter-spacing-heading` | `tracking-heading` | 0.02em | Heading |
|
||||||
|
| `--letter-spacing-body` | `tracking-body` | 0em | Body |
|
||||||
|
| `--letter-spacing-navigation` | `tracking-navigation` | 0.02em | Navigation |
|
||||||
|
| `--letter-spacing-label` | `tracking-label` | 0.04em | Label |
|
||||||
|
|
||||||
|
#### Typography Tokens (`.wing-*` 클래스)
|
||||||
|
|
||||||
|
| 클래스 | Size | Font | Weight | 용도 | 샘플 |
|
||||||
|
|--------|------|------|--------|------|------|
|
||||||
|
| `.wing-title` | 15px | font-korean | Bold (700) | 패널 제목 | 확산 예측 시뮬레이션 |
|
||||||
|
| `.wing-section-header` | 13px | font-korean | Bold (700) | 섹션 헤더 | 기본 정보 입력 |
|
||||||
|
| `.wing-label` | 11px | font-korean | Semibold (600) | 필드 레이블 | 유출량 (kL) |
|
||||||
|
| `.wing-btn` | 11px | font-korean | Semibold (600) | 버튼 텍스트 | 시뮬레이션 실행 |
|
||||||
|
| `.wing-value` | 11px | font-mono | Semibold (600) | 수치 / 데이터 값 | 35.1284° N, 129.0598° E |
|
||||||
|
| `.wing-input` | 11px | font-korean | Normal (400) | 입력 필드 | 서해 대산항 인근 해역 |
|
||||||
|
| `.wing-section-desc` | 10px | font-korean | Normal (400) | 섹션 설명 | 예측 결과는 기상 조건에 따라... |
|
||||||
|
| `.wing-subtitle` | 10px | font-korean | Normal (400) | 보조 설명 | 최근 업데이트: 2026-03-24 09:00 KST |
|
||||||
|
| `.wing-meta` | 9px | font-korean | Normal (400) | 메타 정보 | v2.1 \| 해양환경공단 |
|
||||||
|
| `.wing-badge` | 9px | font-korean | Bold (700) | 뱃지 / 태그 | 진행중 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
#### Radius Tokens
|
||||||
|
|
||||||
|
| Tailwind 클래스 | 값 | 비고 |
|
||||||
|
|-----------------|-----|------|
|
||||||
|
| `rounded-sm` | 6px | **Custom** (Tailwind 기본값 오버라이드) |
|
||||||
|
| `rounded` | 4px (0.25rem) | Tailwind 기본 |
|
||||||
|
| `rounded-md` | 10px | **Custom** (Tailwind 기본값 오버라이드) |
|
||||||
|
| `rounded-lg` | 8px (0.5rem) | Tailwind 기본 |
|
||||||
|
| `rounded-xl` | 12px (0.75rem) | Tailwind 기본 |
|
||||||
|
| `rounded-2xl` | 16px (1rem) | Tailwind 기본 |
|
||||||
|
| `rounded-full` | 9999px | Tailwind 기본 |
|
||||||
|
|
||||||
|
#### 컴포넌트 매핑
|
||||||
|
|
||||||
|
| Radius | 값 | 적용 컴포넌트 |
|
||||||
|
|--------|-----|-------------|
|
||||||
|
| `rounded-sm` | 6px | `.wing-btn`, `.wing-input`, `.wing-card-sm` |
|
||||||
|
| `rounded` | 4px | `.wing-badge` |
|
||||||
|
| `rounded-md` | 10px | `.wing-card`, `.wing-section`, `.wing-tab` |
|
||||||
|
| `rounded-lg` | 8px | `.wing-tab-bar` |
|
||||||
|
| `rounded-xl` | 12px | `.wing-modal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 레이아웃 (Layout)
|
||||||
|
|
||||||
|
#### Breakpoints
|
||||||
|
|
||||||
|
| Name | Prefix | Min Width | 사용 | 비고 |
|
||||||
|
|------|--------|-----------|------|------|
|
||||||
|
| sm | `sm:` | 640px | - | |
|
||||||
|
| md | `md:` | 768px | - | |
|
||||||
|
| lg | `lg:` | 1024px | - | |
|
||||||
|
| xl | `xl:` | 1280px | **사용 중** | TopBar 탭 레이블/아이콘 토글 |
|
||||||
|
| 2xl | `2xl:` | 1536px | - | |
|
||||||
|
|
||||||
|
> Desktop(≥ 1280px)만 지원. Tablet/Mobile 미지원.
|
||||||
|
|
||||||
|
| Device | Width | Columns | Gutter | Margin |
|
||||||
|
|--------|-------|---------|--------|--------|
|
||||||
|
| Desktop | ≥ 1280px | flex 기반 가변 | gap-2 ~ gap-6 | px-5 ~ px-8 |
|
||||||
|
| Tablet | 768px ~ 1279px | - | - | - |
|
||||||
|
| Mobile | < 768px | - | - | - |
|
||||||
|
|
||||||
|
#### Spacing Scale
|
||||||
|
|
||||||
|
| Scale | rem | px | 용도 |
|
||||||
|
|-------|-----|----|------|
|
||||||
|
| 0.5 | 0.125rem | 2px | 미세 간격 |
|
||||||
|
| 1 | 0.25rem | 4px | 최소 간격 (gap-1) |
|
||||||
|
| 1.5 | 0.375rem | 6px | 컴팩트 간격 (gap-1.5) |
|
||||||
|
| 2 | 0.5rem | 8px | 기본 간격 (gap-2, p-2) |
|
||||||
|
| 2.5 | 0.625rem | 10px | 중간 간격 |
|
||||||
|
| 3 | 0.75rem | 12px | 표준 간격 (gap-3, p-3) |
|
||||||
|
| 4 | 1rem | 16px | 넓은 간격 (p-4, gap-4) |
|
||||||
|
| 5 | 1.25rem | 20px | 패널 패딩 (px-5, py-5) |
|
||||||
|
| 6 | 1.5rem | 24px | 섹션 간격 (gap-6, p-6) |
|
||||||
|
| 8 | 2rem | 32px | 큰 간격 (px-8, gap-8) |
|
||||||
|
| 16 | 4rem | 64px | 최대 간격 |
|
||||||
|
|
||||||
|
#### Z-Index Layers
|
||||||
|
|
||||||
|
| Layer | z-index | Color | 설명 |
|
||||||
|
|-------|---------|-------|------|
|
||||||
|
| Tooltip | 60 | `#a855f7` | 툴팁, 드롭다운 메뉴 |
|
||||||
|
| Popup | 50 | `#f97316` | 팝업, 지도 오버레이 |
|
||||||
|
| Modal | 40 | `#ef4444` | 모달 다이얼로그, 백드롭 |
|
||||||
|
| TopBar | 30 | `#3b82f6` | 상단 네비게이션 바 |
|
||||||
|
| Sidebar | 20 | `#06b6d4` | 사이드바, 패널 |
|
||||||
|
| Content | 10 | `#22c55e` | 메인 콘텐츠 영역 |
|
||||||
|
| Base | 0 | `#8690a6` | 기본 레이어, 배경 |
|
||||||
|
|
||||||
|
#### App Shell Classes
|
||||||
|
|
||||||
|
| 클래스 | 역할 | Tailwind 스타일 |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| `.wing-panel` | 탭 콘텐츠 패널 | `flex flex-col h-full overflow-hidden` |
|
||||||
|
| `.wing-panel-scroll` | 패널 내 스크롤 영역 | `flex-1 overflow-y-auto` |
|
||||||
|
| `.wing-header-bar` | 패널 헤더 | `flex items-center justify-between shrink-0 px-5 border-b` |
|
||||||
|
| `.wing-sidebar` | 사이드바 | `flex flex-col border-r border-border` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS 레이어 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
index.css
|
||||||
|
├── @import base.css → @layer base (CSS 변수, reset, body, @font-face)
|
||||||
|
├── @import components.css → @layer components (MapLibre, scrollbar, prd-*, combo-*)
|
||||||
|
├── @import wing.css → @layer components (wing-* 디자인 시스템 클래스)
|
||||||
|
├── @tailwind base
|
||||||
|
├── @tailwind components
|
||||||
|
└── @tailwind utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailwind 시맨틱 토큰 매핑 요약
|
||||||
|
|
||||||
|
| 카테고리 | CSS 변수 | Tailwind 클래스 예시 |
|
||||||
|
|---------|----------|---------------------|
|
||||||
|
| Background | `--bg-base` ~ `--bg-surface-hover` | `bg-bg-base`, `bg-bg-surface`, ... |
|
||||||
|
| Foreground | `--fg-default`, `--fg-sub`, `--fg-disabled` | `text-fg`, `text-fg-sub`, `text-fg-disabled` |
|
||||||
|
| Border | `--stroke-default`, `--stroke-light` | `border-stroke`, `border-stroke-light` |
|
||||||
|
| Accent | `--color-accent` ~ `--color-success` | `text-color-accent`, `bg-color-info`, ... |
|
||||||
|
| Font Size | `--font-size-display-1` ~ `--font-size-caption` | `text-display-1`, `text-body-1`, ... |
|
||||||
|
| Font Family | `--font-korean`, `--font-mono` | `font-korean`, `font-mono`, `font-sans` |
|
||||||
@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
|
|||||||
| Alias | 실제 경로 | 용도 |
|
| Alias | 실제 경로 | 용도 |
|
||||||
|-------|----------|------|
|
|-------|----------|------|
|
||||||
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
||||||
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
|
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useAuth } from '@common/hooks/useAuth';
|
import { useAuth } from '@common/hooks/useAuth';
|
||||||
import OilSpillView from '@tabs/prediction/components/OilSpillView';
|
import OilSpillView from '@components/prediction/components/OilSpillView';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
|
|||||||
git status
|
git status
|
||||||
|
|
||||||
# 스테이징 (파일 지정)
|
# 스테이징 (파일 지정)
|
||||||
git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||||
git add backend/src/incidents/incidentService.ts
|
git add backend/src/incidents/incidentService.ts
|
||||||
|
|
||||||
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
||||||
@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \
|
|||||||
- 변경 내용을 1~3줄로 요약
|
- 변경 내용을 1~3줄로 요약
|
||||||
|
|
||||||
## 변경 파일
|
## 변경 파일
|
||||||
- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규)
|
- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규)
|
||||||
- `backend/src/incidents/incidentService.ts` (수정)
|
- `backend/src/incidents/incidentService.ts` (수정)
|
||||||
|
|
||||||
## Test plan
|
## Test plan
|
||||||
@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg
|
|||||||
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
||||||
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
||||||
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
||||||
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
||||||
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
||||||
|
|
||||||
#### Step 2. 브랜치 생성
|
#### Step 2. 브랜치 생성
|
||||||
|
|
||||||
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
|
|||||||
**Frontend - API:**
|
**Frontend - API:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/incidents/services/incidentsApi.ts
|
// frontend/src/components/incidents/services/incidentsApi.ts
|
||||||
export async function fetchIncidentById(id: number) {
|
export async function fetchIncidentById(id: number) {
|
||||||
const { data } = await api.get(`/incidents/${id}`);
|
const { data } = await api.get(`/incidents/${id}`);
|
||||||
return data;
|
return data;
|
||||||
@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) {
|
|||||||
**Frontend - Component:**
|
**Frontend - Component:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
// frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||||
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['incident', incidentId],
|
queryKey: ['incident', incidentId],
|
||||||
@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit
|
|||||||
#### Step 5. 커밋 & 푸시
|
#### Step 5. 커밋 & 푸시
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/src/incidents/ frontend/src/tabs/incidents/
|
git add backend/src/incidents/ frontend/src/components/incidents/
|
||||||
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
||||||
# pre-commit: TypeScript OK, ESLint OK
|
# pre-commit: TypeScript OK, ESLint OK
|
||||||
# commit-msg: Conventional Commits OK
|
# commit-msg: Conventional Commits OK
|
||||||
|
|||||||
@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
|
|
||||||
| 단계 | 파일 | 작업 |
|
| 단계 | 파일 | 작업 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
||||||
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
||||||
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
|
| | `frontend/src/components/{탭명}/index.ts` | re-export |
|
||||||
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
||||||
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
||||||
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
||||||
@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
### 1-1. 디렉토리 구조
|
### 1-1. 디렉토리 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/
|
frontend/src/components/{탭명}/
|
||||||
components/
|
components/
|
||||||
{TabName}View.tsx # 메인 뷰 컴포넌트
|
{TabName}View.tsx # 메인 뷰 컴포넌트
|
||||||
services/
|
services/
|
||||||
@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/
|
|||||||
서브탭이 **없는** 간단한 탭:
|
서브탭이 **없는** 간단한 탭:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
export function MonitoringView() {
|
export function MonitoringView() {
|
||||||
return (
|
return (
|
||||||
@ -91,7 +91,7 @@ export function MonitoringView() {
|
|||||||
서브탭이 **있는** 탭 (board 패턴):
|
서브탭이 **있는** 탭 (board 패턴):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ export function MonitoringView() {
|
|||||||
### 1-3. API 서비스 (보일러플레이트)
|
### 1-3. API 서비스 (보일러플레이트)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/monitoring/services/monitoringApi.ts
|
// frontend/src/components/monitoring/services/monitoringApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{
|
|||||||
### 1-4. index.ts (re-export)
|
### 1-4. index.ts (re-export)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/monitoring/index.ts
|
// frontend/src/components/monitoring/index.ts
|
||||||
|
|
||||||
export { MonitoringView } from './components/MonitoringView';
|
export { MonitoringView } from './components/MonitoringView';
|
||||||
```
|
```
|
||||||
@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad
|
|||||||
// frontend/src/App.tsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
// 1. import 추가
|
// 1. import 추가
|
||||||
import { MonitoringView } from '@tabs/monitoring';
|
import { MonitoringView } from '@components/monitoring';
|
||||||
|
|
||||||
// 2. renderView switch에 case 추가
|
// 2. renderView switch에 case 추가
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
|
|||||||
### 1단계: 프론트엔드 파일 생성
|
### 1단계: 프론트엔드 파일 생성
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p frontend/src/tabs/monitoring/components
|
mkdir -p frontend/src/components/monitoring/components
|
||||||
mkdir -p frontend/src/tabs/monitoring/services
|
mkdir -p frontend/src/components/monitoring/services
|
||||||
```
|
```
|
||||||
|
|
||||||
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
|
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
|
||||||
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
|
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
|
||||||
- `frontend/src/tabs/monitoring/index.ts` 생성
|
- `frontend/src/components/monitoring/index.ts` 생성
|
||||||
|
|
||||||
### 2단계: 프론트엔드 기존 파일 수정
|
### 2단계: 프론트엔드 기존 파일 수정
|
||||||
|
|
||||||
@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services
|
|||||||
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
||||||
|
|
||||||
--- frontend/src/App.tsx
|
--- frontend/src/App.tsx
|
||||||
+ import { MonitoringView } from '@tabs/monitoring';
|
+ import { MonitoringView } from '@components/monitoring';
|
||||||
// renderView switch 내:
|
// renderView switch 내:
|
||||||
+ case 'monitoring':
|
+ case 'monitoring':
|
||||||
+ return <MonitoringView />;
|
+ return <MonitoringView />;
|
||||||
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
|
|||||||
## 체크리스트
|
## 체크리스트
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
|
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
|
||||||
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
|
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
|
||||||
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
|
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
|
||||||
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
||||||
- [ ] `App.tsx` import + renderView switch case 추가
|
- [ ] `App.tsx` import + renderView switch case 추가
|
||||||
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud
|
|||||||
```bash
|
```bash
|
||||||
# 탭 디렉토리 내 mock 데이터 검색
|
# 탭 디렉토리 내 mock 데이터 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
||||||
frontend/src/tabs/{탭명}/
|
frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
|
|||||||
|
|
||||||
**1) API 서비스 파일 생성:**
|
**1) API 서비스 파일 생성:**
|
||||||
|
|
||||||
파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts`
|
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 해당 탭 디렉토리에서 mock 잔여 검색
|
# 해당 탭 디렉토리에서 mock 잔여 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
|
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
||||||
grep -rn "{탭명}" frontend/src/common/mock/
|
grep -rn "{탭명}" frontend/src/common/mock/
|
||||||
@ -497,7 +497,7 @@ git status
|
|||||||
git add database/migration/017_{탭명}.sql
|
git add database/migration/017_{탭명}.sql
|
||||||
git add backend/src/{탭명}/
|
git add backend/src/{탭명}/
|
||||||
git add backend/src/server.ts
|
git add backend/src/server.ts
|
||||||
git add frontend/src/tabs/{탭명}/
|
git add frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 커밋 (Conventional Commits, 한국어)
|
# 커밋 (Conventional Commits, 한국어)
|
||||||
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
||||||
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 불충분 -- 탭 디렉토리만 검색
|
# 불충분 -- 탭 디렉토리만 검색
|
||||||
grep -rn "mock" frontend/src/tabs/{탭명}/
|
grep -rn "mock" frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 반드시 공통 디렉토리도 검색
|
# 반드시 공통 디렉토리도 검색
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -780,8 +780,8 @@ export async function fetchCategories(): Promise<Category[]> {
|
|||||||
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
||||||
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
||||||
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
||||||
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
|
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외)
|
||||||
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
|
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
|
||||||
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
||||||
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
||||||
- [ ] 커밋 + 푸시 + MR 생성
|
- [ ] 커밋 + 푸시 + MR 생성
|
||||||
|
|||||||
@ -1,191 +0,0 @@
|
|||||||
# 확산 예측 기능 가이드
|
|
||||||
|
|
||||||
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 아키텍처 개요
|
|
||||||
|
|
||||||
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
|
|
||||||
|
|
||||||
```
|
|
||||||
[프론트] 실행 버튼
|
|
||||||
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
|
|
||||||
→ "분석 중..." UI 표시
|
|
||||||
→ 3초마다 GET /api/simulation/status/:execSn 폴링
|
|
||||||
|
|
||||||
[Express 백엔드]
|
|
||||||
→ PRED_EXEC INSERT (PENDING)
|
|
||||||
→ POST Python /run-model 즉시 { job_id } 수신
|
|
||||||
→ 응답 즉시 반환 (프론트 블록 없음)
|
|
||||||
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
|
|
||||||
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
|
|
||||||
|
|
||||||
[Python FastAPI :5003]
|
|
||||||
→ 동시 처리 초과 시 503 즉시 반환
|
|
||||||
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
|
|
||||||
→ NC 결과 → JSON 변환 → 상태 DONE
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. DB 스키마 (PRED_EXEC)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
PRED_EXEC_SN SERIAL PRIMARY KEY
|
|
||||||
ACDNT_SN INTEGER NOT NULL -- 사고 FK
|
|
||||||
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
|
|
||||||
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
|
|
||||||
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
|
|
||||||
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
|
|
||||||
-- PENDING | RUNNING | COMPLETED | FAILED
|
|
||||||
BGNG_DTM TIMESTAMPTZ
|
|
||||||
CMPL_DTM TIMESTAMPTZ
|
|
||||||
REQD_SEC INTEGER
|
|
||||||
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
|
|
||||||
ERR_MSG TEXT
|
|
||||||
```
|
|
||||||
|
|
||||||
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Python FastAPI 엔드포인트 (포트 5003)
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
|
|
||||||
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
|
|
||||||
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
|
|
||||||
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
|
|
||||||
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
|
|
||||||
|
|
||||||
### POST /run-model 입력 파라미터
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
|
|
||||||
"runTime": 72, // 예측 시간 (시간)
|
|
||||||
"matTy": "CRUDE OIL", // OpenDrift 유류명
|
|
||||||
"matVol": 100.0, // 시간당 유출량 (m³/hr)
|
|
||||||
"lon": 126.1,
|
|
||||||
"lat": 36.6,
|
|
||||||
"spillTime": 12, // 유출 지속 시간 (0=순간)
|
|
||||||
"name": "EXPC_1710000000000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 유류 코드 매핑 (DB → OpenDrift)
|
|
||||||
|
|
||||||
| DB SPIL_MAT_CD | OpenDrift 이름 |
|
|
||||||
|---------------|---------------|
|
|
||||||
| CRUD | CRUDE OIL |
|
|
||||||
| DSEL | DIESEL |
|
|
||||||
| BNKR | BUNKER |
|
|
||||||
| HEFO | IFO 180 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Express 백엔드 주요 엔드포인트
|
|
||||||
|
|
||||||
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
|
|
||||||
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
|
|
||||||
|
|
||||||
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
|
|
||||||
|
|
||||||
- `fetchPredictionList()` — PRED_EXEC 목록 조회
|
|
||||||
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 프론트엔드 주요 파일
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
|
|
||||||
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
|
|
||||||
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
|
|
||||||
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
|
|
||||||
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
|
|
||||||
|
|
||||||
### 핵심 타입 (predictionApi.ts)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface HydrGrid {
|
|
||||||
lonInterval: number[];
|
|
||||||
latInterval: number[];
|
|
||||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
|
||||||
rows: number; cols: number;
|
|
||||||
}
|
|
||||||
interface HydrDataStep {
|
|
||||||
value: [number[][], number[][]]; // [u_2d, v_2d]
|
|
||||||
grid: HydrGrid;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 폴링 훅 패턴
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['simulationStatus', execSn],
|
|
||||||
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
|
|
||||||
enabled: execSn !== null,
|
|
||||||
refetchInterval: (data) =>
|
|
||||||
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Python 코드 위치 (prediction/)
|
|
||||||
|
|
||||||
```
|
|
||||||
prediction/opendrift/
|
|
||||||
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
|
|
||||||
├── config.py 경로 설정 (수정 필요: 환경변수화)
|
|
||||||
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
|
|
||||||
├── coastline/ TN_SHORLINE.shp (한국 해안선)
|
|
||||||
├── startup.sh / shutdown.sh
|
|
||||||
├── .env.example 환경변수 샘플
|
|
||||||
└── environment-opendrift.yml conda 환경 재현용
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 환경변수
|
|
||||||
|
|
||||||
### backend/.env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHON_API_URL=http://localhost:5003
|
|
||||||
```
|
|
||||||
|
|
||||||
### prediction/opendrift/.env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
|
|
||||||
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
|
|
||||||
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 위험 요소
|
|
||||||
|
|
||||||
| 위험 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
|
|
||||||
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
|
|
||||||
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
|
|
||||||
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 관련 문서
|
|
||||||
|
|
||||||
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
|
|
||||||
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직
|
|
||||||
@ -66,7 +66,7 @@ wing/
|
|||||||
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
||||||
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
||||||
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
||||||
│ └── tabs/ @tabs/ alias (11개 탭)
|
│ └── tabs/ @components/ alias (11개 탭)
|
||||||
│ ├── prediction/ 유류 확산 예측
|
│ ├── prediction/ 유류 확산 예측
|
||||||
│ ├── hns/ HNS 분석
|
│ ├── hns/ HNS 분석
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -103,7 +103,7 @@ wing/
|
|||||||
| Alias | 경로 |
|
| Alias | 경로 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `@common/*` | `src/common/*` |
|
| `@common/*` | `src/common/*` |
|
||||||
| `@tabs/*` | `src/tabs/*` |
|
| `@components/*` | `src/components/*` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,271 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-17]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
|
||||||
|
|
||||||
|
## [2026-04-16]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
|
||||||
|
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
|
||||||
|
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
|
||||||
|
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
|
||||||
|
- HNS: 분석 생성 시 `acdntSn` 연결 지원
|
||||||
|
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
|
||||||
|
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
|
||||||
|
|
||||||
|
## [2026-04-15]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||||
|
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||||
|
- aerial 이미지 분석 API 기본 URL 변경
|
||||||
|
|
||||||
|
## [2026-04-14]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
|
||||||
|
- 관리자: 비식별화조치 메뉴 및 패널 추가
|
||||||
|
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
|
||||||
|
|
||||||
|
## [2026-04-13]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 사고별 이미지 분석 데이터 조회 API 추가
|
||||||
|
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
|
||||||
|
- 사고 마커 클릭 팝업 디자인 리뉴얼
|
||||||
|
- 지도에 필터링된 사고만 표시되도록 개선
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 이미지 분석 시 사고명 파라미터 지원
|
||||||
|
- 기본 예측시간 48시간 → 6시간으로 변경
|
||||||
|
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
|
||||||
|
- OpenDrift 유종 매핑 수정 (원유, 등유)
|
||||||
|
- 소량 유출량 과학적 표기법으로 표시
|
||||||
|
|
||||||
|
## [2026-04-09]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일)
|
||||||
|
- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계)
|
||||||
|
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||||
|
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
|
||||||
|
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가
|
||||||
|
- 해양 오염물질 배출규정 구역 판별 기능 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 지도: 항상 라이트 모드로 고정 (앱 다크 모드와 무관)
|
||||||
|
- 지도: lightMode prop 제거, useThemeStore 기반 테마 전환 통합
|
||||||
|
- 레이어 색상 상태를 OilSpillView로 끌어올림
|
||||||
|
- 대한민국 해리 GeoJSON 데이터 갱신
|
||||||
|
|
||||||
|
## [2026-04-02]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템 폰트 및 시맨틱 토큰 전면 적용
|
||||||
|
- HNS 탭: HNSTheoryView, HNSSubstanceView, HNSScenarioView, HNSLeftPanel, HNSRightPanel, HNSRecalcModal, HNSAnalysisListTable, HNSView
|
||||||
|
- 예측 탭: OilSpillTheoryView, OilSpillView, BoomDeploymentTheoryView, AnalysisListTable
|
||||||
|
- 구조 탭: RescueView
|
||||||
|
- 하드코딩 색상(#hex, rgba) → CSS 변수 전환, 그라데이션 → 단색, fontFamily/fontSize → Tailwind 토큰
|
||||||
|
|
||||||
|
## [2026-04-01]
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 지도: S57 ENC 오버레이 스타일 로드 완료 대기 후 레이어 추가
|
||||||
|
- 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합
|
||||||
|
- 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가
|
||||||
|
- 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응)
|
||||||
|
|
||||||
|
## [2026-03-31]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가
|
||||||
|
- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도)
|
||||||
|
- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가
|
||||||
|
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
|
||||||
|
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off)
|
||||||
|
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
|
||||||
|
- PretendardGOV 폰트 적용
|
||||||
|
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
||||||
|
|
||||||
|
## [2026-03-30]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 지도: VWorld 위성타일 백엔드 프록시 추가 — API 키를 서버에서 관리하고 CORS 우회
|
||||||
|
|
||||||
|
## [2026-03-27]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 역추적: 사용자가 유출 추정 시각/분석 범위/탐색 반경을 직접 입력하는 분석 파라미터 UI 구현
|
||||||
|
- 역추적: AIS 기반 선박 항적 API 연동 및 가중치 위험도 점수 산정 엔진 (backtrackAnalysisService)
|
||||||
|
- 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성
|
||||||
|
- 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시
|
||||||
|
- DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스)
|
||||||
|
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
|
||||||
|
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
|
||||||
|
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
|
||||||
|
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요)
|
||||||
|
|
||||||
|
## [2026-03-26]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 보고서: 조위/기상(oil-tide) 섹션에 실데이터 렌더링 추가 (풍향/풍속·파고·수온·유향 등)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
|
||||||
|
- 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선)
|
||||||
|
|
||||||
|
## [2026-03-25]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회)
|
||||||
|
- DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn)
|
||||||
|
- 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE)
|
||||||
|
- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑)
|
||||||
|
- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin)
|
||||||
|
- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등)
|
||||||
|
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
|
||||||
|
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
|
||||||
|
|
||||||
|
## [2026-03-24]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- Stitch MCP 기반 디자인 시스템 카탈로그 페이지 (/design)
|
||||||
|
- react-router-dom 도입, BrowserRouter 래핑
|
||||||
|
- SVG 아이콘 에셋 19종 추가
|
||||||
|
- @/ path alias 추가
|
||||||
|
- 디자인: Components 탭 추가 (Button, TextField, Overview 페이지)
|
||||||
|
- 관리자: 수거인력 패널 및 선박모니터링 패널 추가
|
||||||
|
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
|
||||||
|
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||||
|
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
||||||
|
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
||||||
|
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||||
|
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||||
|
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||||
|
- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel)
|
||||||
|
- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인: 색상 팔레트 컨텐츠 개선 + base.css 확장
|
||||||
|
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
||||||
|
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||||
|
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||||
|
- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능)
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
||||||
|
|
||||||
|
## [2026-03-20]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
|
||||||
|
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||||
|
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
|
||||||
|
- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가
|
||||||
|
- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시
|
||||||
|
- 항공 방제: 위성 요청 취소 기능 추가
|
||||||
|
- 항공 방제: 위성 요청 목록/히스토리 지도 탭 분리
|
||||||
|
- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
|
||||||
|
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
|
||||||
|
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
|
||||||
|
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||||
|
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
|
||||||
|
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
|
||||||
|
- Pre-SCAT 해안조사 UI 개선
|
||||||
|
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
||||||
|
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정
|
||||||
|
- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정
|
||||||
|
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
|
||||||
|
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
|
||||||
|
- WeatherRightPanel 중복 코드 정리
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- PREDICTION-GUIDE.md 삭제
|
||||||
|
|
||||||
|
## [2026-03-18]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 관리자: 방제장비 현황 패널 (CleanupEquipPanel) — 관할청·유형별 필터, 자산 수량 조회
|
||||||
|
- 관리자: 자산 현행화 업로드 패널 (AssetUploadPanel) — 엑셀/CSV 드래그 드롭 업로드
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- trajectory API 모델별 windData/hydrData 분리 반환
|
||||||
|
- 예측 서비스(predictionService) 개선
|
||||||
|
- 보고서: 유출유 확산 지도 패널 및 보고서 생성기 개선
|
||||||
|
- 관리자: 권한/메뉴 구성 업데이트, AdminView 패널 등록
|
||||||
|
- prediction/image 이미지 분석 서버 분리 (디렉토리 제거)
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- DB: monitor 권한 트리 마이그레이션(022) 추가, auth_init 갱신
|
||||||
|
|
||||||
|
## [2026-03-17]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합)
|
||||||
|
|
||||||
|
## [2026-03-16]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 보고서 확산예측 지도 캡처 기능 (OilSpreadMapPanel, MAP_CAPTURE_IMG DB 컬럼)
|
||||||
|
- 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결
|
||||||
|
- CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI
|
||||||
|
- KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선
|
||||||
|
- 사용자 매뉴얼 팝업 기능 추가
|
||||||
|
- 확산예측 지도 밝은 해도 스타일 적용 (육지 회색 + 바다 파랑)
|
||||||
|
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
|
||||||
|
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
|
||||||
|
- 오일펜스 배치 가이드 UI 개선
|
||||||
|
- 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일
|
||||||
|
- 오염분석 UI 개선 — HTML 디자인 참고 반영
|
||||||
|
- 범례 UI 개선 — HTML 참고 디자인 반영
|
||||||
|
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- 프론트엔드 포트 변경(5174) + CORS 허용
|
||||||
|
|
||||||
## [2026-03-13]
|
## [2026-03-13]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -40,15 +40,15 @@ export default defineConfig([
|
|||||||
// other options...
|
// other options...
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
import reactX from 'eslint-plugin-react-x'
|
import reactX from 'eslint-plugin-react-x';
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
import reactDom from 'eslint-plugin-react-dom';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
@ -69,5 +69,5 @@ export default defineConfig([
|
|||||||
// other options...
|
// other options...
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import globals from 'globals'
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
@ -20,4 +20,4 @@ export default defineConfig([
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link
|
||||||
<title>frontend</title>
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>해양환경 위기대응 통합지원 시스템</title>
|
||||||
|
<script>
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'data-theme',
|
||||||
|
localStorage.getItem('wing-theme') || 'dark',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
408
frontend/package-lock.json
generated
408
frontend/package-lock.json
generated
@ -32,6 +32,8 @@
|
|||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"react-window": "^2.2.7",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@ -41,6 +43,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
@ -48,6 +51,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
@ -1941,9 +1945,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1955,9 +1959,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1969,9 +1973,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1983,9 +1987,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1997,9 +2001,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2011,9 +2015,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2025,9 +2029,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2039,9 +2043,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2053,9 +2057,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2067,9 +2071,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2081,9 +2085,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2095,9 +2099,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2109,9 +2113,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2123,9 +2127,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2137,9 +2141,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2151,9 +2155,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2165,9 +2169,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -2179,9 +2183,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2193,9 +2197,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2207,9 +2211,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2221,9 +2225,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2235,9 +2239,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2249,9 +2253,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -2263,9 +2267,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2277,9 +2281,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2499,6 +2503,16 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-window": {
|
||||||
|
"version": "1.8.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||||
|
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/supercluster": {
|
"node_modules/@types/supercluster": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
@ -2697,9 +2711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2707,13 +2721,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@ -2859,9 +2873,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2913,9 +2927,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch/node_modules/picomatch": {
|
"node_modules/anymatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -3001,14 +3015,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
@ -3063,9 +3077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3354,6 +3368,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-assert": {
|
"node_modules/core-assert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
||||||
@ -3919,9 +3946,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
||||||
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -4028,16 +4055,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@ -4877,9 +4904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4911,9 +4938,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5142,9 +5169,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5353,6 +5380,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@ -5366,10 +5409,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@ -5439,6 +5485,54 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-window": {
|
||||||
|
"version": "2.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||||
|
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@ -5478,9 +5572,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp/node_modules/picomatch": {
|
"node_modules/readdirp/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5542,9 +5636,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5558,31 +5652,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -5638,6 +5732,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-value": {
|
"node_modules/set-value": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
||||||
@ -5704,9 +5804,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
@ -6188,9 +6288,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -34,6 +34,8 @@
|
|||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"react-window": "^2.2.7",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@ -43,6 +45,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
@ -50,6 +53,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import tailwindcss from 'tailwindcss'
|
import tailwindcss from 'tailwindcss';
|
||||||
import autoprefixer from 'autoprefixer'
|
import autoprefixer from 'autoprefixer';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: [tailwindcss, autoprefixer],
|
plugins: [tailwindcss, autoprefixer],
|
||||||
}
|
};
|
||||||
|
|||||||
2040
frontend/public/data/대한민국.geojson
Normal file
2040
frontend/public/data/대한민국.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
53338
frontend/public/dispersant-consider.geojson
Normal file
53338
frontend/public/dispersant-consider.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
180132
frontend/public/dispersant-restrict.geojson
Normal file
180132
frontend/public/dispersant-restrict.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path d="M4 12 Q16 0 28 12 Q22 15 16 13 Q10 15 4 12 Z" fill="#06b6d4"/>
|
||||||
|
<path d="M4 19 Q10 15 16 19 T28 19 L28 22 Q22 26 16 22 T4 22 Z" fill="#06b6d4"/>
|
||||||
|
<path d="M4 25 Q10 21 16 25 T28 25 L28 28 Q22 32 16 28 T4 28 Z" fill="#06b6d4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 320 B |
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
BIN
frontend/public/fonts/PretendardGOV-Black.otf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user